mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
feat: refine trust controls and harden BitLogin
This commit is contained in:
1 parent
f25c2ac242
commit
18e08bf2cb
32 files changed
+1207
-740
No files matched your search
+29
-22
@@ -47,8 +47,8 @@
|
||||
>
|
||||
<div class="space-y-2 text-left">
|
||||
<p class="text-sm text-text-muted">
|
||||
Sign in with a login name and password that works the same on every
|
||||
BitLogin site, or use one of the other options below.
|
||||
Use BitLogin for a password-style sign-in, or connect the Nostr
|
||||
identity you already use. Both keep you in control of your keys.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,12 @@
|
||||
id="bitloginMount"
|
||||
class="rounded-3xl border border-border bg-overlay-panel-soft/90 p-4 shadow-lg backdrop-blur-sm sm:p-5"
|
||||
>
|
||||
<div class="mb-3 space-y-1">
|
||||
<h3 class="text-base font-semibold text-text">BitLogin</h3>
|
||||
<p class="text-sm text-text-muted">
|
||||
A portable login name and password for BitLogin-enabled sites.
|
||||
</p>
|
||||
</div>
|
||||
<bitlogin-auth
|
||||
id="bitloginWidget"
|
||||
vault-relays="wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band,wss://nostr.wine,wss://relay.snort.social"
|
||||
@@ -70,27 +76,28 @@
|
||||
></bitlogin-auth>
|
||||
</div>
|
||||
|
||||
<details class="space-y-3">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-text-muted hover:text-text">
|
||||
Other login options
|
||||
</summary>
|
||||
<section
|
||||
class="mt-3 space-y-5 rounded-3xl border border-border bg-overlay-panel-soft/90 p-4 shadow-lg backdrop-blur-sm sm:p-5"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-base font-semibold text-text">Available methods</h3>
|
||||
<p class="text-sm text-text-muted">
|
||||
Pick the option that matches how you manage your Nostr identity.
|
||||
</p>
|
||||
</div>
|
||||
<section
|
||||
class="space-y-5 rounded-3xl border border-accent/40 bg-overlay-panel-soft/90 p-4 shadow-lg backdrop-blur-sm sm:p-5"
|
||||
aria-labelledby="nostrLoginOptionsTitle"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-accent">
|
||||
Nostr sign-in
|
||||
</p>
|
||||
<h3 id="nostrLoginOptionsTitle" class="text-base font-semibold text-text">
|
||||
Use your existing Nostr identity
|
||||
</h3>
|
||||
<p class="text-sm text-text-muted">
|
||||
Browser extension (NIP-07), remote signer (NIP-46), or your own key.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Login provider buttons are rendered dynamically -->
|
||||
<div
|
||||
data-login-providers
|
||||
class="grid auto-rows-fr gap-4 sm:grid-cols-2 xl:grid-cols-3"
|
||||
></div>
|
||||
</section>
|
||||
</details>
|
||||
<!-- Login provider buttons are rendered dynamically -->
|
||||
<div
|
||||
data-login-providers
|
||||
class="grid auto-rows-fr gap-4 sm:grid-cols-2 xl:grid-cols-3"
|
||||
></div>
|
||||
</section>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1 text-left">
|
||||
|
||||
@@ -287,7 +287,7 @@ export const FEATURE_BITCOIN_CONNECT = true;
|
||||
* Unstable-branch experiment (AGENTS.md §1) -- reset to `false` before this
|
||||
* reaches main until verified end-to-end against production relays.
|
||||
*/
|
||||
export const FEATURE_BITLOGIN = true;
|
||||
export const FEATURE_BITLOGIN = false;
|
||||
|
||||
/**
|
||||
* Whether this deployment offers Blossom (nostr-native blob storage) as a
|
||||
@@ -372,9 +372,9 @@ export const DEFAULT_TRUST_SEED_NPUBS = [
|
||||
* When a video receives at least this many trusted reports for the active
|
||||
* category (for example, `nudity`), thumbnails render with the blurred preview
|
||||
* by default. Align this value with your moderation policy — the upstream
|
||||
* deployment targets 1 trusted report before blurring.
|
||||
* deployment requires 3 trusted reports before blurring.
|
||||
*/
|
||||
export const DEFAULT_BLUR_THRESHOLD = 1;
|
||||
export const DEFAULT_BLUR_THRESHOLD = 3;
|
||||
|
||||
/**
|
||||
* Baseline trusted-report count that blocks autoplay during browsing.
|
||||
@@ -382,9 +382,9 @@ export const DEFAULT_BLUR_THRESHOLD = 1;
|
||||
* Videos that meet or exceed this count will not autoplay in feeds unless the
|
||||
* viewer explicitly opts in. Increase the number for a more permissive stance
|
||||
* or decrease it if you want autoplay to stop sooner. The upstream deployment
|
||||
* targets 1 trusted report before blocking autoplay.
|
||||
* requires 3 trusted reports before blocking autoplay.
|
||||
*/
|
||||
export const DEFAULT_AUTOPLAY_BLOCK_THRESHOLD = 1;
|
||||
export const DEFAULT_AUTOPLAY_BLOCK_THRESHOLD = 3;
|
||||
|
||||
/**
|
||||
* Trusted mute threshold that hides creators globally.
|
||||
@@ -401,10 +401,10 @@ export const DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD = 20;
|
||||
*
|
||||
* When a video's trusted report count reaches this number, bitvid treats it as
|
||||
* spam or policy violating content and removes it from default feeds. Align
|
||||
* the value with your moderation policy — 1 trusted report is the upstream
|
||||
* the value with your moderation policy — 5 trusted reports is the current
|
||||
* baseline.
|
||||
*/
|
||||
export const DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD = 1;
|
||||
export const DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD = 5;
|
||||
|
||||
/**
|
||||
* Optional theme accent overrides for light and dark mode.
|
||||
|
||||
@@ -56,7 +56,7 @@ To maintain a functional and ethical platform, the following types of content ar
|
||||
|
||||
bitvid enforces these guidelines through client-side visibility rules that match the current `moreMenuController` and `userBlocks` behavior:
|
||||
|
||||
- **Blurred previews:** When trusted NIP-56 `nudity` reports reach the configured blur threshold (`DEFAULT_BLUR_THRESHOLD` in [`config/instance-config.js`](../config/instance-config.js)), thumbnails render blurred and autoplay previews pause. Every blur includes a “show anyway” override. The upstream configuration currently uses one trusted report, but adjust the export to match your policy.
|
||||
- **Blurred previews:** When trusted NIP-56 `nudity` reports reach the configured blur threshold (`DEFAULT_BLUR_THRESHOLD` in [`config/instance-config.js`](../config/instance-config.js)), thumbnails render blurred and autoplay previews pause. Every blur includes a “show anyway” override. The hosted default requires three trusted reports.
|
||||
- **Downranked authors:** Muting a creator adds them to your NIP-51 mute list (kind `10000`). Videos from muted creators are deprioritized but still visible with clear badges so you can reverse the action.
|
||||
- **Personal blocks:** Blocking a creator adds them to your private block list (`userBlocks`). Videos from blocked creators are hidden from feeds, carousels, and modals unless you explicitly unblock them.
|
||||
- **Admin hard hides:** Moderator-maintained blacklists (NIP-51 kind `30000`) remove creators from default feeds for everyone using the hosted client. Moderators can still review the content through “show anyway” controls when auditing decisions.
|
||||
@@ -88,7 +88,7 @@ You can manage moderation directly from the client using the existing menus rend
|
||||
|
||||
### **5.5 Safety & Moderation thresholds**
|
||||
|
||||
- Open your profile avatar → **Profile** → **Safety & Moderation** to adjust blur and autoplay thresholds. Leave a field blank to return to the defaults exported from [`config/instance-config.js`](../config/instance-config.js) (`DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD`). The upstream repo ships example values of 1 trusted report for blur, 1 for autoplay blocking, 20 trusted mutes to hide authors, and 1 trusted spam report to hide videos—tune the config file before deployment if your community needs different baselines.
|
||||
- Open your profile avatar → **Profile** → **Safety & Moderation** to adjust blur and autoplay thresholds. Leave a field blank to return to the defaults exported from [`config/instance-config.js`](../config/instance-config.js) (`DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD`). The hosted defaults require 3 trusted reports for blur and autoplay blocking, 20 trusted mutes to hide authors, and 5 trusted spam reports to hide videos—tune the config file before deployment if your community needs different baselines.
|
||||
- These controls update immediately and sync with the moderation overlays applied by `moreMenuController` and `VideoCard` components. Operators can edit the config file to change the baseline before deploying their own instance.
|
||||
|
||||
## **6. Appeals and Feedback**
|
||||
|
||||
@@ -751,6 +751,22 @@
|
||||
transition-property: border-color, box-shadow, transform;
|
||||
}
|
||||
|
||||
.site-update-notice {
|
||||
@apply fixed bottom-5 right-5 z-50 flex max-w-sm flex-col items-end gap-3;
|
||||
}
|
||||
|
||||
.site-update-notice__button {
|
||||
@apply btn focus-ring rounded-full px-4 py-3 text-sm shadow-lg;
|
||||
}
|
||||
|
||||
.site-update-notice__panel {
|
||||
@apply w-full rounded-2xl border border-border bg-overlay-panel-soft p-4 text-left text-text shadow-modal backdrop-blur-sm;
|
||||
}
|
||||
|
||||
.site-update-notice__close {
|
||||
@apply btn-ghost focus-ring -mr-2 -mt-2 h-9 w-9 rounded-full p-0 text-lg leading-none;
|
||||
}
|
||||
|
||||
.login-option::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -4514,6 +4530,39 @@
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* The player owns a single scroll region. On mobile, `100vh` can extend
|
||||
underneath the browser controls/home indicator, which previously made the
|
||||
final composer action hard to reach. Prefer the dynamic viewport and keep
|
||||
scrolling on the panel (where the sticky player navigation listens). */
|
||||
@media (max-width: calc(theme("screens.lg") - 0.02px)) {
|
||||
#playerModal {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#playerModal > .bv-modal__panel {
|
||||
block-size: 100%;
|
||||
min-block-size: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
#playerModal .player-modal__container {
|
||||
block-size: auto;
|
||||
min-block-size: 100%;
|
||||
}
|
||||
|
||||
#playerModal .comment-composer {
|
||||
padding-bottom: max(var(--space-lg), env(safe-area-inset-bottom));
|
||||
scroll-margin-block-end: max(var(--space-lg), env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
#modalVideo {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
|
||||
@@ -9,7 +9,7 @@ Instead of a centralized moderation authority, the service builds a local "trust
|
||||
- **Viewer**: The currently logged-in user.
|
||||
- **Trusted Contacts**: Users followed by the Viewer (Kind 3 contact list).
|
||||
- **Trusted Seeds**: An optional set of admin-defined pubkeys that provide a baseline of trust (e.g., project maintainers).
|
||||
- **Admin Whitelist/Blacklist**: Hardcoded lists in `accessControl` that override dynamic trust.
|
||||
- **Admin Whitelist/Blacklist**: Nostr-backed lists in `accessControl`. The creator whitelist controls access only; a blacklist always filters first and suppresses its members' content and reports.
|
||||
|
||||
### 2. Trust Aggregation
|
||||
When a piece of content (Event) is reported (Kind 1984), the service aggregates these reports but *only counts reports from trusted users*.
|
||||
|
||||
@@ -6,7 +6,7 @@ bitvid is follow-centric. Your Home feed comes from people you follow (F1). Disc
|
||||
## Core principles
|
||||
- **Freedom to choose**: User picks filters. Defaults are safe but reversible.
|
||||
- **Explain decisions**: Every blur/hide shows a “why” badge and a “show anyway” control.
|
||||
- **Blacklist > whitelist**: Admin blacklists and moderation gates always win; whitelists only influence Discovery ranking.
|
||||
- **Separate access and trust**: The creator whitelist controls site and content access only; moderation uses the viewer's trust graph and admin blacklists.
|
||||
- **Minimal central power**: Admin lists are opt-in; users can unsubscribe.
|
||||
|
||||
## Threat model (short)
|
||||
@@ -47,10 +47,10 @@ Thread new moderation behaviors through the same service → stage → app → U
|
||||
- Hide videos when `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` trusted contacts mute the author. Cards render with `data-moderation-hidden="true"`, the badge reads `Hidden · {count} trusted mute(s)`, and a "Show anyway" button becomes available alongside a "Restore default moderation" control once the override is active.
|
||||
- Hide videos when `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` trusted contacts file spam reports. The badge copy escalates to `Hidden · {count} trusted spam report(s)` and the card stays hidden until the viewer overrides it.
|
||||
- Downrank author when any F1 has them in mute list (10000).
|
||||
- Opt-in admin lists (30000 with `d=bitvid:admin:*`) can hard-hide content, but whitelist entries no longer bypass moderation gates—they only influence Discovery rankings when a viewer opts into that list.
|
||||
- Opt-in admin lists (30000 with `d=bitvid:admin:*`) can hard-hide content through the blacklist. The creator whitelist is access-only and does not change web-of-trust blur, autoplay, or hide decisions.
|
||||
- Trust seeds now come from the Super Admin plus every active moderator, so their reports shape anonymous/default visitor filters automatically. The `DEFAULT_TRUST_SEED_NPUBS` export only activates as an emergency fallback when those live lists cannot be loaded (toggle via `FEATURE_TRUST_SEEDS`).
|
||||
|
||||
> Default thresholds now come directly from [`config/instance-config.js`](../../config/instance-config.js). Look for the exports `DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, and `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` to adjust the instance-wide behavior before deploying. The upstream repo currently publishes example values of 1 trusted report for blur, 1 for autoplay blocking, 20 trusted mutes to hide authors, and 1 trusted spam report to hide videos.
|
||||
> Default thresholds now come directly from [`config/instance-config.js`](../../config/instance-config.js). Look for the exports `DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, and `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` to adjust the instance-wide behavior before deploying. The hosted defaults require 3 trusted reports for blur and autoplay blocking, 20 trusted mutes to hide authors, and 5 trusted spam reports to hide videos.
|
||||
|
||||
> Operators can still override these thresholds on a per-viewer basis in **Settings → Safety & Moderation**; leaving the UI fields blank restores the config-driven defaults. Update [`config/instance-config.js`](../../config/instance-config.js) to change what new viewers receive out of the box.
|
||||
|
||||
@@ -89,7 +89,7 @@ The trusted mute hide helper copy clarifies the escalation path: once the truste
|
||||
### Runtime flags
|
||||
|
||||
- `TRUSTED_MUTE_HIDE_THRESHOLD` — numeric default for the trusted mute hide control. Initialized from `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); the upstream config sets this to `20`, but adjust the export to match your policy.
|
||||
- `TRUSTED_SPAM_HIDE_THRESHOLD` — numeric default for the trusted spam hide control. Initialized from `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); the upstream config example is `1`.
|
||||
- `TRUSTED_SPAM_HIDE_THRESHOLD` — numeric default for the trusted spam hide control. Initialized from `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); the hosted default is `5`.
|
||||
- `FEATURE_TRUSTED_HIDE_CONTROLS` — boolean toggle to hide/show the new trusted hide controls in the UI (default `true`).
|
||||
|
||||
## Files in this folder
|
||||
|
||||
@@ -40,8 +40,8 @@ Create three distinct F1 reporter keys; each sends:
|
||||
Expected:
|
||||
|
||||
* After 1 report → no blur; autoplay allowed.
|
||||
* Once reports reach `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD` → autoplay blocked (the upstream configuration currently sets this to 2, but rely on your `config/instance-config.js` value).
|
||||
* Once reports reach `DEFAULT_BLUR_THRESHOLD` → thumbnail blurred; reason chip shown (upstream example: 1). At the spam threshold (`TRUSTED_SPAM_HIDE_THRESHOLD` sourced from `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js)) the card hides entirely and the badge copy switches to "Hidden · {count} trusted spam report(s)" until the viewer overrides it.
|
||||
* Once reports reach `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD` → autoplay blocked (the hosted configuration currently sets this to 3, but rely on your `config/instance-config.js` value).
|
||||
* Once reports reach `DEFAULT_BLUR_THRESHOLD` → thumbnail blurred; reason chip shown (the hosted default is 3). At the spam threshold (`TRUSTED_SPAM_HIDE_THRESHOLD` sourced from `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js), currently 5) the card hides entirely and the badge copy switches to "Hidden · {count} trusted spam report(s)" until the viewer overrides it.
|
||||
|
||||
**Exercises:** [`ModerationService.ingestReportEvent()`](../../js/services/moderationService.js) and [`ModerationService.getTrustedReportSummary()`](../../js/services/moderationService.js) wire through [`createModerationStage()`](../../js/feedEngine/stages.js) so [`bitvidApp.decorateVideoModeration()`](../../js/app.js) can hand the summary to [`VideoCard.refreshModerationUi()`](../../js/ui/components/VideoCard.js).
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ list. Blocking still follows the precedence rules above.
|
||||
- A video has 3 `nudity` reports from those seed accounts.
|
||||
|
||||
Result:
|
||||
- Thumbnail is blurred if `DEFAULT_BLUR_THRESHOLD` is 1 (met).
|
||||
- Autoplay is blocked if `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD` is 1 (met).
|
||||
- Thumbnail is blurred because the 3 trusted reports meet `DEFAULT_BLUR_THRESHOLD`.
|
||||
- Autoplay is blocked because the 3 trusted reports meet `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`.
|
||||
- No hard hide unless trusted mute/spam thresholds are met.
|
||||
|
||||
### Example 2: Logged-in viewer with personal blocks
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- `hideIfTrustedMuteCount(author, category) >= trustedMuteHideThresholds[category] ?? DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`
|
||||
- `hideIfTrustedSpamReports(event) >= DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD`
|
||||
|
||||
Threshold constants are exported from [`config/instance-config.js`](../../config/instance-config.js) so operators can change the defaults without touching moderation code. Inspect the `DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, and `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` exports to set your policy. The upstream repo includes example values (blur at 1, autoplay block at 1, trusted mute hide at 20, trusted spam hide at 1), but treat those as guidance rather than hard-coded requirements. Per-category trusted mute hide thresholds now live in moderation settings as `trustedMuteHideThresholds` (e.g., `{ spam: 5, nudity: 10 }`), and any category not present falls back to the default trusted mute hide threshold. When evaluating trusted mutes we use the content's report type if available, otherwise we fall back to any category metadata attached to trusted mute list entries.
|
||||
Threshold constants are exported from [`config/instance-config.js`](../../config/instance-config.js) so operators can change the defaults without touching moderation code. The hosted baseline requires 3 trusted reports to blur or block autoplay, 20 trusted mutes to hide an author, and 5 trusted spam reports to hide a video. Per-category trusted mute hide thresholds now live in moderation settings as `trustedMuteHideThresholds` (e.g., `{ spam: 5, nudity: 10 }`), and any category not present falls back to the default trusted mute hide threshold. When evaluating trusted mutes we use the content's report type if available, otherwise we fall back to any category metadata attached to trusted mute list entries.
|
||||
|
||||
### Trusted mute decay window
|
||||
- Trusted mute counts are **time-bounded**: only mute lists updated within the rolling window (currently 60 days) contribute to `trustedMuteCount`.
|
||||
@@ -34,7 +34,7 @@ Threshold constants are exported from [`config/instance-config.js`](../../config
|
||||
## Admin lists (opt-in)
|
||||
- We recognize curated lists using `30000` events:
|
||||
- `['d','bitvid:admin:blacklist']` → hard-hide when subscribed.
|
||||
- `['d','bitvid:admin:whitelist']` → improves Discovery ranking when subscribed but no longer bypasses moderation gates.
|
||||
- `['d','bitvid:admin:whitelist']` → controls creator access only; it does not add a reporter to the trust graph or alter moderation decisions.
|
||||
- `['d','bitvid:admin:editors']` → trusted channel editors.
|
||||
- Users can subscribe/unsubscribe any time.
|
||||
|
||||
@@ -42,7 +42,7 @@ Threshold constants are exported from [`config/instance-config.js`](../../config
|
||||
|
||||
1. **Personal blocks win first.** If a viewer blocks an author or reporter, we ignore their content and reports regardless of admin lists.
|
||||
2. **Admin blacklist applies next.** Entries on `bitvid:admin:blacklist` are hard-hidden and their reports suppressed before looking at thresholds.
|
||||
3. **F1 thresholds run last.** Blur/autoplay gating only evaluates trusted-report counts after personal blocks and admin blacklists. Admin whitelists contribute to Discovery ranking when a viewer subscribes, but they never override moderation gates or a viewer block.
|
||||
3. **F1 thresholds run last.** Blur/autoplay gating evaluates trusted-report counts after personal blocks and admin blacklists. The creator whitelist is not an input to these decisions.
|
||||
|
||||
## Pseudocode
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Instead of a central authority deciding what is "safe," the service aggregates r
|
||||
- Users can report content for reasons like `nudity`, `spam`, `illegal`, etc.
|
||||
- Reports are public events signed by the reporter.
|
||||
- The service subscribes to reports for active content and aggregates them locally.
|
||||
- **Crucially**: Reports from users *outside* the trusted contact list are ignored (unless whitelisted by admin).
|
||||
- **Crucially**: Reports from users *outside* the trusted contact list are ignored. Creator whitelist membership does not establish moderation trust.
|
||||
|
||||
### NIP-51 Mutes (Kind 10000)
|
||||
- Users can mute other users to hide their content entirely.
|
||||
@@ -28,7 +28,7 @@ Instead of a central authority deciding what is "safe," the service aggregates r
|
||||
- It also supports "Trusted Mutes" where mutes from trusted contacts can influence visibility (though this feature is evolving).
|
||||
|
||||
### Admin Overrides
|
||||
- The service integrates with `AccessControl` to respect an instance-wide **Blacklist** (always hidden) and **Whitelist** (always trusted).
|
||||
- The service integrates with `AccessControl` to respect an instance-wide **Blacklist** (always hidden). The creator whitelist is access-only and never establishes moderation trust.
|
||||
|
||||
## Architecture & Data Flow
|
||||
|
||||
@@ -89,7 +89,7 @@ await moderationService.submitReport({
|
||||
## Invariants
|
||||
|
||||
- **Trust is Subjective**: A report only counts if the *viewer* follows the reporter. Changing the viewer changes all trust scores immediately.
|
||||
- **Admin Trumps Trust**: Blacklisted users are ignored even if followed. Whitelisted users are trusted even if not followed.
|
||||
- **Admin Trumps Trust**: Blacklisted users are ignored even if followed. Creator whitelist membership neither grants nor removes trust.
|
||||
- **Reactive**: The service emits events (`summary`, `contacts`, `user-blocks`) to drive UI updates; it does not directly manipulate the DOM.
|
||||
|
||||
## When to Change
|
||||
|
||||
@@ -38,7 +38,7 @@ Bootstrapping is intentionally defensive so the UI can initialize quickly:
|
||||
- **Access control hydration**: `waitForAccessControl()` races `accessControl.ensureReady()` against a 3.5s timeout. If hydration times out, bootstrapping logs a dev warning and falls back to defaults.
|
||||
- **Immediate apply**: Seeds are applied immediately after the first hydration attempt, even if the admin list hydration failed, so moderation does not block on relay availability.
|
||||
- **Retry when relays are ready**: If hydration failed, `bootstrapTrustedSeeds()` checks for relay readiness via `nostrService.nostrClient.relays` (and pool existence). If relays become available within 3.5s, it retries admin list hydration once. If relays never become ready, it logs a dev warning and re-applies the current seeds to keep moderation functional.
|
||||
- **Change listeners**: `accessControl.onWhitelistChange()` and `accessControl.onEditorsChange()` are registered so any subsequent admin/editor updates re-run seed application and recompute moderation summaries.
|
||||
- **Change listeners**: `accessControl.onEditorsChange()` and `accessControl.onBlacklistChange()` are registered so changes affecting the moderator roster or eligibility re-run seed application and recompute moderation summaries. Creator-whitelist changes do not affect trust seeds.
|
||||
|
||||
## Relay list persistence and refresh
|
||||
|
||||
|
||||
@@ -1135,13 +1135,14 @@ export function createModerationStage({
|
||||
trustedMuteCategory,
|
||||
);
|
||||
|
||||
const adminWhitelist = adminStatus?.whitelisted === true;
|
||||
const adminWhitelistBypass = false;
|
||||
|
||||
const blockAutoplay =
|
||||
trustedCount >= normalizedAutoplayThreshold || trustedMuted || viewerMuted;
|
||||
const blurFromReports = trustedCount >= normalizedBlurThreshold;
|
||||
let blurThumbnail = blurFromReports;
|
||||
let blurReason = blurThumbnail ? "trusted-report" : "";
|
||||
const adminWhitelist = adminStatus?.whitelisted === true;
|
||||
const adminWhitelistBypass = false;
|
||||
|
||||
if (!item.metadata || typeof item.metadata !== "object") {
|
||||
item.metadata = {};
|
||||
|
||||
@@ -54,6 +54,7 @@ import { userBlocks } from "./userBlocks.js";
|
||||
import { relayManager } from "./relayManager.js";
|
||||
import createApplication from "./bootstrap.js";
|
||||
import SearchFilterModal from "./ui/components/SearchFilterModal.js";
|
||||
import { initializeSiteUpdateNotice } from "./ui/siteUpdateNotice.js";
|
||||
import { installTestHarness } from "./testHarness.js";
|
||||
|
||||
validateInstanceConfig();
|
||||
@@ -1320,6 +1321,7 @@ async function bootstrapInterface() {
|
||||
}
|
||||
|
||||
async function initializeInterface() {
|
||||
initializeSiteUpdateNotice();
|
||||
startApplication();
|
||||
|
||||
try {
|
||||
|
||||
@@ -1910,7 +1910,7 @@ export class ModerationService {
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Iterate over all reports for the event.
|
||||
* 2. Check if the reporter is in the `trustedContacts` set (or Admin Whitelist).
|
||||
* 2. Check if the reporter is in the `trustedContacts` set.
|
||||
* 3. If trusted, increment the count for that report type (e.g., "nudity").
|
||||
* 4. Update the summary state and emit change events if thresholds are crossed.
|
||||
*
|
||||
@@ -1966,7 +1966,7 @@ export class ModerationService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isTrustedReporter = status.whitelisted || this.trustedContacts.has(reporterHex);
|
||||
const isTrustedReporter = this.trustedContacts.has(reporterHex);
|
||||
|
||||
for (const [type, detail] of typeMap.entries()) {
|
||||
let stats = typeStats.get(type);
|
||||
@@ -2092,7 +2092,7 @@ export class ModerationService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isTrustedReporter = status.whitelisted || this.trustedContacts.has(reporterHex);
|
||||
const isTrustedReporter = this.trustedContacts.has(reporterHex);
|
||||
if (!isTrustedReporter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ async function waitForAccessControl({ timeoutMs = TRUST_SEED_READY_TIMEOUT_MS }
|
||||
|
||||
// Cached admin lists (hydrated synchronously from localStorage at
|
||||
// construction) are enough to seed trust — don't stall boot behind the relay
|
||||
// refresh. If the refresh later changes editors/whitelist/blacklist, the
|
||||
// refresh. If the refresh later changes editors/blacklist, the
|
||||
// change listeners registered in bootstrapTrustedSeeds re-apply the seeds.
|
||||
if (
|
||||
typeof accessControl.isHydrated === "function" &&
|
||||
@@ -189,10 +189,6 @@ export async function bootstrapTrustedSeeds() {
|
||||
applySeeds();
|
||||
};
|
||||
|
||||
if (accessControl && typeof accessControl.onWhitelistChange === "function") {
|
||||
accessControl.onWhitelistChange(applyOnChange);
|
||||
}
|
||||
|
||||
if (accessControl && typeof accessControl.onEditorsChange === "function") {
|
||||
accessControl.onEditorsChange(applyOnChange);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { FEATURE_BITLOGIN } from "../constants.js";
|
||||
// side effect, which retroactively upgrades the already-present <bitlogin-auth>
|
||||
// tag in the modal's static HTML once it resolves.
|
||||
const BITLOGIN_WIDGET_BUNDLE_URL = "../../vendor/bitlogin/bitlogin.js";
|
||||
const PUBKEY_PATTERN = /^[0-9a-f]{64}$/i;
|
||||
|
||||
let widgetLoadPromise = null;
|
||||
|
||||
@@ -52,6 +53,29 @@ function closeLoginModal() {
|
||||
// and each independently call requestLogin() for one real sign-in.
|
||||
const wiredWidgets = new WeakSet();
|
||||
|
||||
/**
|
||||
* A CustomEvent's detail is not an authentication boundary: any same-origin
|
||||
* script can dispatch one. Re-read the key from the widget's crypto worker and
|
||||
* require it to match before BitVid adopts the identity or its signer.
|
||||
*/
|
||||
export async function resolveVerifiedBitloginPubkey(widget, claimedPubkey) {
|
||||
const claimed = typeof claimedPubkey === "string" ? claimedPubkey.trim() : "";
|
||||
if (!PUBKEY_PATTERN.test(claimed) || typeof widget?.getPublicKey !== "function") {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const actual = await widget.getPublicKey();
|
||||
const normalizedActual = typeof actual === "string" ? actual.trim() : "";
|
||||
if (!PUBKEY_PATTERN.test(normalizedActual) || normalizedActual.toLowerCase() !== claimed.toLowerCase()) {
|
||||
return "";
|
||||
}
|
||||
return normalizedActual.toLowerCase();
|
||||
} catch (error) {
|
||||
devLogger.warn("[BitLogin] Unable to verify the widget signer:", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function attachWidget(app, widget) {
|
||||
const mount = document.getElementById("bitloginMount");
|
||||
if (!FEATURE_BITLOGIN) {
|
||||
@@ -77,11 +101,13 @@ function attachWidget(app, widget) {
|
||||
});
|
||||
|
||||
widget.addEventListener("bitlogin-login", async (event) => {
|
||||
const pubkey =
|
||||
const claimedPubkey =
|
||||
event?.detail && typeof event.detail.publicKey === "string"
|
||||
? event.detail.publicKey.trim()
|
||||
: "";
|
||||
const pubkey = await resolveVerifiedBitloginPubkey(widget, claimedPubkey);
|
||||
if (!pubkey) {
|
||||
userLogger.warn("[BitLogin] Ignored an unverifiable login event.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -857,7 +857,7 @@ export class ProfileModerationController {
|
||||
if (isBlocked(status.hex)) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(status.whitelisted || trustedContacts.has(status.hex));
|
||||
return trustedContacts.has(status.hex);
|
||||
};
|
||||
|
||||
if (service.trustedMuteLists instanceof Map) {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
export const SITE_UPDATE_LAST_SEEN_KEY = "bitvid:site-update:last-seen";
|
||||
export const SITE_UPDATE_DISMISSED_KEY = "bitvid:site-update:dismissed";
|
||||
export const GITHUB_COMMITS_ENDPOINT =
|
||||
"https://api.github.com/repos/PR0M3TH3AN/bitvid/commits?sha=unstable&per_page=5";
|
||||
|
||||
export const LATEST_UPDATE_ITEMS = Object.freeze([
|
||||
"Clearer Nostr sign-in choices alongside BitLogin.",
|
||||
"Creator approval is now separate from web-of-trust moderation.",
|
||||
"Trusted-report thresholds now require broader agreement before action.",
|
||||
]);
|
||||
|
||||
function readStorage(storage, key) {
|
||||
try {
|
||||
return storage?.getItem?.(key) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(storage, key, value) {
|
||||
try {
|
||||
storage?.setItem?.(key, value);
|
||||
} catch {
|
||||
// Privacy mode or quota errors should never block the application.
|
||||
}
|
||||
}
|
||||
|
||||
function createElement(doc, tagName, className, text = "") {
|
||||
const element = doc.createElement(tagName);
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
if (text) {
|
||||
element.textContent = text;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function renderUpdateItems(doc, list, items) {
|
||||
list.replaceChildren();
|
||||
for (const item of Array.isArray(items) ? items : []) {
|
||||
if (typeof item === "string" && item.trim()) {
|
||||
list.appendChild(createElement(doc, "li", "", item.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCommitItems(payload) {
|
||||
if (!Array.isArray(payload)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return payload
|
||||
.map((entry) => {
|
||||
const sha = typeof entry?.sha === "string" ? entry.sha.slice(0, 8) : "";
|
||||
const message =
|
||||
typeof entry?.commit?.message === "string"
|
||||
? entry.commit.message.split("\n")[0].trim()
|
||||
: "";
|
||||
return sha && message ? `${sha} — ${message}` : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function loadGitHubCommitItems({ doc, list, fetchImpl, fallbackItems }) {
|
||||
if (typeof fetchImpl !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(GITHUB_COMMITS_ENDPOINT, {
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
});
|
||||
if (!response?.ok) {
|
||||
throw new Error("github-commits-unavailable");
|
||||
}
|
||||
const commits = normalizeCommitItems(await response.json());
|
||||
if (!commits.length) {
|
||||
throw new Error("github-commits-empty");
|
||||
}
|
||||
renderUpdateItems(doc, list, commits);
|
||||
} catch {
|
||||
renderUpdateItems(doc, list, fallbackItems);
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeSiteUpdateNotice({
|
||||
doc = typeof document === "undefined" ? null : document,
|
||||
storage = typeof window === "undefined" ? null : window.localStorage,
|
||||
updateItems = LATEST_UPDATE_ITEMS,
|
||||
fetchImpl = typeof fetch === "function" ? fetch : null,
|
||||
} = {}) {
|
||||
if (!doc?.body) {
|
||||
return { shown: false, reason: "document-unavailable" };
|
||||
}
|
||||
|
||||
const versionMarker = doc.querySelector("[data-site-version]");
|
||||
const version = versionMarker?.dataset?.siteVersion?.trim() || "";
|
||||
const date = versionMarker?.dataset?.siteVersionDate?.trim() || "";
|
||||
if (!version) {
|
||||
return { shown: false, reason: "version-unavailable" };
|
||||
}
|
||||
|
||||
const lastSeen = readStorage(storage, SITE_UPDATE_LAST_SEEN_KEY);
|
||||
const dismissed = readStorage(storage, SITE_UPDATE_DISMISSED_KEY);
|
||||
if (!lastSeen) {
|
||||
writeStorage(storage, SITE_UPDATE_LAST_SEEN_KEY, version);
|
||||
return { shown: false, reason: "first-visit" };
|
||||
}
|
||||
|
||||
if (lastSeen === version || dismissed === version) {
|
||||
return { shown: false, reason: "already-seen" };
|
||||
}
|
||||
|
||||
const root = createElement(doc, "aside", "site-update-notice");
|
||||
root.setAttribute("aria-label", "Latest site update");
|
||||
|
||||
const panel = createElement(doc, "section", "site-update-notice__panel hidden");
|
||||
panel.id = "siteUpdateNoticePanel";
|
||||
panel.setAttribute("role", "status");
|
||||
panel.setAttribute("aria-live", "polite");
|
||||
|
||||
const panelHeader = createElement(doc, "div", "flex items-start justify-between gap-3");
|
||||
const title = createElement(doc, "h2", "text-base font-semibold", "Latest update");
|
||||
const closeButton = createElement(doc, "button", "site-update-notice__close", "×");
|
||||
closeButton.type = "button";
|
||||
closeButton.setAttribute("aria-label", "Dismiss this update");
|
||||
panelHeader.append(title, closeButton);
|
||||
|
||||
const intro = createElement(
|
||||
doc,
|
||||
"p",
|
||||
"mt-2 text-sm leading-relaxed text-text-muted",
|
||||
"BitVid has been updated. Here is what changed:",
|
||||
);
|
||||
const list = createElement(doc, "ul", "mt-3 list-disc space-y-1 pl-5 text-sm text-text-muted");
|
||||
renderUpdateItems(doc, list, updateItems);
|
||||
const build = createElement(
|
||||
doc,
|
||||
"p",
|
||||
"mt-3 text-xs font-mono text-text-muted",
|
||||
`Build ${version}${date ? ` • ${date}` : ""}`,
|
||||
);
|
||||
panel.append(panelHeader, intro, list, build);
|
||||
|
||||
const toggleButton = createElement(doc, "button", "site-update-notice__button", "Latest update");
|
||||
toggleButton.type = "button";
|
||||
toggleButton.setAttribute("aria-expanded", "false");
|
||||
toggleButton.setAttribute("aria-controls", panel.id);
|
||||
|
||||
const setPanelOpen = (open) => {
|
||||
panel.classList.toggle("hidden", !open);
|
||||
toggleButton.setAttribute("aria-expanded", String(open));
|
||||
};
|
||||
|
||||
let commitLoadStarted = false;
|
||||
toggleButton.addEventListener("click", () => {
|
||||
const nextOpen = panel.classList.contains("hidden");
|
||||
setPanelOpen(nextOpen);
|
||||
if (nextOpen && !commitLoadStarted) {
|
||||
commitLoadStarted = true;
|
||||
void loadGitHubCommitItems({
|
||||
doc,
|
||||
list,
|
||||
fetchImpl,
|
||||
fallbackItems: updateItems,
|
||||
});
|
||||
}
|
||||
});
|
||||
closeButton.addEventListener("click", () => {
|
||||
writeStorage(storage, SITE_UPDATE_LAST_SEEN_KEY, version);
|
||||
writeStorage(storage, SITE_UPDATE_DISMISSED_KEY, version);
|
||||
root.remove();
|
||||
});
|
||||
|
||||
root.append(panel, toggleButton);
|
||||
doc.body.appendChild(root);
|
||||
return { shown: true, version, root };
|
||||
}
|
||||
@@ -167,7 +167,7 @@ function injectVersionInfo() {
|
||||
let content = fs.readFileSync(indexHtmlPath, 'utf8');
|
||||
|
||||
const versionHtml = `
|
||||
<div class="mt-4 text-xs text-muted opacity-50 font-mono">
|
||||
<div class="mt-4 text-xs text-muted opacity-50 font-mono" data-site-version="${hash.slice(0, 8)}" data-site-version-date="${date}">
|
||||
v: ${hash.slice(0, 8)} • ${date}
|
||||
</div>`;
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveVerifiedBitloginPubkey } from "../js/ui/bitloginModalIntegration.js";
|
||||
|
||||
const PUBKEY = "a".repeat(64);
|
||||
|
||||
test("BitLogin accepts a login identity only when the event and widget worker agree", async () => {
|
||||
const widget = { getPublicKey: async () => PUBKEY.toUpperCase() };
|
||||
assert.equal(await resolveVerifiedBitloginPubkey(widget, PUBKEY), PUBKEY);
|
||||
});
|
||||
|
||||
test("BitLogin rejects a forged event pubkey even when its claimed account has access", async () => {
|
||||
const widget = { getPublicKey: async () => "b".repeat(64) };
|
||||
assert.equal(await resolveVerifiedBitloginPubkey(widget, PUBKEY), "");
|
||||
});
|
||||
|
||||
test("BitLogin rejects malformed claims and widgets without a worker-backed public key", async () => {
|
||||
assert.equal(await resolveVerifiedBitloginPubkey({}, PUBKEY), "");
|
||||
assert.equal(await resolveVerifiedBitloginPubkey({ getPublicKey: async () => PUBKEY }, "npub1nope"), "");
|
||||
});
|
||||
@@ -163,6 +163,14 @@ function createController(options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
test('login modal keeps Nostr sign-in options visible beside BitLogin', () => {
|
||||
const nostrSection = container.querySelector('[aria-labelledby="nostrLoginOptionsTitle"]');
|
||||
assert.ok(nostrSection, 'Nostr login options should be visible without expanding a disclosure');
|
||||
assert.ok(nostrSection.querySelector('[data-login-providers]'));
|
||||
assert.equal(container.querySelector('details'), null);
|
||||
assert.match(container.querySelector('#bitloginMount').textContent, /BitLogin/);
|
||||
});
|
||||
|
||||
test('LoginModalController shows custom error for empty nsec input', async (t) => {
|
||||
const providers = [{ id: 'nsec', label: 'Secret Key', login: async () => {} }];
|
||||
const authService = { requestLogin: async () => {} };
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
DEFAULT_BLUR_THRESHOLD,
|
||||
} from "../js/constants.js";
|
||||
|
||||
test("moderation stage enforces admin lists without whitelist bypass", async () => {
|
||||
test("moderation stage keeps web-of-trust actions separate from creator access", async () => {
|
||||
const whitelistedHex = "1".repeat(64);
|
||||
const blockedHex = "2".repeat(64);
|
||||
const blacklistedHex = "3".repeat(64);
|
||||
@@ -496,17 +496,22 @@ test("moderation stage propagates whitelist, muters, and threshold updates", asy
|
||||
assert.equal(updatedMuted.video.moderation.blurReason, undefined);
|
||||
});
|
||||
|
||||
test("moderation stage blurs viewer-muted authors", async () => {
|
||||
test("moderation stage preserves a viewer mute for a whitelisted creator", async () => {
|
||||
const mutedHex = "f".repeat(64);
|
||||
|
||||
const service = {
|
||||
async refreshViewerFromClient() {},
|
||||
async setActiveEventIds() {},
|
||||
getAdminListSnapshot() {
|
||||
return { whitelist: new Set(), whitelistHex: new Set(), blacklist: new Set(), blacklistHex: new Set() };
|
||||
return {
|
||||
whitelist: new Set([`npub${mutedHex}`]),
|
||||
whitelistHex: new Set([mutedHex]),
|
||||
blacklist: new Set(),
|
||||
blacklistHex: new Set(),
|
||||
};
|
||||
},
|
||||
getAccessControlStatus(identifier) {
|
||||
return { hex: identifier, whitelisted: false, blacklisted: false };
|
||||
return { hex: identifier, whitelisted: identifier === mutedHex, blacklisted: false };
|
||||
},
|
||||
getTrustedReportSummary() {
|
||||
return null;
|
||||
@@ -551,6 +556,7 @@ test("moderation stage blurs viewer-muted authors", async () => {
|
||||
|
||||
assert.equal(entry.video.moderation.viewerMuted, true);
|
||||
assert.equal(entry.metadata.moderation.viewerMuted, true);
|
||||
assert.equal(entry.video.moderation.adminWhitelistBypass, false);
|
||||
assert.equal(entry.video.moderation.blockAutoplay, true);
|
||||
assert.equal(entry.video.moderation.blurThumbnail, true);
|
||||
assert.equal(entry.video.moderation.blurReason, "viewer-mute");
|
||||
|
||||
@@ -329,7 +329,7 @@ test("moderation stage bypasses hard hides on home feed", async () => {
|
||||
assert.equal(hideWhy.hideBypass, "feed-policy");
|
||||
});
|
||||
|
||||
test("moderation stage hides admin-whitelisted videos once thresholds fire", async () => {
|
||||
test("moderation stage hides admin-whitelisted videos when web-of-trust thresholds fire", async () => {
|
||||
const whitelistedHex = hex("f");
|
||||
const videoId = hex("4");
|
||||
|
||||
@@ -400,4 +400,3 @@ test("moderation stage hides admin-whitelisted videos once thresholds fire", asy
|
||||
assert.equal(hideWhy.adminWhitelist, true);
|
||||
assert.equal(hideWhy.trustedReportCount, 6);
|
||||
});
|
||||
|
||||
@@ -224,22 +224,7 @@ test("bootstrap seeds track editor roster and ignore whitelist-only changes", as
|
||||
"initial editor roster should seed trust"
|
||||
);
|
||||
|
||||
const whitelistListener = Array.from(whitelistListeners)[0];
|
||||
if (typeof whitelistListener === "function") {
|
||||
whitelistListener(["npub1whitelistentrywhitelistentrywhitelistentry"]);
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
setTrustedSeedsCalls.length,
|
||||
2,
|
||||
"whitelist change should retrigger trusted seed derivation"
|
||||
);
|
||||
const [, whitelistSeeds] = setTrustedSeedsCalls;
|
||||
assert.deepEqual(
|
||||
new Set(whitelistSeeds),
|
||||
new Set(initialSeeds),
|
||||
"whitelist updates should not introduce new trusted seeds"
|
||||
);
|
||||
assert.equal(whitelistListeners.size, 0, "creator whitelist changes must not affect trust seeds");
|
||||
|
||||
currentEditors = [
|
||||
"npub1newmoderatorexamplemoderatorexamplemoderator",
|
||||
@@ -251,10 +236,10 @@ test("bootstrap seeds track editor roster and ignore whitelist-only changes", as
|
||||
|
||||
assert.equal(
|
||||
setTrustedSeedsCalls.length,
|
||||
3,
|
||||
2,
|
||||
"editor change should recompute trusted seed roster"
|
||||
);
|
||||
const latestSeeds = setTrustedSeedsCalls[2];
|
||||
const latestSeeds = setTrustedSeedsCalls[1];
|
||||
assert.deepEqual(
|
||||
new Set(latestSeeds),
|
||||
new Set([ADMIN_SUPER_NPUB, ...currentEditors]),
|
||||
|
||||
@@ -167,7 +167,7 @@ test("blocking and unblocking reporters recomputes trusted summaries", async (t)
|
||||
assert.deepEqual(setActiveCalls, [[eventId], [eventId]]);
|
||||
});
|
||||
|
||||
test("trustedReportCount only counts eligible F1 reporters and admin whitelist", (t) => {
|
||||
test("trustedReportCount only counts eligible F1 reporters, not creator whitelist entries", (t) => {
|
||||
withMockedNostrTools(t);
|
||||
|
||||
const eventId = "c".repeat(64);
|
||||
@@ -217,14 +217,14 @@ test("trustedReportCount only counts eligible F1 reporters and admin whitelist",
|
||||
}
|
||||
|
||||
const summary = service.getTrustedReportSummary(eventId);
|
||||
assert.equal(summary.totalTrusted, 2);
|
||||
assert.equal(summary.totalTrusted, 1);
|
||||
assert.equal(summary.types.nudity.total, 3);
|
||||
assert.equal(summary.types.nudity.trusted, 2);
|
||||
assert.equal(service.trustedReportCount(eventId, "nudity"), 2);
|
||||
assert.equal(summary.types.nudity.trusted, 1);
|
||||
assert.equal(service.trustedReportCount(eventId, "nudity"), 1);
|
||||
|
||||
const reporters = service.getTrustedReporters(eventId, "nudity");
|
||||
assert.deepEqual(
|
||||
reporters.map((entry) => entry.pubkey).sort(),
|
||||
[adminWhitelisted, f1Reporter].sort(),
|
||||
[f1Reporter],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import {
|
||||
SITE_UPDATE_DISMISSED_KEY,
|
||||
SITE_UPDATE_LAST_SEEN_KEY,
|
||||
initializeSiteUpdateNotice,
|
||||
} from "../js/ui/siteUpdateNotice.js";
|
||||
|
||||
function createDocument(version = "deadbeef") {
|
||||
return new JSDOM(
|
||||
`<!doctype html><html><body><div data-site-version="${version}" data-site-version-date="2026-07-26"></div></body></html>`,
|
||||
{ url: "https://bitvid.example" },
|
||||
);
|
||||
}
|
||||
|
||||
test("site update notice records a first visit without interrupting it", () => {
|
||||
const dom = createDocument();
|
||||
const result = initializeSiteUpdateNotice({
|
||||
doc: dom.window.document,
|
||||
storage: dom.window.localStorage,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { shown: false, reason: "first-visit" });
|
||||
assert.equal(dom.window.localStorage.getItem(SITE_UPDATE_LAST_SEEN_KEY), "deadbeef");
|
||||
assert.equal(dom.window.document.querySelector(".site-update-notice"), null);
|
||||
});
|
||||
|
||||
test("site update notice shows a dismissible build summary after an update", async () => {
|
||||
const dom = createDocument("cafebabe");
|
||||
dom.window.localStorage.setItem(SITE_UPDATE_LAST_SEEN_KEY, "deadbeef");
|
||||
|
||||
const result = initializeSiteUpdateNotice({
|
||||
doc: dom.window.document,
|
||||
storage: dom.window.localStorage,
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ sha: "1234567890abcdef", commit: { message: "Make Nostr login obvious\n\nMore detail" } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(result.shown, true);
|
||||
const button = dom.window.document.querySelector(".site-update-notice__button");
|
||||
const panel = dom.window.document.querySelector(".site-update-notice__panel");
|
||||
assert.ok(button);
|
||||
assert.ok(panel.classList.contains("hidden"));
|
||||
|
||||
button.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(button.getAttribute("aria-expanded"), "true");
|
||||
assert.equal(panel.classList.contains("hidden"), false);
|
||||
assert.match(panel.textContent, /cafebabe/);
|
||||
assert.match(panel.textContent, /12345678 — Make Nostr login obvious/);
|
||||
|
||||
dom.window.document.querySelector("[aria-label='Dismiss this update']").click();
|
||||
assert.equal(dom.window.document.querySelector(".site-update-notice"), null);
|
||||
assert.equal(dom.window.localStorage.getItem(SITE_UPDATE_LAST_SEEN_KEY), "cafebabe");
|
||||
assert.equal(dom.window.localStorage.getItem(SITE_UPDATE_DISMISSED_KEY), "cafebabe");
|
||||
});
|
||||
|
||||
test("site update notice keeps its curated summary when GitHub is unavailable", async () => {
|
||||
const dom = createDocument("cafebabe");
|
||||
dom.window.localStorage.setItem(SITE_UPDATE_LAST_SEEN_KEY, "deadbeef");
|
||||
initializeSiteUpdateNotice({
|
||||
doc: dom.window.document,
|
||||
storage: dom.window.localStorage,
|
||||
updateItems: ["Fallback update copy."],
|
||||
fetchImpl: async () => ({ ok: false }),
|
||||
});
|
||||
|
||||
dom.window.document.querySelector(".site-update-notice__button").click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.match(
|
||||
dom.window.document.querySelector(".site-update-notice__panel").textContent,
|
||||
/Fallback update copy/,
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
|
||||
DEFAULT_BLUR_THRESHOLD,
|
||||
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
|
||||
} from "../config/instance-config.js";
|
||||
import { validateInstanceConfig } from "../config/validate-config.js";
|
||||
|
||||
// Common valid overrides to use as a base
|
||||
@@ -10,6 +15,12 @@ const VALID_OVERRIDES = {
|
||||
PLATFORM_LUD16_OVERRIDE: "[email protected]",
|
||||
};
|
||||
|
||||
test("instance moderation defaults require multiple trusted reports", () => {
|
||||
assert.equal(DEFAULT_BLUR_THRESHOLD, 3);
|
||||
assert.equal(DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, 3);
|
||||
assert.equal(DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, 5);
|
||||
});
|
||||
|
||||
test("validateInstanceConfig succeeds with valid configuration", () => {
|
||||
assert.doesNotThrow(() => validateInstanceConfig(VALID_OVERRIDES));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const playerStyles = await readFile(
|
||||
new URL("../css/tailwind.source.css", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
test("mobile player modal uses the dynamic viewport and keeps the comment action above browser chrome", () => {
|
||||
assert.match(
|
||||
playerStyles,
|
||||
/#playerModal\s*\{[^}]*height:\s*100dvh;[^}]*min-height:\s*100dvh;[^}]*overflow:\s*hidden;/s,
|
||||
"the player root should use the dynamic viewport instead of scrolling beneath browser chrome"
|
||||
);
|
||||
assert.match(
|
||||
playerStyles,
|
||||
/#playerModal\s*>\s*\.bv-modal__panel\s*\{[^}]*overflow-y:\s*auto;/s,
|
||||
"the modal panel should remain the single scroll region used by sticky navigation"
|
||||
);
|
||||
assert.match(
|
||||
playerStyles,
|
||||
/#playerModal\s+\.comment-composer\s*\{[^}]*padding-bottom:\s*max\(var\(--space-lg\),\s*env\(safe-area-inset-bottom\)\);/s,
|
||||
"the comment action row should clear the mobile safe area"
|
||||
);
|
||||
});
|
||||
+6
-6
@@ -2,17 +2,17 @@
|
||||
"package": "@bitlogin/widget",
|
||||
"version": "0.1.0",
|
||||
"files": {
|
||||
"bitlogin-shared-n51Pos3V.js": {
|
||||
"sha256": "bf7ee691d519f3f9fed7fe1d0955389ea33d0b6de5282432d4815c82cdffe25e",
|
||||
"bytes": 288287
|
||||
"bitlogin-shared-QIBe5Omw.js": {
|
||||
"sha256": "7ce4ed0a5c3c7f744e864f55745f7fa78482cbfa19cd30eb551f67a415f55191",
|
||||
"bytes": 288275
|
||||
},
|
||||
"bitlogin.js": {
|
||||
"sha256": "927646a94a1bb0fff4a0080778f2fc58be60107f1e4e1250369074f980f19ac4",
|
||||
"sha256": "55312cef3462a2e41043b57bd3c87a80bad4e077dd6e529faf25fa55db4ba7a8",
|
||||
"bytes": 67232
|
||||
},
|
||||
"cryptoWorker.js": {
|
||||
"sha256": "ba5ded0d533f394e02b5a1a66949f6c5c17e3af1ab83db34dc7b4c3c48506a0c",
|
||||
"bytes": 76304
|
||||
"sha256": "49a421e1026ad94243ceb0653f3761820d4e98e19b8fbe469307cd692f9a8489",
|
||||
"bytes": 79173
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -10807,9 +10807,8 @@ export {
|
||||
$1 as a,
|
||||
U1 as a0,
|
||||
Rt as a1,
|
||||
jt as a2,
|
||||
t2 as a3,
|
||||
W1 as a4,
|
||||
t2 as a2,
|
||||
W1 as a3,
|
||||
z1 as b,
|
||||
wn as c,
|
||||
Y1 as d,
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { r as y, e as w, E as v, p as Q, R as k, i as h, a as d } from "./bitlogin-shared-n51Pos3V.js";
|
||||
import { r as y, e as w, E as v, p as Q, R as k, i as h, a as d } from "./bitlogin-shared-QIBe5Omw.js";
|
||||
const x = 6, p = 64;
|
||||
function u(a = x) {
|
||||
const e = w(), t = [];
|
||||
|
||||
Vendored
+713
-641
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user