Files
bitvid/dist/js/accessControl.js
T
google-labs-jules[bot]andPR0M3TH3AN 0bfa3b08e8 fix(ci): fix netlify deployment by building to dist
- Move from root publishing to clean `dist/` directory
- Create `scripts/build-dist.mjs` to orchestrate build and copy assets
- Update `package.json` build script
- Update `netlify.toml` to publish `dist/`
- Ignore `dist/` in `.gitignore`

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-29 09:07:15 +00:00

808 lines
20 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;
}
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);
}
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;
}
}
}
}
const normalized = normalizeNpub(npub);
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() {
return Boolean(isLockdownMode);
}
}
export const accessControl = new AccessControl();
export default accessControl;
export {
AccessControl,
ADMIN_WHITELIST_MODE_STORAGE_KEY,
normalizeNpub,
isValidNpub,
};