mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
A video from a genuinely whitelisted author refused to play with "not from a whitelisted author" (the source URL was confirmed healthy — HTTP 200, video/mp4, range, CORS *). Root cause: accessControl keeps the whitelist in two sets — whitelistPubkeys (hex) and whitelist (npub) — and canAccess consulted only one based on the caller's input format without cross-deriving. playbackCoordinator passes the author's npub, so it checked only the npub set; an entry present in only the hex representation read as "not allowed." - canAccess now derives BOTH hex and npub from the candidate and checks both sets (only fixes false-negatives; a non-whitelisted author is still denied). - Dev-gated diagnostic at the playback rejection logs hex/npub, whitelistMode, set sizes, and inNpubSet/inHexSet to pinpoint any remaining denial. - Tests: tests/access-control-canaccess.test.mjs. Co-Authored-By: Claude Opus 4.8 <[email protected]>
875 lines
23 KiB
JavaScript
875 lines
23 KiB
JavaScript
// js/accessControl.js
|
|
|
|
import {
|
|
ADMIN_EDITORS_NPUBS,
|
|
ADMIN_SUPER_NPUB,
|
|
getWhitelistMode,
|
|
setWhitelistMode as persistWhitelistMode,
|
|
ADMIN_WHITELIST_MODE_STORAGE_KEY,
|
|
isLockdownMode,
|
|
} from "./config.js";
|
|
import {
|
|
loadAdminState,
|
|
persistAdminState,
|
|
readCachedAdminState,
|
|
} from "./adminListStore.js";
|
|
import { ensureNostrTools } from "./nostr/toolkit.js";
|
|
import { userLogger } from "./utils/logger.js";
|
|
|
|
const HEX_KEY_PATTERN = /^[0-9a-f]{64}$/i;
|
|
|
|
function normalizeNpub(value) {
|
|
return typeof value === "string" ? value.trim() : "";
|
|
}
|
|
|
|
function normalizeHexKey(value) {
|
|
if (typeof value !== "string") {
|
|
return "";
|
|
}
|
|
|
|
const trimmed = value.trim().toLowerCase();
|
|
return HEX_KEY_PATTERN.test(trimmed) ? trimmed : "";
|
|
}
|
|
|
|
function bytesToHex(bytes) {
|
|
if (!bytes || typeof bytes !== "object") {
|
|
return "";
|
|
}
|
|
|
|
if (typeof bytes.length !== "number") {
|
|
return "";
|
|
}
|
|
|
|
let hex = "";
|
|
for (const byte of Array.from(bytes)) {
|
|
const normalized = Number(byte);
|
|
if (!Number.isInteger(normalized) || normalized < 0 || normalized > 255) {
|
|
return "";
|
|
}
|
|
hex += normalized.toString(16).padStart(2, "0");
|
|
}
|
|
|
|
return normalizeHexKey(hex);
|
|
}
|
|
|
|
function dedupeNpubs(values) {
|
|
const normalized = Array.isArray(values) ? values.map(normalizeNpub) : [];
|
|
return Array.from(
|
|
normalized.reduce((set, npub) => {
|
|
if (npub) {
|
|
set.add(npub);
|
|
}
|
|
return set;
|
|
}, new Set())
|
|
);
|
|
}
|
|
|
|
function areSetsEqual(first, second) {
|
|
if (first === second) {
|
|
return true;
|
|
}
|
|
|
|
const a = first instanceof Set ? first : new Set();
|
|
const b = second instanceof Set ? second : new Set();
|
|
|
|
if (a.size !== b.size) {
|
|
return false;
|
|
}
|
|
|
|
for (const value of a) {
|
|
if (!b.has(value)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function isValidNpub(npub) {
|
|
if (typeof npub !== "string") {
|
|
return false;
|
|
}
|
|
const trimmed = npub.trim();
|
|
if (!trimmed) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const decoded = window?.NostrTools?.nip19?.decode(trimmed);
|
|
return decoded?.type === "npub";
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
class AccessControl {
|
|
constructor() {
|
|
this.editors = new Set();
|
|
this.whitelist = new Set();
|
|
this.whitelistPubkeys = new Set();
|
|
this.blacklist = new Set();
|
|
this.blacklistPubkeys = new Set();
|
|
this.whitelistEnabled = getWhitelistMode();
|
|
this.hasLoaded = false;
|
|
this.lastError = null;
|
|
this._isRefreshing = false;
|
|
this._refreshPromise = Promise.resolve();
|
|
this._hydratedFromCache = false;
|
|
this._whitelistListeners = new Set();
|
|
this._editorListeners = new Set();
|
|
this._blacklistListeners = new Set();
|
|
|
|
this._scheduleHydrateFromCache();
|
|
}
|
|
|
|
_scheduleHydrateFromCache() {
|
|
const runHydrate = () => {
|
|
try {
|
|
this._hydrateFromCache();
|
|
} catch (error) {
|
|
userLogger.error("accessControl failed to hydrate from cache", error);
|
|
}
|
|
};
|
|
|
|
// Attempt a synchronous hydration so downstream consumers (like
|
|
// bootstrapTrustedSeeds) have immediate access to any cached admin
|
|
// state before they trigger refresh logic. This avoids a race where the
|
|
// async microtask hydration hasn't executed yet when those consumers
|
|
// call into `refresh()` and encounter an empty cache.
|
|
runHydrate();
|
|
|
|
if (typeof queueMicrotask === "function") {
|
|
queueMicrotask(runHydrate);
|
|
return;
|
|
}
|
|
|
|
Promise.resolve().then(runHydrate);
|
|
}
|
|
|
|
_hydrateFromCache() {
|
|
this._hydratedFromCache = false;
|
|
let cachedState = null;
|
|
try {
|
|
cachedState = readCachedAdminState();
|
|
} catch (error) {
|
|
if (error && error.name === "ReferenceError") {
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
if (cachedState) {
|
|
this._applyState(cachedState, { markLoaded: false });
|
|
this._hydratedFromCache = true;
|
|
}
|
|
}
|
|
|
|
_rebuildHexSets(
|
|
whitelistValues = [],
|
|
blacklistValues = [],
|
|
toolkitCandidate = null
|
|
) {
|
|
const whitelistHex = new Set();
|
|
const blacklistHex = new Set();
|
|
|
|
const decodeSources = [];
|
|
const addDecodeSource = (candidate) => {
|
|
if (
|
|
candidate &&
|
|
typeof candidate === "object" &&
|
|
candidate.nip19 &&
|
|
typeof candidate.nip19.decode === "function" &&
|
|
!decodeSources.includes(candidate.nip19)
|
|
) {
|
|
decodeSources.push(candidate.nip19);
|
|
}
|
|
};
|
|
|
|
addDecodeSource(toolkitCandidate);
|
|
|
|
const scopeTools =
|
|
(typeof window !== "undefined" ? window?.NostrTools : null) ||
|
|
(typeof globalThis !== "undefined" ? globalThis?.NostrTools : null) ||
|
|
null;
|
|
addDecodeSource(scopeTools);
|
|
|
|
const decodeNpubToHex = (npub) => {
|
|
const normalized = normalizeNpub(npub);
|
|
if (!normalized) {
|
|
return "";
|
|
}
|
|
|
|
for (const nip19 of decodeSources) {
|
|
try {
|
|
const decoded = nip19.decode(normalized);
|
|
if (!decoded || decoded.type !== "npub") {
|
|
continue;
|
|
}
|
|
const data = decoded.data;
|
|
if (typeof data === "string") {
|
|
const normalizedHex = normalizeHexKey(data);
|
|
if (normalizedHex) {
|
|
return normalizedHex;
|
|
}
|
|
} else {
|
|
const converted = bytesToHex(data);
|
|
if (converted) {
|
|
return converted;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return "";
|
|
};
|
|
|
|
for (const entry of whitelistValues) {
|
|
const decodedHex = decodeNpubToHex(entry);
|
|
if (decodedHex) {
|
|
whitelistHex.add(decodedHex);
|
|
}
|
|
}
|
|
|
|
for (const entry of blacklistValues) {
|
|
const decodedHex = decodeNpubToHex(entry);
|
|
if (decodedHex) {
|
|
blacklistHex.add(decodedHex);
|
|
}
|
|
}
|
|
|
|
this.whitelistPubkeys = whitelistHex;
|
|
this.blacklistPubkeys = blacklistHex;
|
|
}
|
|
|
|
_applyState(state, options = {}) {
|
|
userLogger.info("[accessControl] _applyState called", { state, options });
|
|
const markLoaded = options.markLoaded !== false;
|
|
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 previousEditors =
|
|
this.editors instanceof Set ? new Set(this.editors) : new Set();
|
|
const previousWhitelist =
|
|
this.whitelist instanceof Set ? new Set(this.whitelist) : new Set();
|
|
const previousBlacklist =
|
|
this.blacklist instanceof Set ? new Set(this.blacklist) : new Set();
|
|
|
|
this.editors = new Set(
|
|
dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors])
|
|
);
|
|
const editorsChanged = !areSetsEqual(previousEditors, this.editors);
|
|
const normalizedWhitelist = dedupeNpubs(whitelist);
|
|
this.whitelist = new Set(normalizedWhitelist);
|
|
const whitelistChanged = !areSetsEqual(previousWhitelist, this.whitelist);
|
|
|
|
const blacklistDedupe = dedupeNpubs(blacklist);
|
|
const adminGuardSet = new Set([
|
|
normalizeNpub(ADMIN_SUPER_NPUB),
|
|
...Array.from(this.editors),
|
|
]);
|
|
const sanitizedBlacklist = blacklistDedupe.filter((npub) => {
|
|
const normalized = normalizeNpub(npub);
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
if (adminGuardSet.has(normalized)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
this.blacklist = new Set(sanitizedBlacklist);
|
|
const blacklistChanged = !areSetsEqual(previousBlacklist, this.blacklist);
|
|
|
|
const toolkitCandidate =
|
|
(typeof window !== "undefined" ? window?.NostrTools : null) ||
|
|
(typeof globalThis !== "undefined" ? globalThis?.NostrTools : null);
|
|
this._rebuildHexSets(normalizedWhitelist, sanitizedBlacklist, toolkitCandidate);
|
|
|
|
try {
|
|
const ensured = ensureNostrTools();
|
|
if (ensured && typeof ensured.then === "function") {
|
|
ensured
|
|
.then((tools) => {
|
|
if (!tools) {
|
|
return;
|
|
}
|
|
this._rebuildHexSets(
|
|
Array.from(this.whitelist),
|
|
Array.from(this.blacklist),
|
|
tools
|
|
);
|
|
})
|
|
.catch(() => {});
|
|
} else if (ensured) {
|
|
this._rebuildHexSets(
|
|
Array.from(this.whitelist),
|
|
Array.from(this.blacklist),
|
|
ensured
|
|
);
|
|
}
|
|
} catch (error) {
|
|
// ignore toolkit errors; we'll attempt to hydrate again on the next refresh
|
|
}
|
|
|
|
this.whitelistEnabled = getWhitelistMode();
|
|
this.lastError = null;
|
|
|
|
if (markLoaded) {
|
|
this.hasLoaded = true;
|
|
this._hydratedFromCache = false;
|
|
}
|
|
|
|
if (whitelistChanged) {
|
|
this._emitWhitelistChange(Array.from(this.whitelist));
|
|
}
|
|
if (editorsChanged) {
|
|
this._emitEditorsChange(Array.from(this.editors));
|
|
}
|
|
if (blacklistChanged) {
|
|
this._emitBlacklistChange(Array.from(this.blacklist));
|
|
}
|
|
}
|
|
|
|
_emitWhitelistChange(whitelistValues) {
|
|
if (!this._whitelistListeners.size) {
|
|
return;
|
|
}
|
|
|
|
const snapshot = Array.isArray(whitelistValues)
|
|
? [...whitelistValues]
|
|
: this.getWhitelist();
|
|
|
|
for (const listener of Array.from(this._whitelistListeners)) {
|
|
try {
|
|
listener(snapshot);
|
|
} catch (error) {
|
|
userLogger.error("accessControl whitelist listener failed", error);
|
|
}
|
|
}
|
|
}
|
|
|
|
_emitBlacklistChange(blacklistValues) {
|
|
if (!this._blacklistListeners.size) {
|
|
return;
|
|
}
|
|
|
|
const snapshot = Array.isArray(blacklistValues)
|
|
? [...blacklistValues]
|
|
: this.getBlacklist();
|
|
|
|
for (const listener of Array.from(this._blacklistListeners)) {
|
|
try {
|
|
listener(snapshot);
|
|
} catch (error) {
|
|
userLogger.error("accessControl blacklist listener failed", error);
|
|
}
|
|
}
|
|
}
|
|
|
|
_emitEditorsChange(editorsValues) {
|
|
if (!this._editorListeners.size) {
|
|
return;
|
|
}
|
|
|
|
const snapshot = Array.isArray(editorsValues)
|
|
? [...editorsValues]
|
|
: this.getEditors();
|
|
|
|
for (const listener of Array.from(this._editorListeners)) {
|
|
try {
|
|
listener(snapshot);
|
|
} catch (error) {
|
|
userLogger.error("accessControl editors listener failed", error);
|
|
}
|
|
}
|
|
}
|
|
|
|
_performRefresh() {
|
|
if (this._isRefreshing) {
|
|
return this._refreshPromise;
|
|
}
|
|
|
|
this._isRefreshing = true;
|
|
|
|
const operation = (async () => {
|
|
try {
|
|
const state = await loadAdminState();
|
|
this._applyState(state);
|
|
} catch (error) {
|
|
this.lastError = error;
|
|
if (!this.hasLoaded && !this._hydratedFromCache) {
|
|
this.editors.clear();
|
|
this.whitelist.clear();
|
|
this.whitelistPubkeys.clear();
|
|
this.blacklist.clear();
|
|
this.blacklistPubkeys.clear();
|
|
}
|
|
throw error;
|
|
} finally {
|
|
this._isRefreshing = false;
|
|
}
|
|
})();
|
|
|
|
this._refreshPromise = operation;
|
|
return operation;
|
|
}
|
|
|
|
refresh() {
|
|
if (this._isRefreshing) {
|
|
userLogger.info("[accessControl] refresh ignored (already refreshing)");
|
|
return this._refreshPromise;
|
|
}
|
|
|
|
userLogger.info("[accessControl] refresh started");
|
|
const operation = this._performRefresh();
|
|
const tracked = operation.catch((error) => {
|
|
userLogger.error("Failed to refresh admin lists:", error);
|
|
throw error;
|
|
});
|
|
this._refreshPromise = tracked;
|
|
return tracked;
|
|
}
|
|
|
|
async ensureReady() {
|
|
// If not loaded and not already refreshing, kick off a background refresh.
|
|
// We do NOT await this promise, allowing the application to proceed immediately.
|
|
// Reactive updates (e.g. onBlacklistChange) will handle the UI state when data arrives.
|
|
if (!this.hasLoaded && !this._isRefreshing) {
|
|
this.refresh().catch((error) => {
|
|
userLogger.warn(
|
|
"[accessControl] Background refresh failed:",
|
|
error,
|
|
);
|
|
});
|
|
}
|
|
|
|
// Always resolve immediately to prevent blocking application startup.
|
|
return Promise.resolve();
|
|
}
|
|
|
|
async waitForReady() {
|
|
if (this.hasLoaded) {
|
|
return;
|
|
}
|
|
return this.refresh();
|
|
}
|
|
|
|
whitelistMode() {
|
|
return this.whitelistEnabled;
|
|
}
|
|
|
|
// True once we have usable lists to filter with — either a completed refresh
|
|
// or admin state hydrated synchronously from the localStorage cache at
|
|
// construction. Callers use this to avoid blocking the feed on a remote
|
|
// refresh when cached lists already allow accurate canAccess() checks.
|
|
isHydrated() {
|
|
return this.hasLoaded === true || this._hydratedFromCache === true;
|
|
}
|
|
|
|
isSuperAdmin(npub) {
|
|
const normalized = normalizeNpub(npub);
|
|
return normalized ? normalized === ADMIN_SUPER_NPUB : false;
|
|
}
|
|
|
|
isAdminEditor(npub) {
|
|
const normalized = normalizeNpub(npub);
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
if (this.isSuperAdmin(normalized)) {
|
|
return true;
|
|
}
|
|
return this.editors.has(normalized);
|
|
}
|
|
|
|
canEditAdminLists(npub) {
|
|
return this.isAdminEditor(npub);
|
|
}
|
|
|
|
getWhitelist() {
|
|
return Array.from(this.whitelist);
|
|
}
|
|
|
|
// Hex-encoded whitelist pubkeys (resolved from npubs). Used by NIP-71 ingest
|
|
// to scope its relay subscription to authors this instance already allows.
|
|
getWhitelistPubkeys() {
|
|
return Array.from(this.whitelistPubkeys);
|
|
}
|
|
|
|
getBlacklist() {
|
|
return Array.from(this.blacklist);
|
|
}
|
|
|
|
getEditors() {
|
|
return Array.from(this.editors);
|
|
}
|
|
|
|
onWhitelistChange(listener) {
|
|
if (typeof listener !== "function") {
|
|
return () => {};
|
|
}
|
|
|
|
this._whitelistListeners.add(listener);
|
|
|
|
return () => {
|
|
this._whitelistListeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
onEditorsChange(listener) {
|
|
if (typeof listener !== "function") {
|
|
return () => {};
|
|
}
|
|
|
|
this._editorListeners.add(listener);
|
|
|
|
return () => {
|
|
this._editorListeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
onBlacklistChange(listener) {
|
|
if (typeof listener !== "function") {
|
|
return () => {};
|
|
}
|
|
|
|
this._blacklistListeners.add(listener);
|
|
|
|
return () => {
|
|
this._blacklistListeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
async addModerator(requestorNpub, moderatorNpub) {
|
|
if (!this.isSuperAdmin(requestorNpub)) {
|
|
return { ok: false, error: "forbidden" };
|
|
}
|
|
if (!isValidNpub(moderatorNpub)) {
|
|
return { ok: false, error: "invalid npub" };
|
|
}
|
|
|
|
const normalized = normalizeNpub(moderatorNpub);
|
|
if (!normalized || normalized === ADMIN_SUPER_NPUB) {
|
|
return { ok: false, error: "immutable" };
|
|
}
|
|
|
|
const nextEditors = dedupeNpubs([...this.getEditors(), normalized]);
|
|
|
|
try {
|
|
await persistAdminState(requestorNpub, { editors: nextEditors });
|
|
await this.refresh();
|
|
return { ok: true };
|
|
} catch (error) {
|
|
return { ok: false, error: error?.code || "storage-error" };
|
|
}
|
|
}
|
|
|
|
async removeModerator(requestorNpub, moderatorNpub) {
|
|
if (!this.isSuperAdmin(requestorNpub)) {
|
|
return { ok: false, error: "forbidden" };
|
|
}
|
|
|
|
const normalized = normalizeNpub(moderatorNpub);
|
|
if (!normalized || normalized === ADMIN_SUPER_NPUB) {
|
|
return { ok: false, error: "immutable" };
|
|
}
|
|
const nextEditors = this.getEditors().filter((value) => value !== normalized);
|
|
|
|
try {
|
|
await persistAdminState(requestorNpub, { editors: nextEditors });
|
|
await this.refresh();
|
|
return { ok: true };
|
|
} catch (error) {
|
|
return { ok: false, error: error?.code || "storage-error" };
|
|
}
|
|
}
|
|
|
|
async addToWhitelist(actorNpub, targetNpub) {
|
|
if (!this.canEditAdminLists(actorNpub)) {
|
|
return { ok: false, error: "forbidden" };
|
|
}
|
|
if (!isValidNpub(targetNpub)) {
|
|
return { ok: false, error: "invalid npub" };
|
|
}
|
|
|
|
const normalized = normalizeNpub(targetNpub);
|
|
if (!normalized) {
|
|
return { ok: false, error: "invalid npub" };
|
|
}
|
|
|
|
const nextWhitelist = dedupeNpubs([...this.getWhitelist(), normalized]);
|
|
const nextBlacklist = this.getBlacklist();
|
|
|
|
try {
|
|
await persistAdminState(actorNpub, {
|
|
whitelist: nextWhitelist,
|
|
blacklist: nextBlacklist,
|
|
});
|
|
await this.refresh();
|
|
return { ok: true };
|
|
} catch (error) {
|
|
return { ok: false, error: error?.code || "storage-error" };
|
|
}
|
|
}
|
|
|
|
async removeFromWhitelist(actorNpub, targetNpub) {
|
|
if (!this.canEditAdminLists(actorNpub)) {
|
|
return { ok: false, error: "forbidden" };
|
|
}
|
|
|
|
const normalized = normalizeNpub(targetNpub);
|
|
if (!normalized) {
|
|
return { ok: false, error: "invalid npub" };
|
|
}
|
|
const nextWhitelist = this.getWhitelist().filter(
|
|
(value) => value !== normalized
|
|
);
|
|
|
|
try {
|
|
await persistAdminState(actorNpub, { whitelist: nextWhitelist });
|
|
await this.refresh();
|
|
return { ok: true };
|
|
} catch (error) {
|
|
return { ok: false, error: error?.code || "storage-error" };
|
|
}
|
|
}
|
|
|
|
async addToBlacklist(actorNpub, targetNpub) {
|
|
if (!this.canEditAdminLists(actorNpub)) {
|
|
return { ok: false, error: "forbidden" };
|
|
}
|
|
if (!isValidNpub(targetNpub)) {
|
|
return { ok: false, error: "invalid npub" };
|
|
}
|
|
|
|
const normalized = normalizeNpub(targetNpub);
|
|
if (!normalized) {
|
|
return { ok: false, error: "invalid npub" };
|
|
}
|
|
|
|
const normalizedActor = normalizeNpub(actorNpub);
|
|
if (normalizedActor && normalized === normalizedActor) {
|
|
return { ok: false, error: "self" };
|
|
}
|
|
|
|
if (this.isSuperAdmin(normalized) || this.isAdminEditor(normalized)) {
|
|
return { ok: false, error: "immutable" };
|
|
}
|
|
|
|
const nextBlacklist = dedupeNpubs([...this.getBlacklist(), normalized]);
|
|
const nextWhitelist = this.getWhitelist();
|
|
|
|
try {
|
|
await persistAdminState(actorNpub, {
|
|
blacklist: nextBlacklist,
|
|
whitelist: nextWhitelist,
|
|
});
|
|
await this.refresh();
|
|
return { ok: true };
|
|
} catch (error) {
|
|
return { ok: false, error: error?.code || "storage-error" };
|
|
}
|
|
}
|
|
|
|
async removeFromBlacklist(actorNpub, targetNpub) {
|
|
if (!this.canEditAdminLists(actorNpub)) {
|
|
return { ok: false, error: "forbidden" };
|
|
}
|
|
|
|
const normalized = normalizeNpub(targetNpub);
|
|
if (!normalized) {
|
|
return { ok: false, error: "invalid npub" };
|
|
}
|
|
const nextBlacklist = this.getBlacklist().filter(
|
|
(value) => value !== normalized
|
|
);
|
|
|
|
try {
|
|
await persistAdminState(actorNpub, { blacklist: nextBlacklist });
|
|
await this.refresh();
|
|
return { ok: true };
|
|
} catch (error) {
|
|
return { ok: false, error: error?.code || "storage-error" };
|
|
}
|
|
}
|
|
|
|
setWhitelistMode(actorNpub, enabled) {
|
|
if (!this.isSuperAdmin(actorNpub)) {
|
|
return { ok: false, error: "forbidden" };
|
|
}
|
|
persistWhitelistMode(!!enabled);
|
|
this.whitelistEnabled = !!enabled;
|
|
return { ok: true };
|
|
}
|
|
|
|
isBlacklisted(npub) {
|
|
const normalized = normalizeNpub(npub);
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
|
|
const normalizedHexCandidate = normalizeHexKey(normalized);
|
|
if (normalizedHexCandidate) {
|
|
return this.blacklistPubkeys.has(normalizedHexCandidate);
|
|
}
|
|
|
|
return this.blacklist.has(normalized);
|
|
}
|
|
|
|
canAccess(candidate) {
|
|
let npub = "";
|
|
let hex = "";
|
|
|
|
const considerHexCandidate = (value) => {
|
|
const normalizedHex = normalizeHexKey(value);
|
|
if (!normalizedHex) {
|
|
return false;
|
|
}
|
|
hex = normalizedHex;
|
|
return true;
|
|
};
|
|
|
|
if (typeof candidate === "string") {
|
|
if (!considerHexCandidate(candidate)) {
|
|
npub = candidate;
|
|
}
|
|
} else if (candidate && typeof candidate === "object") {
|
|
if (typeof candidate.npub === "string") {
|
|
npub = candidate.npub;
|
|
} else if (typeof candidate.pubkey === "string") {
|
|
if (!considerHexCandidate(candidate.pubkey)) {
|
|
try {
|
|
npub = window.NostrTools.nip19.npubEncode(candidate.pubkey);
|
|
} catch (error) {
|
|
npub = candidate.pubkey;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let normalized = normalizeNpub(npub);
|
|
|
|
// Cross-derive the missing representation. `whitelistPubkeys`/`blacklistPubkeys`
|
|
// are hex-keyed while `whitelist`/`blacklist` are npub-keyed; both are built
|
|
// from the same admin lists, but a format mismatch between the caller's input
|
|
// and how a given entry was stored must NOT read as "not allowed". Filling in
|
|
// both forms lets the checks below consult both sets.
|
|
const accessTools =
|
|
(typeof window !== "undefined" ? window?.NostrTools : null) ||
|
|
(typeof globalThis !== "undefined" ? globalThis?.NostrTools : null) ||
|
|
null;
|
|
if (normalized && !hex && typeof accessTools?.nip19?.decode === "function") {
|
|
try {
|
|
const decoded = accessTools.nip19.decode(normalized);
|
|
if (decoded?.type === "npub") {
|
|
hex = normalizeHexKey(
|
|
typeof decoded.data === "string"
|
|
? decoded.data
|
|
: bytesToHex(decoded.data)
|
|
);
|
|
}
|
|
} catch (error) {
|
|
// ignore — fall back to the npub-only checks
|
|
}
|
|
}
|
|
if (hex && !normalized && typeof accessTools?.nip19?.npubEncode === "function") {
|
|
try {
|
|
normalized = normalizeNpub(accessTools.nip19.npubEncode(hex));
|
|
} catch (error) {
|
|
// ignore — fall back to the hex-only checks
|
|
}
|
|
}
|
|
|
|
if (!normalized) {
|
|
if (!hex) {
|
|
return false;
|
|
}
|
|
|
|
if (this.isLockdownActive()) {
|
|
return false;
|
|
}
|
|
|
|
if (this.blacklistPubkeys.has(hex)) {
|
|
return false;
|
|
}
|
|
|
|
if (this.whitelistPubkeys.has(hex)) {
|
|
return true;
|
|
}
|
|
|
|
if (this.whitelistEnabled && !this.whitelistPubkeys.has(hex)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
if (this.isAdminEditor(normalized)) {
|
|
return true;
|
|
}
|
|
|
|
if (this.isLockdownActive()) {
|
|
return false;
|
|
}
|
|
|
|
if (hex) {
|
|
if (this.blacklistPubkeys.has(hex)) {
|
|
return false;
|
|
}
|
|
if (this.whitelistPubkeys.has(hex)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (this.blacklist.has(normalized)) {
|
|
return false;
|
|
}
|
|
|
|
if (this.whitelist.has(normalized)) {
|
|
return true;
|
|
}
|
|
|
|
if (this.whitelistEnabled && !this.whitelist.has(normalized)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
isLockdownActive() {
|
|
// Bypass lockdown in test mode
|
|
if (typeof window !== "undefined") {
|
|
try {
|
|
const hostname = window.location.hostname;
|
|
const isLocal =
|
|
hostname === "localhost" ||
|
|
hostname === "127.0.0.1" ||
|
|
hostname === "[::1]";
|
|
const params = new URLSearchParams(window.location.search);
|
|
|
|
if (
|
|
isLocal &&
|
|
(params.get("__test__") === "1" ||
|
|
localStorage.getItem("__bitvidTestMode__") === "1")
|
|
) {
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
// ignore
|
|
}
|
|
}
|
|
return Boolean(isLockdownMode);
|
|
}
|
|
}
|
|
|
|
export const accessControl = new AccessControl();
|
|
export default accessControl;
|
|
export {
|
|
AccessControl,
|
|
ADMIN_WHITELIST_MODE_STORAGE_KEY,
|
|
normalizeNpub,
|
|
isValidNpub,
|
|
};
|