Compare commits

..
Author SHA1 Message Date
thePR0M3TH3AN a742cf901e Merge pull request #2601 from PR0M3TH3AN/unstable
Unstable
2026-02-17 17:04:17 -05:00
thePR0M3TH3AN 0471b3fca9 Merge pull request #2184 from PR0M3TH3AN/unstable
Unstable
2026-02-07 18:11:30 -05:00
thePR0M3TH3AN 6da969550c Merge pull request #2061 from PR0M3TH3AN/unstable
Unstable
2026-02-01 21:36:47 -05:00
thePR0M3TH3AN b429b1c075 update 2026-01-30 17:09:51 -05:00
thePR0M3TH3AN 4d9d2e3585 Merge pull request #1913 from PR0M3TH3AN/unstable
Unstable
2026-01-28 22:24:25 -05:00
thePR0M3TH3AN 18cd1423ec Merge pull request #1868 from PR0M3TH3AN/unstable
Unstable
2026-01-27 23:11:40 -05:00
thePR0M3TH3AN 6f3156b741 Merge pull request #1791 from PR0M3TH3AN/unstable
Unstable
2026-01-26 23:05:14 -05:00
thePR0M3TH3AN 08cd523fca Merge pull request #1743 from PR0M3TH3AN/unstable
Unstable
2026-01-26 12:09:38 -05:00
thePR0M3TH3AN 320af8b514 Merge pull request #1731 from PR0M3TH3AN/unstable
Unstable
2026-01-26 07:58:42 -05:00
thePR0M3TH3AN 2275745533 Merge pull request #1610 from PR0M3TH3AN/unstable
Unstable
2026-01-23 15:22:29 -05:00
thePR0M3TH3AN d4ab45fde9 Merge pull request #1532 from PR0M3TH3AN/unstable
Unstable
2026-01-22 19:41:35 -05:00
thePR0M3TH3AN 6c99c9d039 Merge pull request #1515 from PR0M3TH3AN/unstable
Unstable
2026-01-22 15:13:01 -05:00
thePR0M3TH3AN 489295af40 Merge pull request #1513 from PR0M3TH3AN/unstable
Unstable
2026-01-22 15:04:07 -05:00
thePR0M3TH3AN 631a666a66 Merge pull request #1469 from PR0M3TH3AN/unstable
Unstable
2026-01-21 12:48:49 -05:00
thePR0M3TH3AN 4f6086e067 Merge pull request #1366 from PR0M3TH3AN/unstable
Unstable
2026-01-18 23:17:09 -05:00
thePR0M3TH3AN 9d07c0c6c4 Merge pull request #1262 from PR0M3TH3AN/unstable
Unstable
2026-01-16 09:48:45 -05:00
thePR0M3TH3AN 39ab4d8f37 Merge pull request #1197 from PR0M3TH3AN/unstable
Unstable
2026-01-12 22:28:42 -05:00
thePR0M3TH3AN 34a431dcfd Merge pull request #1188 from PR0M3TH3AN/unstable
Unstable
2026-01-12 21:01:35 -05:00
thePR0M3TH3AN f1efa065c3 Merge pull request #1163 from PR0M3TH3AN/unstable
Unstable
2026-01-09 15:57:03 -05:00
thePR0M3TH3AN 22826cbf5c Merge pull request #1155 from PR0M3TH3AN/codex/create-netlify.toml-file
Add Netlify build configuration
2026-01-09 12:12:02 -05:00
thePR0M3TH3AN ca4c432339 Add Netlify build configuration 2026-01-09 12:11:03 -05:00
1559 changed files with 243941 additions and 120891 deletions

No files matched your search

+3 -99
View File
@@ -89,54 +89,6 @@ jobs:
path: artifacts/dist-hashes-current.json
if-no-files-found: error
# Visual regression runs as its own job so an environment-sensitive pixel diff
# (CI Chromium/font rendering can differ from the committed baseline) reports a
# failure WITHOUT blocking the build job — and therefore without blocking the
# deploy-cache-state CDN purge below. Real build/lint failures still gate the
# deploy via the build job.
visual-regression:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
matrix:
node-version: [22]
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Cache npm directory
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ matrix.node-version }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.node-version }}-npm-
- name: Cache node_modules
id: node-modules-cache
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ matrix.node-version }}-modules-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.node-version }}-modules-
- name: Install dependencies
if: steps.node-modules-cache.outputs.cache-hit != 'true'
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run visual regression tests
id: visual
run: npm run test:visual
@@ -310,8 +262,8 @@ jobs:
CLOUDFLARE_PURGE_BASE_URL: ${{ secrets.CLOUDFLARE_PURGE_BASE_URL }}
run: |
if [ -z "$CLOUDFLARE_PURGE_BASE_URL" ]; then
echo "CLOUDFLARE_PURGE_BASE_URL is not configured; skipping deployed index.html verification."
exit 0
echo "CLOUDFLARE_PURGE_BASE_URL is required to verify deployed index.html"
exit 1
fi
DEPLOY_INDEX_URL="${CLOUDFLARE_PURGE_BASE_URL%/}/index.html" npm run verify:deployed:index-html
@@ -400,54 +352,6 @@ jobs:
- name: Run DM unit tests
run: npm run test:dm:unit
e2e-tests:
runs-on: ubuntu-latest
timeout-minutes: 35
strategy:
fail-fast: false
matrix:
node-version: [22]
project: [e2e, e2e-firefox]
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Cache npm directory
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ matrix.node-version }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.node-version }}-npm-
- name: Cache node_modules
id: node-modules-cache
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ matrix.node-version }}-modules-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.node-version }}-modules-
- name: Install dependencies
if: steps.node-modules-cache.outputs.cache-hit != 'true'
run: npm ci
- name: Build app
run: npm run build
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium firefox
- name: Run deterministic E2E suite (${{ matrix.project }})
run: npx playwright test tests/e2e --project=${{ matrix.project }}
dm-integration-tests:
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -526,7 +430,7 @@ jobs:
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium firefox
run: npx playwright install --with-deps chromium
- name: Run headless E2E tests
run: npm run test:e2e
-97
View File
@@ -1,97 +0,0 @@
# Build a fresh, downloadable site archive on every push (any branch), so a
# ready-to-upload copy of the compiled app is always available for a MANUAL nsite
# publish (NIP-5A). This does NOT publish to nsite or touch any Nostr signing key —
# it just builds `dist/`.
# - every branch: upload it as a 30-day workflow artifact.
# - main only: also cut an immutable GitHub Release with the .zip attached,
# giving a stable download URL:
# https://github.com/PR0M3TH3AN/bitvid/releases/latest/download/bitvid-site.zip
# The Release contains only the public compiled app (same as Vercel serves) — no
# secret. See docs/nsite-mirror-plan.md.
name: Build site archive
on:
push:
workflow_dispatch:
permissions:
contents: read
# One archive build in flight per branch; a newer push supersedes an older one.
concurrency:
group: nsite-archive-${{ github.ref }}
cancel-in-progress: true
jobs:
archive:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build static site
run: npm run build
- name: Short SHA
id: vars
run: echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
# `path: dist` stores the site files at the artifact root (index.html at top),
# so the downloaded .zip extracts straight to the nsite site directory.
- name: Upload site archive
uses: actions/upload-artifact@v4
with:
name: bitvid-site-${{ github.ref_name }}-${{ steps.vars.outputs.sha }}
path: dist
if-no-files-found: error
retention-days: 30
# Production only: publish an immutable GitHub Release with the .zip attached, so
# the latest prod archive is always at a stable URL:
# https://github.com/PR0M3TH3AN/bitvid/releases/latest/download/bitvid-site.zip
# This is purely a download convenience + per-release snapshot — it does NOT
# publish to nsite and holds no Nostr signing key (that step stays manual).
release:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
# `npm run archive` builds dist/ and writes bitvid-site.zip (index.html at root).
- name: Build site archive
run: npm run archive
- name: Publish site release
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="site-$(date -u +%Y%m%d)-${GITHUB_SHA::7}"
notes="Compiled bitvid static app (\`dist/\`) at ${GITHUB_SHA}.
Stable latest link: https://github.com/${GITHUB_REPOSITORY}/releases/latest/download/bitvid-site.zip"
# Re-runs of the same commit reuse the tag: refresh the asset instead of failing.
if gh release view "$tag" >/dev/null 2>&1; then
gh release upload "$tag" bitvid-site.zip --clobber
gh release edit "$tag" --latest
else
gh release create "$tag" bitvid-site.zip \
--title "Site archive $tag" \
--notes "$notes" \
--latest
fi
-9
View File
@@ -13,12 +13,3 @@ ai/reports/
coverage/
dist/
TEST_LOG.mdtest_logs/nip-hits.txt
!artifacts/audit/
!artifacts/audit/*.md
!artifacts/audit/*.json
# Vercel CLI local link/state
.vercel
# Local site archive for manual nsite upload (npm run archive)
bitvid-site.zip
-23
View File
@@ -1,23 +0,0 @@
# After Benchmark for Moderation Stage Batching
## Command
`node benchmarks/moderation_batch_bench.js`
## Environment
- Node.js v22.22.0
- 5000 items
- 500 authors
- 100 muters per author
- Forced prune check (lastPrunedAt = 0)
## Results
- Run 1: 184.34ms
- Run 2: 277.68ms
- Run 3: 188.89ms
- Run 4: 256.48ms
- **Average: ~227ms**
## Comparison
- Baseline: ~454ms
- After: ~227ms
- **Improvement: ~50% faster**
+30 -161
View File
@@ -63,15 +63,6 @@ For any nostr related work, please review the nip documentation located in /docs
5. **Telemetry hooks:** Keep existing analytics/logging untouched unless instructed. If you add new modal states, annotate them clearly in code comments.
6. **Modal regressions:** If validation or helper wiring breaks in Main, disable new feature flags and ship a revert PR immediately. Note the rollback steps in AGENTS.md for posterity.
### 4a. Cloudflare/S3 upload-path gotchas (audited 2026-06-16)
* **Two near-identical upload services exist** — `js/services/r2Service.js` (Cloudflare R2) and `js/services/s3UploadService.js` (generic S3). They share `buildR2Key`, `resolveUploadIdentifier`, and the magnet build but are *copies*. **Any fix to one must be mirrored in the other** or they silently drift.
* **Storage-key collisions = data loss — FIXED 2026-06-16.** `buildR2Key` (`js/r2.js`) builds `u/<npub>/<namespace>/<slug>.<ext>` where `namespace` used to fall back to the literal `"uploads"` and `slug` to `"video"` (e.g. a non-ASCII filename, or no info-hash) — so a URL-first upload with no info-hash and a duplicate filename overwrote the previous object while the old note still pointed at that URL. WebTorrent uploads were always safe (info-hash namespaces the key = content-addressed). Fix: `computeStorageContentHash(file)` (`js/r2.js`) derives a content-based namespace when no info-hash is available (full SHA-256 for ≤512 MB, sampled metadata+edge fingerprint above that to avoid buffering huge files), wired into **both** `r2Service` and `s3UploadService`. Test: `tests/storage-key-collision.test.mjs`. **If you add a third upload path, reuse `computeStorageContentHash`.**
* **Whole-file hash before upload.** When the modal doesn't pre-seed an info-hash, `resolveUploadIdentifier` runs `calculateTorrentInfoHash(file)`, which reads the *entire* file before the upload begins and before any progress is emitted — on multi-GB files this looks like a hang. Emit a "preparing/hashing" status or parallelize.
* **Publish can report success unconfirmed.** `publishEventToRelay` (`js/nostrPublish.js`) has an optimistic-success fallthrough when the relay handle exposes no `on()`/`then()` — a note may show "Published" with no relay ACK. Don't tighten blindly (legacy `seen`-only relays rely on it), but log it.
* **Silent partial failures.** Thumbnail and `.torrent` upload failures are caught and `warn`-only; the video note still publishes with no user signal. Empty (0-byte) files hit `CompleteMultipartUpload` with `Parts:[]` and surface a cryptic error — guard `file.size === 0`.
* **Object cleanup (deletes/edits).** `R2Service.deleteVideoStorage()` removes the backing objects so "deleted" videos aren't left publicly downloadable. Contract to preserve: it's **best-effort and never throws** (must not block note deletion), it derives keys via `collectVideoStorageKeys()` and **only ever touches URLs under the owner's bucket base** (never external/3rd-party URLs), and it skips cleanly when storage is locked/credential-less. Wired into delete (`app.handleDeleteModalConfirm`) and edit-with-replaced-URL (`editModalController.handleSubmit`). Edit cleanup intentionally does **not** delete thumbnails (the new note may still reference them) and only fires when both old and new URLs are present and different.
---
## 5. Manual QA Checklist
@@ -253,14 +244,37 @@ When you create a PR or start work, leave a clear signal:
- **PR title prefix**: Use a descriptive prefix like `[nostr-core]`, `[playback]`, `[ui]`, `[ci]` so the scope is visible at a glance.
- **PR description**: Include a "Files Modified" section listing the key files touched, so other agents can quickly detect conflicts.
### Task Coordination
### Task Claiming Protocol (TORCH)
> The previous TORCH relay-locking protocol (and its `torch/` tooling) has been
> removed. Coordinate via GitHub instead: before starting, check open PRs
> (`gh pr list` or the GitHub API) for overlap on the same subsystem, and use a
> descriptive PR title prefix so scope is visible. Don't run two agents on the
> same subsystem simultaneously, and keep `js/app.js` single-writer (see the
> Multi-Agent Coordination rules above).
Task coordination uses **TORCH** (Task Orchestration via Relay-Coordinated Handoff) — a decentralized locking protocol built on Nostr. Agents publish ephemeral lock events to public relays; locks auto-expire via NIP-40. No tokens, no secrets, no git push required. Works across all platforms (Claude Code, Codex, Jules). See `docs/agents/TORCH.md` for the full protocol documentation.
**Before starting any task:**
1. **Check for existing claims:**
```bash
node scripts/agent/nostr-lock.mjs check --cadence <daily|weekly>
```
Returns JSON with `locked` (claimed agents) and `available` (free agents) arrays. If an agent appears in the `locked` list, **skip it**.
2. **Claim the task:**
```bash
AGENT_PLATFORM=<jules|claude-code|codex> \
node scripts/agent/nostr-lock.mjs lock \
--agent <agent-name> \
--cadence <daily|weekly>
```
The script generates an ephemeral keypair, publishes a lock event to public Nostr relays, and performs a built-in race check. The key is used once and discarded. Locks auto-expire after 2 hours (configurable via `NOSTR_LOCK_TTL`).
- **Exit 0** = lock acquired, begin work.
- **Exit 3** = race lost, another agent claimed first. Go back to step 1.
- **Exit 2** = relay error. Write a `_failed.md` log and stop.
3. **View all active locks** (optional, for debugging):
```bash
node scripts/agent/nostr-lock.mjs list
```
**Scheduler agents:** Daily and weekly scheduler agents must follow this protocol in addition to their directory-based rotation logic (`docs/agents/task-logs/daily/` and `docs/agents/task-logs/weekly/`). The lock check happens _after_ determining the next task but _before_ executing it. See the scheduler prompts for the specific implementation steps.
### Currently In-Flight Work
@@ -352,11 +366,6 @@ Or set `localStorage.__bitvidTestRelays__` to a JSON array of relay URLs.
| `waitForSelector(sel, ms)` | `Promise<true>` | Wait for a DOM element |
| `getRelayHealth()` | `{ relays, unreachable, backoff }` | Relay connection status |
| `applyRelayOverrides(urls)` | `boolean` | Redirect relay connections |
| `setTestRelays(urls)` | `{ ok, relays }` | Align relayManager + nostrClient to a deterministic relay set |
| `setSignerDecryptBehavior(mode, opts?)` | `{ ok, mode, ... }` | Force decrypt behavior (`passthrough`, `timeout`, `error`, `delay`) for signer-based list sync tests |
| `getListSyncEvents()` | `Array<{ source, at, detail }>` | Read captured sync timeline (`bitvid:auth-loading-state` + `userBlocks` status) |
| `clearListSyncEvents()` | `void` | Reset captured sync events between assertions |
| `waitForListSyncEvent(criteria, ms?)` | `Promise<Event>` | Wait for sync milestones (`status`, `reason`, `source`) |
| `nostrClient` | `NostrClient` | Direct access for advanced use |
### Mock Relay (`scripts/agent/simple-relay.mjs`)
@@ -397,9 +406,6 @@ test("agent can seed and view videos", async ({ page, gotoApp, loginAs, seedEven
| `clearRelay()` | `() => Promise` | Wipe all relay events |
| `gotoApp(path?)` | `(string?) => Promise` | Navigate with test mode + relay overrides |
| `loginAs(page)` | `(Page) => Promise<string>` | Login with deterministic test key |
| `setTestRelays(page, relays)` | `(Page, string[]) => Promise<{ok, relays}>` | Force relay alignment through harness + relayManager |
| `setDecryptBehavior(page, mode, opts?)` | `(Page, mode, opts?) => Promise` | Force signer decrypt behavior (`passthrough`, `timeout`, `error`, `delay`) |
| `startDiagnostics(page, opts?)` | `(Page, opts?) => Promise<{stop()}>` | Capture console/page errors and list-sync telemetry for assertions |
| `testPubkey` | `string` | Hex pubkey of the test key |
| `relayUrl` | `string` | WebSocket URL of the mock relay |
@@ -412,8 +418,6 @@ Use these for stable element targeting:
| `[data-testid="login-button"]` | Header login button |
| `[data-testid="upload-button"]` | Header upload button (hidden until logged in) |
| `[data-testid="profile-button"]` | Header profile button (hidden until logged in) |
| `[data-testid="profile-permission-prompt"]` | Profile list-sync permission CTA container |
| `[data-testid="profile-permission-prompt-button"]` | Profile list-sync permission/retry CTA button |
| `[data-testid="search-input"]` | Header search field |
| `[data-testid="login-modal"]` | Login modal container |
| `[data-testid="login-provider-button"]` | Login provider option buttons |
@@ -424,12 +428,6 @@ Use these for stable element targeting:
| `[data-testid="upload-url"]` | Video URL input |
| `[data-testid="upload-magnet"]` | Magnet link input |
| `[data-testid="upload-submit"]` | Publish button |
| `[data-testid="hashtags-sync-status"]` | Profile hashtag sync status message |
| `[data-testid="subscriptions-sync-status"]` | Profile subscriptions sync status message |
| `[data-testid="blocked-refresh-button"]` | Profile blocked creators refresh button |
| `[data-testid="blocked-sync-status"]` | Profile blocked creators sync status message |
| `[data-testid="blocked-list"]` | Blocked creators list container |
| `[data-testid="blocked-empty-state"]` | Blocked creators empty state |
| `[data-testid="video-modal"]` | Video player modal |
| `[data-testid="video-card"]` | Individual video card in the feed |
| `[data-testid="video-list"]` | Video feed grid container |
@@ -442,9 +440,6 @@ Use these for stable element targeting:
| `scripts/agent/simple-relay.mjs` | Mock relay with HTTP seeding API |
| `tests/e2e/helpers/bitvidTestFixture.ts` | Reusable Playwright fixture |
| `tests/e2e/agent-testability.spec.ts` | Infrastructure validation tests |
| `scripts/playwright/run-extension-persistent.mjs` | Dedicated persistent-context launcher for extension smoke tests |
| `docs/testing/playwright-integration-recommendations-2026-02-22.md` | Playwright/extension integration recommendations and follow-ups |
| `docs/testing/playwright-function-coverage.md` | Playwright coverage integration, latest baseline metrics, and improvement plan |
---
@@ -551,132 +546,6 @@ When finishing a task or handing off, provide:
---
## 16. Scenario-First Tests & Test Integrity (Dark Factory Standard)
In this repo, **validation replaces code review**. That means our test/scenario system is the *only* thing standing between “working software” and “green-but-worthless software.”
Agents will naturally optimize for short-term goals (e.g., “get CI green”) unless we constrain them. If we ever allow “edit tests until they pass,” we destroy the signal, and over time the suite becomes meaningless.
### Core philosophy: scenarios > checklists
We treat tests as **behavioral specifications** expressed as **scenarios**:
- “User stories” / end-to-end behaviors (Given/When/Then or equivalent)
- Assertions focus on **externally observable outcomes** at boundaries (API responses, persisted state, emitted events, CLI output, UI state), not internal call sequences.
- We prefer *minimal coupling* to implementation details so refactors don’t require rewriting “truth.”
(StrongDM reached the same conclusion: repo-local tests are easy to reward-hack; scenarios and satisfaction-based validation reduce “teaching to the test.”)
### Non-negotiable constitution (anti-cheat)
**Hard rules (always on):**
1) **Never weaken, delete, or rewrite a test just to make CI pass.**
2) **Never change expected behavior to match buggy output** (“rubber-stamp the snapshot”, “update golden”, “adjust assertion”) unless it is a **spec correction** (see below).
3) Do not “fix” flaky tests with retries, sleeps, timeouts, or looser assertions. **Remove nondeterminism** instead (control time, randomness, IO, network).
4) Prefer black-box assertions at system boundaries; avoid tests that merely mirror internal logic.
### Definitions
- **Scenario**: a behavioral spec (often end-to-end) describing what must happen for a user/system in a real environment.
- **Invariant**: a property that must always hold across many inputs/states (great for property-based or metamorphic testing).
- **Holdout scenario**: an evaluation scenario kept outside the agent-editable area to reduce overfitting / reward-hacking.
### When tests may change (spec correction protocol)
Changing a test expectation is allowed **only** when the test was enforcing the wrong behavior.
If you believe a test should change:
1) Identify the **scenario/spec** that defines correct behavior.
2) Explain precisely why the old expectation was wrong.
3) Replace it with an **equally strict or stricter** behavior-based check.
4) Record a **Test Integrity Note** (below) and, if applicable, a short spec note (e.g., `docs/spec_changes/<date>-<slug>.md`).
If you cannot point to scenario/spec truth, **do not change the test**. Add a “Needs Spec Clarification” note instead.
### “Test Integrity Note” (required for any PR touching tests)
Every PR that adds/changes tests must include this machine-readable block in the PR description (or in a `TEST_INTEGRITY.md` entry referenced by the PR):
```yaml
test_integrity_note:
change_type: ["new_tests" | "refactor_tests" | "spec_correction" | "flake_fix"]
scenarios:
- id: SCN-<slug>
given: "<preconditions>"
when: "<stimulus>"
then: "<observable outcomes>"
observable_outcomes:
- "<what the user/system can observe at the boundary>"
determinism_controls:
- "<fake clock / fixed seed / hermetic env / service virtualization>"
anti_cheat_rationale:
prevents:
- "hard-coded return value"
- "over-mocking internal logic"
- "snapshot rubber-stamping"
- "retry/sleep-based flake masking"
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
### Test portfolio guidance (scenario-first, still layered)
We want a balanced suite:
- **Scenario/E2E**: user-story validation at boundaries (few, high value, high fidelity)
- **Integration/contract**: service boundaries, serialization, persistence, workflows
- **Unit/invariants**: properties that kill trivial cheats (“return true” shouldn’t survive)
For cheat resistance, strongly prefer:
- invariants/property-based tests for critical logic
- metamorphic relations where “exact expected output” is hard
- periodic mutation testing to “test the tests” (surviving mutants indicate missing assertions)
### External dependency realism (service virtualization / “digital twins”)
Where third-party services are involved, we prefer deterministic “behavioral clones” (mocks/stubs at the API boundary that reproduce edge cases and contracts) over live calls. This keeps scenarios realistic without being flaky or rate-limited, and it makes high-volume scenario validation affordable.
### Role: Test Integrity & Scenario Spec Agent (Enforcer)
This repo includes (or assumes) a dedicated agent role whose only job is to protect validation integrity.
This agent’s goal is NOT “make CI green.” Its goal is “make the suite reflect reality and reject fake passes.”
**Authority:**
- May add new scenarios, invariants, and tests.
- May refactor tests to be more behavioral and less procedural.
- May improve determinism (fixed seeds, fake clocks, hermetic env).
- May only change expectations via the Spec Correction Protocol above.
**Prohibitions:**
- Must never weaken tests to pass.
- Must never edit holdout scenarios (if configured).
- Must never solve flakiness via retries/sleeps/loosening.
**Deliverables each run:**
- Scenario list (Given/When/Then)
- Proposed test diffs
- Test Integrity Note
- “Cheat vectors blocked” summary (what trivial implementations it prevents)
---
## 17. NIP-07 Signer Reliability & Encrypted-List Loading (hard-won, 2026-06-16)
The single biggest source of "DMs / hashtags / watch-history / block & subscription lists won't load after login" is **not** bitvid — it's an **unresponsive NIP-07 signer**. The extension's MV3 background service-worker can die or its content-script↔worker channel can orphan, after which raw `window.nostr` calls hang forever. No client change can force a dead signer to answer.
* **Diagnose first, don't guess.** Run a single raw `window.nostr` probe in the page console (`getPublicKey → nip04.encrypt → nip04.decrypt`) that bypasses bitvid entirely. If *that* hangs, it's the extension/environment. Recommend a well-maintained signer (nos2x, Alby); KeysBand's dead worker was the root cause and switching to nos2x fixed everything.
* **Resilience invariants — do not regress these** (see `docs/KNOWN_BUGS.md` #0 for the full history and the files):
1. **Cap relay fan-out** (`js/nostr/toolkit.js` `capReadRelays`, ≤8, user-relays-first + 2 reserved default slots). An uncapped cold-login REQ storm to ~20 dead NIP-65 relays starves the single-threaded signer's postMessage round-trips.
2. **Circuit breaker** on the NIP-07 channel (`js/nostr/nip07Permissions.js`): after N consecutive *timeouts* fast-fail instead of hanging ~15s each. Channel-death errors must count toward opening (not reset it); interactive permission prompts bypass it; one periodic probe detects recovery.
3. **Never swallow a transient decrypt error as an empty result.** Re-throw channel-death/timeout sub-errors so the retry path runs — returning `[]` turns "signer is slow" into "user has no blocks/lists" and kills retries.
4. **Generous decrypt budget** (~25–30s/call, ~60s backoff cap); a 6s timeout kills slow-but-responsive signers mid-decrypt. Handshake variant timeout is ~20s for the same reason.
5. **One actionable user notice** (`js/utils/signerHealthNotice.js`) after a few timeouts with `signerStatus: "present"` — not a silent forever-retry.
* **Don't render into closed modals at login.** Once a responsive signer makes decryption instant, eagerly populating every profile panel at login (friends avatars, subscriptions, blocks, DM summaries) freezes the main thread ~10–15s. Populate each pane lazily on open (`selectPane`) and gate data-change re-render listeners on "is the view open".
* **Deterministic testing.** Reproduce signer-dependent bugs headlessly with a fake `window.nostr` (Playwright `exposeBinding` + `addInitScript`) + a mock relay + configurable channel models (healthy/slow/overload/dead, latency override). See `scripts/perf/nip07-channel-sim.mjs`.
---
## Next
Please read these documents next.
-18
View File
@@ -1,18 +0,0 @@
# Baseline Benchmark for Moderation Stage Batching
## Command
`node benchmarks/moderation_batch_bench.js`
## Environment
- Node.js v22.22.0
- 5000 items
- 500 authors
- 100 muters per author
- Forced prune check (lastPrunedAt = 0)
## Results
- Run 1: 545.80ms
- Run 2: 370.08ms
- Run 3: 523.04ms
- Run 4: 379.80ms
- **Average: ~454ms**
+1 -94
View File
@@ -135,8 +135,6 @@ npm run test:visual
| `npm run lint:tokens` | Design token enforcement |
| `npm run lint:file-size` | Enforce file size limits |
| `npm run lint:innerhtml` | Enforce innerHTML baseline |
| `npm run audit` | Run design system audit |
| `npm run build:beacon` | Build torrent beacon assets |
| `npm run test:unit` | Full unit test suite |
| `npm run test:unit:shard1` | Shard 1/3 (faster local dev) |
| `npm run test:unit:shard2` | Shard 2/3 |
@@ -145,10 +143,7 @@ npm run test:visual
| `npm run test:dm:unit` | Direct message unit tests |
| `npm run test:dm:integration` | DM integration tests |
| `npm run test:e2e` | Headless E2E tests |
| `npm run test:load` | Run load tests |
| `npm run test:visual` | Visual regression tests |
| `npm run test:visual:update` | Update visual regression baselines |
| `npm run telemetry:aggregate` | Aggregate telemetry logs |
| `node scripts/agent/validate-events.mjs` | Validate Nostr event schemas |
---
@@ -310,95 +305,6 @@ const dTag = event.tags.find(t => t[0] === 'd')?.[1];
// WRONG: Don't use logical IDs like videoRootId for relay lookups
```
### NIP-07 Signer Reliability (hard-won)
The #1 cause of "DMs / hashtags / watch-history / lists won't load after login" is an **unresponsive NIP-07 signer**, not bitvid. The extension's MV3 background worker can die and `window.nostr` calls then hang forever — no client change fixes a dead signer. **Diagnose with a raw `window.nostr` probe** (`getPublicKey → nip04.encrypt → nip04.decrypt`) in the console; if that hangs, it's the extension (recommend nos2x/Alby). See `AGENTS.md` §17 and `docs/KNOWN_BUGS.md` #0.
Resilience invariants — **do not regress**:
1. **Cap relay fan-out** (`js/nostr/toolkit.js` `capReadRelays`, ≤8, user-relays-first + 2 reserved defaults) — an uncapped cold-login REQ storm starves the single-threaded signer.
2. **Circuit breaker** on consecutive NIP-07 timeouts (`js/nostr/nip07Permissions.js`) — fast-fail instead of hanging; channel-death counts toward opening; permission prompts bypass.
3. **Never swallow a transient decrypt error as `[]`** — re-throw channel-death/timeout so retries run (returning empty = "user has no lists" + no retry).
4. **Generous decrypt budget** (~25–30s/call) — a 6s timeout kills slow signers mid-decrypt.
5. **Lazy modal population** — don't render every profile panel into a closed modal at login; populate per-pane on open (`selectPane`).
---
## Testing & Validation: Scenario-First, Cheat-Resistant (Dark Factory Rules)
You are operating inside a “Dark Factory” workflow where **validation replaces human code review**. Passing tests is only meaningful if tests are truthful.
### Prime directive
**Never alter a test merely to make it pass.**
If you change tests to match buggy behavior, you are destroying the only feedback signal and producing code with no real-world value.
StrongDM describes the exact failure mode we are preventing: agents discovering shortcuts (“return true”) and narrow tests being reward-hacked, leading to a shift from “tests” to scenario-based validation.
### How to think about tests here
Tests are not procedural checklists. They are **behavioral specifications**:
- Write tests as **scenarios** (Given/When/Then or equivalent).
- Assert **externally observable outcomes** at boundaries.
- Avoid “testing the implementation” (internal call sequences, private methods, mirrored logic).
- Prefer deterministic/harmonic execution: fixed seeds, controlled clocks, hermetic IO, virtualized dependencies.
### Prohibited behaviors (reward-hacking patterns)
Do NOT:
- weaken assertions, broaden tolerances, or reduce coverage to get green
- update snapshots/goldens without proving the change is intended behavior
- add retries/sleeps/timeouts to hide flakes
- mock so heavily that tests can pass with a broken system
- “hard-code to the test” (single fixtures, single input paths, brittle constants)
### Allowed test changes: Spec Correction Protocol
You may change test expectations only if the test is wrong relative to intended behavior.
Required steps:
1) Identify the scenario/spec that defines correct behavior.
2) Explain why the previous expectation was incorrect.
3) Replace it with an equally strict (or stricter) behavioral assertion.
4) Provide a **Test Integrity Note** (machine-readable) documenting the change.
If you cannot justify a change with scenario/spec truth: **do not change the test**. Instead, output a “Needs Spec Clarification” note and propose candidate scenarios.
### Required output when you touch tests
Include this YAML block in your PR description (or reference it from a `TEST_INTEGRITY.md` entry):
```yaml
test_integrity_note:
change_type: ["new_tests" | "refactor_tests" | "spec_correction" | "flake_fix"]
scenarios:
- id: SCN-<slug>
given: "<preconditions>"
when: "<stimulus>"
then: "<observable outcomes>"
observable_outcomes:
- "<boundary/output asserted>"
determinism_controls:
- "<fixed seed / fake clock / hermetic env / service virtualization>"
anti_cheat_rationale:
prevents:
- "hard-coded return value"
- "over-mocking internal logic"
- "snapshot rubber-stamping"
- "retry/sleep-based flake masking"
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
### If external services are involved
Prefer boundary-level service virtualization (“digital twins” / behavioral clones) so scenario tests remain:
- high fidelity (realistic edge cases)
- deterministic and replayable
- not rate-limited / not flaky
(StrongDM’s “Digital Twin Universe” is the reference pattern.)
---
## Testing Guidelines
@@ -582,6 +488,7 @@ When assigning work to agents:
| `docs/playback-fallback.md` | URL-first strategy |
| `docs/moderation/README.md` | Moderation system |
| `docs/logging.md` | Logger usage |
| `docs/agents/TORCH.md` | TORCH distributed task locking protocol |
| `context/` / `todo/` / `decisions/` / `test_logs/` | Agent persistent state files (see AGENTS.md §15) |
---
+11 -30
View File
@@ -1,40 +1,21 @@
# Known Issues
## Test Infrastructure
## Tests
### Missing `jsdom` Dependency
- **Status:** Active
- **Detected:** 2026-02-26
- **Description:** Multiple unit tests fail with `ERR_MODULE_NOT_FOUND` because `jsdom` is imported but not installed in the project.
- **Impact:** Prevents running unit tests that rely on DOM simulation.
- **Remediation:** `npm install --save-dev jsdom`
### Skipped Tests
## Run Notes (2026-03-04)
The following tests are skipped due to flakiness or environmental issues and should be investigated:
### Firefox E2E feed hydration flakes under full parallel load
- **Status:** Active
- **Description:** `test:e2e` can intermittently fail in Firefox with `[testHarness] Timed out waiting for <n> feed items (found 0)` in `waitForFeedItems`, while the same failing specs usually pass in isolated reruns.
- **Impact:** Full-suite Firefox E2E runs are flaky even after local polling hardening.
- **Observed failures:** `tests/e2e/video-crud-flows.spec.ts`, `tests/e2e/video-edit-delete-execution.spec.ts`, `tests/e2e/view-navigation.spec.ts`.
_No skipped tests currently documented._ (Last checked: 2026-02-17)
### Nostr tools signing/key API expectation in unit tests
- **Status:** Addressed
- **Description:** `tests/nostr-send-direct-message.test.mjs` must pass `Uint8Array` secret keys to `getPublicKey/finalizeEvent` for current `nostr-tools`.
- **Impact:** Passing hex strings causes failures like `expected Uint8Array, got type=string`.
### Visual Regression Tests (`test:visual`)
## Run Notes (2026-06-20)
- **Artifact Retention**: Visual tests are configured to retain screenshots, traces, and videos on failure to assist with debugging. These artifacts can be found in `artifacts/test-results/`.
- **Note**: Visual tests are passing as of 2026-02-12. (Agent verified 2026-02-17: Tests fail if browsers are missing, consistent with Environment issue).
### Additional E2E specs flake under full parallel load (pass in isolation)
- **Status:** Active
- **Description:** Under the full `test:e2e` run (378 tests, 4 workers) these intermittently fail on `not.toBeVisible`/feed-appearance timeouts, but pass deterministically when re-run in isolation (`--workers=1`): `login-flows.spec.ts:149` (upload/profile buttons hide after logout), `video-upload-publish-lifecycle.spec.ts:49` and `:79` (URL-first publish appears / owner delete). Same root cause as the Firefox feed-hydration flakes above — worker resource contention, not an app regression (verified: pass in isolation on the current branch).
- **Impact:** Full-suite e2e is flaky; isolated reruns are green.
## Environment
### `webseed-playback.spec.ts:31` fails on Firefox headless (CDN mode)
- **Status:** Active (pre-existing — reproduced on pre-refactor commit `1b11cb1b`)
- **Description:** "plays directly from HTML link (CDN mode)" fails on `--project=e2e-firefox` with `#modalVideo` `src` empty instead of the test's archive.org URL. The Chromium variant and the WebTorrent-fallback variant (`:63`) both pass. Firefox-headless-specific (external URL reachability/codec), not an app regression.
- **Impact:** One Firefox-only e2e failure; CDN playback works on Chromium and in manual testing.
### Playwright Browsers
### `uploadModal-reset.test.mjs` ("UploadModal Reset Logic") hangs/cancels
- **Status:** Active (pre-existing — reproduced on pre-refactor commit `1b11cb1b`)
- **Description:** The jsdom-based UploadModal reset suite intermittently fails with `cancelledByParent` / "Promise resolution is still pending but the event loop has already resolved" (~12s hang). It passes or fails depending on full-suite test ordering/timing (an async-hang flake, likely a torrent-metadata/upload promise not settling under the mocked services). Not a regression from the MediaUploader extraction — it fails identically on `1b11cb1b`.
- **Impact:** One unit suite can report `not ok 1`; the runner still exits 0. Other tests unaffected.
- **Issue**: Visual and E2E tests (`npm run test:visual`, `npm run test:e2e`) may fail with `browserType.launch: Executable doesn't exist`.
- **Workaround**: Run `npx playwright install` to download the required browser binaries if you are running tests outside of the pre-configured dev container or CI environment. (Last checked: 2026-02-17)
+7 -26
View File
@@ -4,53 +4,34 @@
|------------|-------------|----------------|--------|-------|
| **NIP-01** | Basic Protocol Flow | `js/nostr/client.js`, `docs/nips/01.md` | Compliant | Base implementation verified via `client.js`. |
| **NIP-04** | Encrypted Direct Message | `js/nostr/dmDecryptWorker.js`, `js/nostr/client.js` | Compliant | Verified by `tests/nostr-specs/nip04-nip44.test.mjs`. |
| **NIP-07** | `window.nostr` Capability | `js/nostr/nip07Permissions.js`, `js/nostr/adapters/nip07Adapter.js` | Compliant | Verified in `tests/compliance/nip07_compliance.test.mjs`. |
| **NIP-09** | Event Deletion | `js/nostr/client.js`, `js/nostrEventSchemas.js` | Compliant | `deleteAllVersions` calls `_hardDeleteVersions` which uses Kind 5. |
| **NIP-07** | `window.nostr` Capability | `js/nostr/nip07Permissions.js`, `js/nostr/adapters/nip07Adapter.js` | Compliant | Retry logic and timeouts verified in `nip07_compliance.test.mjs`. |
| **NIP-09** | Event Deletion | `js/nostr/client.js`, `docs/nips/09.md` | Partial | `deleteAllVersions` implements Kind 5 logic. |
| **NIP-10** | Text Notes & Threads | `js/nostr/commentEvents.js`, `docs/nips/10.md` | Compliant | `buildCommentEvent` implements NIP-10 threading markers (E, P, K tags). |
| **NIP-17** | Private Direct Messages | `js/nostr/client.js`, `js/ui/profileModal/ProfileDirectMessageRenderer.js` | Compliant | Explicit support for Gift Wraps (Kind 1059) and Seals (Kind 13). |
| **NIP-18** | Reposts | `js/nostrEventSchemas.js` | Compliant | Implements Kind 6 and 16. |
| **NIP-19** | bech32-encoded entities | `js/utils/nostrHelpers.js`, `js/nostr/nip71.js` | Compliant | Uses `nostr-tools` for correct encoding/decoding. |
| **NIP-21** | `nostr:` URI scheme | `js/app/routerCoordinator.js`, `js/utils/nostrHelpers.js` | Partial | Router handles `nostr:` prefix but only for hex/npub. `nprofile`/`nevent` not supported in helper. |
| **NIP-25** | Reactions | `js/nostr/reactionEvents.js`, `js/nostrEventSchemas.js` | Compliant | Implements Kind 7 reaction events. |
| **NIP-21** | `nostr:` URI scheme | `docs/nips/21.md` | Unknown | Usage in content parsing verified in `js/utils/nostrHelpers.js`. |
| **NIP-33** | Addressable Events | `js/nostr/client.js` | Compliant | `d` tag usage verified in `video_note_compliance.test.mjs`. |
| **NIP-42** | Authentication of clients to relays | `js/nostr/client.js` | Non-compliant | No `AUTH` command handling found in client or pool. |
| **NIP-44** | Encrypted Payloads (Versioned) | `js/nostr/dmDecryptWorker.js`, `js/nostr/client.js` | Compliant | Verified by `tests/nostr-specs/nip04-nip44.test.mjs`. |
| **NIP-46** | Nostr Remote Signing | `js/nostr/nip46Client.js` | Compliant | Full client implementation for remote signing. |
| **NIP-47** | Wallet Connect | `js/payments/nwcClient.js` | Compliant | Implements NWC client for zaps. |
| **NIP-51** | Lists | `js/userBlocks.js`, `js/subscriptions.js` | Compliant | Mute list (Kind 10000) and Subscription list (Kind 30000) logic aligns with spec. |
| **NIP-56** | Reporting | `js/services/moderationService.js`, `docs/nips/56.md` | Compliant | `buildReportEvent` implements Kind 1984. |
| **NIP-57** | Zaps | `js/payments/zapRequests.js`, `js/payments/zapSplit.js` | Compliant | Implements Kind 9734/9735 flow. |
| **NIP-59** | Gift Wrap | `js/nostr/client.js` | Compliant | Implemented as part of NIP-17. |
| **NIP-65** | Relay List Metadata | `js/relayManager.js` | Compliant | Code supports Kind 10002. Verified by `tests/nostr-specs/nip65_compliance.test.mjs`. |
| **NIP-65** | Relay List Metadata | `js/relayManager.js` | Partial | Code supports Kind 10002. Test `nip65_compliance.test.mjs` is missing. |
| **NIP-71** | Video Events | `js/nostr/nip71.js` | Partial | Kinds 21/22 supported in schema, but Kind 30078 is primary. Verified tag generation in `tests/nostr-specs/kind30078.test.mjs`. |
| **NIP-78** | Application-specific data | `js/services/hashtagPreferencesService.js` | Compliant | Used for hashtag preferences (Kind 30015). |
| **NIP-94** | File Metadata | `js/nostr/videoPublisher.js` | Compliant | `buildVideoMirrorEvent` implements Kind 1063. |
| **NIP-96** | HTTP File Storage Integration | `js/nostr/videoPublisher.js` | Partial | References `service nip96` in NIP-71 tags. |
| **NIP-98** | HTTP Auth | `js/services/r2Service.js` | Compliant | Used for R2 service authentication (Kind 27235). |
| **Kind 0** | Profile Metadata | `js/nostrEventSchemas.js` | Compliant | Implemented. |
| **Kind 1** | Share | `js/nostrEventSchemas.js` | Compliant | Implemented. |
| **Kind 4** | Legacy DM | `js/nostrEventSchemas.js` | Compliant | Implemented. |
| **Kind 5** | Deletion | `js/nostrEventSchemas.js` | Compliant | Implemented. |
| **Kind 6** | Repost | `js/nostrEventSchemas.js` | Compliant | Implemented. |
| **Kind 7** | Reaction | `js/nostrEventSchemas.js` | Compliant | Implemented. |
| **Kind 13** | Seal | `js/nostrEventSchemas.js` | Compliant | Implemented. |
| **Kind 14** | Chat Message | `js/nostrEventSchemas.js` | Compliant | Implemented. |
| **Kind 16** | Generic Repost | `js/nostrEventSchemas.js` | Compliant | Implemented. |
| **Kind 21** | NIP-71 Video | `js/nostr/nip71.js` | Compliant | Implemented. |
| **Kind 22** | NIP-71 Short Video | `js/nostr/nip71.js` | Compliant | Implemented. |
| **Kind 1059** | Gift Wrap | `js/nostrEventSchemas.js` | Compliant | Implemented. |
| **Kind 1111** | Video Comment | `js/nostrEventSchemas.js` | Compliant | Implemented. |
| **Kind 1984** | Report | `js/nostrEventSchemas.js` | Compliant | Implemented. |
| **Kind 9734** | Zap Request | `js/nostrEventSchemas.js` | Compliant | Implemented. |
| **Kind 9735** | Zap Receipt | `js/nostrEventSchemas.js` | Compliant | Implemented. |
| **Kind 10000** | Mute List | `js/userBlocks.js` | Compliant | Uses standard NIP-51 (no d tag for main list). |
| **Kind 10002** | Relay List | `js/relayManager.js` | Compliant | Verified. |
| **Kind 10050** | DM Relay List | `js/nostrEventSchemas.js` | Compliant | Implemented. |
| **Kind 20001** | DM Read Receipt | `js/nostrEventSchemas.js` | Compliant | Implemented. |
| **Kind 20002** | DM Typing | `js/nostrEventSchemas.js` | Compliant | Implemented. |
| **Kind 27235** | HTTP Auth | `js/services/r2Service.js` | Compliant | Implemented. |
| **Kind 1063** | File Header | `js/nostr/videoPublisher.js` | Compliant | Implemented for NIP-94 mirroring. |
| **Kind 30000** | Subscription List | `js/subscriptions.js` | Compliant | Implements NIP-51 follow sets. |
| **Kind 30015** | Hashtag Preferences | `js/services/hashtagPreferencesService.js` | Compliant | Implemented. |
| **Kind 30078** | App Data | `js/nostr/client.js`, `js/nostr/videoPayloadBuilder.js` | Compliant | Verified by `tests/nostr-specs/kind30078.test.mjs`. |
| **Kind 30079** | Watch History | `js/nostr/watchHistory.js`, `config/instance-config.js` | Compliant | Parameterized replaceable list (custom kind). Implements bucketing and encryption. |
| **NIP-25** | Reactions | `js/nostr/reactionEvents.js`, `js/nostrEventSchemas.js` | Compliant | Implements Kind 7 reaction events. |
| **NIP-42** | Authentication of clients to relays | `js/nostr/client.js` | Unknown | Needs verification of AUTH command handling. |
| **NIP-47** | Wallet Connect | `js/payments/nwcClient.js` | Compliant | Implements NWC client for zaps. |
+28 -48
View File
@@ -1,59 +1,39 @@
# Onboarding Audit Report
**Date:** 2026-02-21
**Date:** 2026-02-15
**Agent:** onboarding-audit-agent
**Headline:** ⚠️ Onboarding failures found
**Status:** ⚠️ Onboarding failures found (Fixed in this run)
## 1. Environment Assumptions
- **Node**: v22.22.0
- **NPM**: 10.9.0
- **OS**: Linux
## Environment
- **Node:** v22.22.0
- **NPM:** 11.10.0
- **OS:** Linux
## 2. Steps Executed
1. `npm ci`
2. `npx playwright install`
3. `npm run build` (Simulating `npm start` build phase)
4. `npm run test:unit:shard1`
5. `npm run test:smoke`
6. `npm run format`
7. `npm run lint`
8. `npm run audit`
## Executed Steps (Clean Environment)
## 3. Results
| Step | Command | Result | Notes |
|------|---------|--------|-------|
| 1 | `npm ci` | ✅ Pass | Installed dependencies successfully. |
| 2 | `npm run build` | ✅ Pass | Build verified manually (simulating `npm start` build step). |
| 3 | `npm run test:unit:shard1` | ✅ Pass | Unit tests passed. |
| 4 | `npm run test:smoke` | ❌ Fail / ✅ Pass | Failed initially due to missing Playwright browsers. Passed after running `npx playwright install`. |
| 5 | `npm run format` | ✅ Pass | Format check passed. |
| 6 | `npm run lint` | ✅ Pass | Lint check passed. |
| Command | Status | Notes |
| :--- | :--- | :--- |
| `npm ci` | ✅ Pass | Installed dependencies successfully. |
| `npx playwright install` | ✅ Pass | Downloaded Chromium, Firefox, WebKit. |
| `npm run build` | ✅ Pass | Build completed, assets hashed. |
| `npm run test:unit:shard1` | ✅ Pass | Unit tests passed. |
| `npm run test:smoke` | ✅ Pass | Smoke test passed. |
| `npm run format` | ✅ Pass | No formatting changes needed. |
| `npm run lint` | ❌ Fail | **File size violation** in `js/subscriptions.js`. |
| `npm run audit` | ✅ Pass | Audit report generated. |
## Failures & Fixes
## 4. Failures
### `npm run lint`
**Error Log:**
### 1. Missing Playwright Browsers
**Command:** `npm run test:smoke`
**Failure:**
```
1 file size violation(s):
✗ GREW: js/subscriptions.js (2426 lines, was 2374, limit 2424)
New files must stay under 1000 lines. Grandfathered files must not grow.
To decompose a large file, extract logic into smaller modules and re-export.
browserType.launch: Executable doesn't exist at /home/jules/.cache/ms-playwright/chromium_headless_shell-1208/chrome-headless-shell-linux64/chrome-headless-shell
Looks like Playwright Test or Playwright was just installed or updated.
Please run the following command to download new browsers:
npx playwright install
```
**Root Cause:** `npm ci` installs the `@playwright/test` package but does not download the browser binaries required for smoke and visual tests. The documentation did not mention this step for local setup.
**Fix:** Updated `README.md` and `CONTRIBUTING.md` to include `npx playwright install` as a required step for running these tests.
**Root Cause:**
`js/subscriptions.js` has grown beyond its grandfathered limit + buffer. The documented limit is 2424 lines, but the file is now 2426 lines.
**Recommendation:**
1. Immediate fix: Increase the limit in `scripts/check-file-size.mjs` slightly to unblock CI if decomposition is not immediately possible.
2. Long term: Decompose `js/subscriptions.js`.
## 5. Docs Changes Made
- None. Documentation accurately reflects the commands, but the codebase fails the lint check.
## 6. Devcontainer/Docker
- Not required. Environment setup (`npm ci`, `playwright install`) worked reliably.
## Documentation Updates
- **README.md**: Added `npx playwright install` note to "Local Setup".
- **CONTRIBUTING.md**: Added `npx playwright install` note to "Development Setup".
-62
View File
@@ -1,62 +0,0 @@
# Protocol Inventory
This document tracks external protocol and specification dependencies used by the codebase. It complements `NIP_INVENTORY.md` which specifically tracks Nostr Implementation Possibilities.
## 1. Nostr (NIPs)
For a detailed inventory of supported NIPs (Nostr Implementation Possibilities), please refer to [NIP_INVENTORY.md](./NIP_INVENTORY.md).
**Summary of Key NIPs:**
- **NIP-01:** Basic Protocol Flow (Compliant)
- **NIP-71:** Video Events (Partial/Compliant)
- **NIP-94:** File Metadata (Compliant)
- **NIP-44:** Encrypted Payloads (Compliant)
## 2. BitTorrent / WebTorrent
| Protocol | Spec | Source URL | Implementation | Status |
|----------|------|------------|----------------|--------|
| **BitTorrent** | BEP-0003 | [bittorrent.org](http://www.bittorrent.org/beps/bep_0003.html) | `js/services/playbackService.js` | Compliant (via WebTorrent) |
| **Magnet URI** | BEP-0009 | [bittorrent.org](http://www.bittorrent.org/beps/bep_0009.html) | `js/magnetUtils.js` | Compliant |
| **WebSeed** | BEP-0019 | [bittorrent.org](http://www.bittorrent.org/beps/bep_0019.html) | `js/magnetUtils.js` | Compliant |
| **WebTorrent** | WebRTC-based BitTorrent | [webtorrent.io](https://webtorrent.io/docs) | `js/services/playbackService.js` | Compliant |
**Notes:**
- `js/magnetUtils.js` implements parsing and normalization of magnet links, ensuring `xt`, `ws`, and `xs` parameters are handled correctly.
- `js/services/playbackService.js` handles the fallback logic between HTTP and WebTorrent playback.
## 3. Media Streaming
| Protocol | Spec | Source URL | Implementation | Status |
|----------|------|------------|----------------|--------|
| **HLS** | RFC 8216 | [rfc-editor.org](https://datatracker.ietf.org/doc/html/rfc8216) | Native Browser / `video` tag | Compliant |
**Notes:**
- The application supports `.m3u8` (HLS) playback natively via the browser's video element (Safari) or via HLS.js if integrated (currently relies on browser/device native support or passthrough).
- Inputs in `components/upload-modal.html` accept `.m3u8` and `.ts` files.
## 4. Lightning Network (LNURL)
| Protocol | Spec | Source URL | Implementation | Status |
|----------|------|------------|----------------|--------|
| **LNURL** | LUD-01, LUD-06, LUD-16 | [github.com/lnurl](https://github.com/lnurl/luds) | `js/payments/lnurl.js` | Compliant |
| **Zap Requests** | NIP-57 | [nips.be](https://github.com/nostr-protocol/nips/blob/master/57.md) | `js/payments/zapRequests.js` | Compliant |
**Notes:**
- `js/payments/lnurl.js` implements Bech32 decoding/encoding and LNURL resolution.
- `js/payments/zapReceiptValidator.js` validates receipts against the LNURL server's public key.
## 5. HTTP & Authentication
| Protocol | Spec | Source URL | Implementation | Status |
|----------|------|------------|----------------|--------|
| **Nostr HTTP Auth** | NIP-98 / Kind 27235 | [nips.be](https://github.com/nostr-protocol/nips/blob/master/98.md) | `js/services/r2Service.js` | Compliant |
| **S3 / R2 API** | AWS S3 Compat | [aws.amazon.com](https://docs.aws.amazon.com/s3/) | `js/services/r2Service.js` | Compliant |
**Notes:**
- `js/services/r2Service.js` uses NIP-98 style authentication for uploads to compatible storage services (Cloudflare R2).
## Compliance & Remediation
- **NIP-42 (Auth):** Marked as `Non-compliant` in `NIP_INVENTORY.md`. Remediation involves implementing `AUTH` command handling in `js/nostr/client.js`.
- **HLS:** Reliance on native browser support might limit compatibility on some desktop browsers (Firefox/Chrome on Windows without extensions). Consider integrating `hls.js` if not already present.
+3 -16
View File
@@ -30,9 +30,7 @@
1. **Visit the Site**: Navigate to the live instance of **bitvid** (e.g., `[https://bitvid.network](https://bitvid.network)`).
2. **Login with Nostr**:
- Use a compatible NIP-07 browser extension or manually input your public key.
- For the most reliable experience we recommend a well-maintained signer such as [nos2x](https://github.com/fiatjaf/nos2x) or [Alby](https://getalby.com/). Encrypted features (DMs, hashtag preferences, watch history, block/subscription lists) require the extension to respond to decryption requests.
- **Troubleshooting:** if those encrypted lists never finish loading after login, bitvid will surface a notice — your extension's signer is likely locked or its background worker went to sleep. Unlock/reload the extension and refresh the page. (bitvid caps relay fan-out and fast-fails an unresponsive signer so the tab no longer pins the CPU while waiting.)
- Use a compatible Nostr browser extension or manually input your public key.
### Upload a video
@@ -181,7 +179,6 @@ To run **bitvid** locally:
npm run test:smoke # Critical path verification
npm run format
npm run lint
npm run audit # Design system audit
```
_Tip: Use `npm run test:unit` to run the full suite (can be slow)._
@@ -215,13 +212,7 @@ For detailed architecture and system documentation, see the [Documentation Index
- **Run load tests**: `npm run test:load`
- **Run DM unit tests**: `npm run test:dm:unit`
- **Run DM integration tests**: `npm run test:dm:integration`
- **Run headless E2E tests (Chromium + Firefox)**: `npm run test:e2e`
- **Run E2E tests (Chromium only)**: `npm run test:e2e:chromium`
- **Run E2E tests (Firefox only)**: `npm run test:e2e:firefox`
- **Run deterministic E2E matrix**: `npm run test:e2e:deterministic`
- **Run NIP-07 extension smoke (opt-in)**: `PLAYWRIGHT_EXTENSION_PATH=/abs/path/to/extension npm run test:e2e:extension`
- Uses persistent-context launcher: `scripts/playwright/run-extension-persistent.mjs`
- Optional profile dir: `PLAYWRIGHT_EXTENSION_USER_DATA_DIR=/abs/path/to/profile`
- **Run headless E2E tests**: `npm run test:e2e`
- **Run visual regression tests**: `npm run test:visual`
- **Update visual baselines**: `npm run test:visual:update`
- **Run design system audit**: `npm run audit` (Generates remediation report)
@@ -580,11 +571,7 @@ Before pushing, run `npm run build` locally so the Tailwind bundle regenerates.
- **Direct Messages**: Run `npm run test:dm:unit` and `npm run test:dm:integration` for DM flows.
- **End-to-End**:
- `npm run test:e2e` runs deterministic Playwright journeys on Chromium + Firefox.
- `npm run test:e2e:chromium` / `npm run test:e2e:firefox` runs a single browser target.
- `npm run test:e2e:extension` runs the opt-in Chromium extension suite (set `PLAYWRIGHT_EXTENSION_PATH` and optionally `PLAYWRIGHT_ENABLE_EXTENSION_E2E=1`).
- Recommendations/roadmap: `docs/testing/playwright-integration-recommendations-2026-02-22.md`
- **End-to-End**: Run `npm run test:e2e` for headless Playwright journeys.
### Manual QA checklist
+1 -1
View File
@@ -1,4 +1,4 @@
# Daily Design System Audit Report (2026-02-22)
# Daily Design System Audit Report (2026-02-16)
Headline: ✓ No violations
-590
View File
@@ -1,590 +0,0 @@
# Test Integrity Notes
Machine-readable record of test-expectation changes made under the Spec
Correction Protocol (see CLAUDE.md → Dark Factory rules). Each entry justifies
why a previous expectation was wrong relative to intended behavior and confirms
the replacement is equally strict (or stricter).
---
```yaml
test_integrity_note:
date: 2026-06-14
file: tests/app-batch-fetch-profiles.test.mjs
change_type: ["spec_correction"]
scenarios:
- id: SCN-profiles-batched
given: "Multiple relays configured (one fast, one failing) and uncached, valid pubkeys to fetch"
when: "batchFetchProfiles runs after profile fetches were migrated to the L1 SubscriptionManager"
then: "Profiles are fetched in ONE batched query across all relays (not one query per relay); the fast relay's newest profile still renders, cached profiles hydrate immediately, and a failing relay does not break the result"
observable_outcomes:
- "exactly one pool.list call, targeting all configured relays"
- "query authors = only uncached/valid pubkeys"
- "DOM updates: cached profile first, then newest fast-relay profile"
- "cache set with the newest (Fast) profile only"
determinism_controls:
- "mock pool.list with deterministic per-relay responses; fixed createdAt ordering"
anti_cheat_rationale:
prevents:
- "over-mocking internal logic"
- "hard-coded return value"
note: "Only the per-relay CALL-COUNT assertion changed (2 -> 1), because the
refactor intentionally batches the fetch. All behavioral assertions
(correct/newest profile rendered, cached hydration, failing relay
tolerated) are unchanged, and a new assertion verifies the single batched
query targets all relays — so coverage is equal or stronger."
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
---
```yaml
test_integrity_note:
date: 2026-06-14
files:
- tests/relay-subscribe-cap.test.mjs
- tests/user-blocks.test.mjs
- tests/subscriptions-manager.test.mjs
- tests/nostr/client.test.mjs
change_type: ["spec_correction"]
scenarios:
- id: SCN-relay-reads-bounded
given: "A user's NIP-65 read set (in prod often ~20 relays, most dead) and a
cold login that must decrypt encrypted lists (blocks, subs, hashtags) via
the nip-07 extension"
when: "Reads/subscriptions are built via capReadRelays — the user's own
relays first, with RESERVED_DEFAULT_RELAY_SLOTS reserved for reliable
default aggregators, bounded to MAX_SUBSCRIBE_RELAYS=8; writes stay
uncapped"
then: "Every configured relay is still queried when the user's set is small
(data lives on the user's own relays); the distinct read fan-out never
exceeds 8 (so a large dead list can't storm the pool and starve nip-07
decryption); at least the reserved reliable defaults remain reachable even
when the user supplied none; write reach is unchanged and defaults are NOT
injected into writes"
observable_outcomes:
- "read/subscribe relay set length <= 8"
- "writeRelays == full configured set; no default injected into writes"
- ">= RESERVED_DEFAULT_RELAY_SLOTS reliable defaults present in the read set"
- "user-blocks: every configured relay queried; fast-then-full background
ordering preserved; block list hydrates from the background relay payload"
- "subscriptions-manager: every configured relay queried even when one
rejects; only the newest event decrypted"
- "nostr/client: same events arriving from multiple seeded relays still
dedupe to the expected set; relay fan-out bounded"
determinism_controls:
- "deterministic per-relay mock pool.list responses; fixed created_at ordering"
- "capReadRelays is test-aware: the reserved core resolves to the test-relay
override under test, keeping harnesses isolated from prod default URLs"
- "tests set nostrClient.readRelays to the user's relays to mirror the
logged-in state applyRelayPreferences establishes in prod (the fetcher ranks
candidates by read preference)"
anti_cheat_rationale:
prevents:
- "hard-coded return value"
- "over-mocking internal logic"
- "retry/sleep-based flake masking"
note: "Only incidental EXACT relay-count assertions were loosened to BOUNDS
(<=8) because the storm fix intentionally adds a small reliable-default
backstop to the read set. Every behavioral assertion (correct/newest event
decrypted, block hydration from the right relay, dedup correctness, per-relay
coverage, writes uncapped) is unchanged or strengthened — coverage is equal
or stronger, and new assertions pin the reserved-defaults liveness guarantee."
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: "Exact-count assertions became bounded-count
assertions, but this reflects intended new behavior (reliable-default
backstop) rather than weakening a real correctness check; coverage of the
actual security/decrypt behavior is preserved."
```
---
```yaml
test_integrity_note:
date: 2026-06-23
file: tests/nip71-mirror-flags.test.mjs
change_type: ["spec_correction"]
scenarios:
- id: SCN-mirror-delete-unconditional
given: "A video that was shared to NIP-71 on one device/session, then deleted from another device (or after the per-video opt-in flag — browser-local localStorage — was cleared)"
when: "resolveDeleteSync runs during delete with FEATURE_NIP71_MIRROR on but the local enabled flag false/absent"
then: "The mirror teardown (NIP-09 + empty tombstone, idempotent) is still attempted, so the NIP-71 mirror is not orphaned on other apps; the feature flag still fully gates it"
observable_outcomes:
- "resolveDeleteSync({featureOn:true, enabled:false|undefined}) -> {action:'unshare'}"
- "resolveDeleteSync({featureOn:false, ...}) -> {action:'none'} (feature flag still gates)"
determinism_controls:
- "pure function; explicit input matrix"
anti_cheat_rationale:
prevents:
- "hard-coded return value"
note: "The previous expectation (enabled:false -> none) encoded the bug: it
gated teardown on a browser-local flag, orphaning the mirror across devices.
The corrected spec is STRICTER — teardown fires whenever the feature is on —
and adds the enabled:undefined case. The feature-flag gate assertion is
retained, so no real gate was weakened."
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
---
```yaml
test_integrity_note:
date: 2026-06-23
file: tests/nostr-delete-flow.test.mjs
change_type: ["spec_correction", "refactor_tests"]
scenarios:
- id: SCN-delete-surfaces-relay-failures
given: "A logged-in delete where one write relay rejects the kind-5 deletion"
when: "deleteAllVersions publishes to the WRITE set via getDeletePublishRelays() (the afb6200b relay-scope fix)"
then: "The delete summary surfaces the failing relay"
- id: SCN-buffered-feed-tombstone-guard
given: "A delete event seeds a tombstone, then an OLDER non-deleted event for the same root arrives on the live stream"
when: "events flow through the real VideoEventBuffer (the path subscribeVideos builds via the SubscriptionManager), not the removed direct pool.sub() injection"
then: "The older event is tombstone-guarded (marked deleted, kept out of activeMap) and never surfaces as a LIVE video; a deletion event surfaces marked deleted:true so the UI removes the card"
observable_outcomes:
- "deleteSummary.summary.failed identifies wss://relay.fail (writeRelays now configured so the injected failure is exercised)"
- "no callback video with the deleted/older event id and deleted:false"
- "client.activeMap cleared for the root; tombstone advanced; stored event marked deleted"
determinism_controls:
- "videoEventVerifier virtualizes the off-thread signature worker (all events valid)"
- "buffer.scheduleFlush(true) awaited for synchronous commit"
- "real NostrClient + real VideoEventBuffer (no mocked tombstone guard)"
anti_cheat_rationale:
prevents:
- "over-mocking internal logic"
- "snapshot rubber-stamping"
note: "Two corrections. (1) The file used bare assert + top-level await, so the
unit runner (which requires a node:test import) SILENTLY EXCLUDED it — it had
rotted and was failing on main unnoticed. Converted to node:test so it runs
in CI. (2) The relay-failure test set only client.relays, but deletes moved to
the write set (getDeletePublishRelays); it now sets writeRelays so the
injected failure is actually exercised — the assertion was previously passing
vacuously. (3) The subscription assertions drove the REMOVED pool.sub() path
and expected 'no callback at all' for deleted events; the current
VideoEventBuffer intentionally surfaces deleted events marked deleted:true so
the UI can remove the card. Rewritten to drive the real buffer + real client
and assert the stricter, accurate invariant (never surfaces as LIVE; guarded
and removed from activeMap). Coverage is stronger: the buffer's own unit test
mocks the client's tombstone guard, whereas this exercises the real guard."
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
---
```yaml
test_integrity_note:
date: 2026-06-23
file: tests/watch-history.test.mjs
change_type: ["spec_correction"]
scenarios:
- id: SCN-watch-history-partial-acceptance-durable
given: "A logged-in user snapshots watch history to 3 write relays where 2 accept and 1 rejects (single attempt, no escalation)"
when: "watchHistoryService.snapshot runs after the 565a9618 fix (a snapshot is durable as soon as ANY relay accepts; reads take the newest event per d-tag)"
then: "The snapshot SUCCEEDS — it does not throw, the result is ok and not retryable, no republish is scheduled, the month result is flagged partial with the accepting/rejecting relays captured, and the pointer queue is emptied (the pointer was durably published)"
- id: SCN-watch-history-logged-in-not-session
given: "A logged-in user (nostrClient.pubkey set) records a watch and the entry is published and re-loaded"
when: "publishView resolves the owning actor as the logged-in pubkey and sets the session flag only when nobody is logged in"
then: "The round-tripped entry is NOT flagged session=true (it is the logged-in user's history, not a local-only session entry); the queue files under the logged-in pubkey so getQueuedPointers(actor) sees it"
observable_outcomes:
- "snapshot() resolves (does not throw); result.ok === true; result.retryable === false"
- "scheduleWatchHistoryRepublish NOT called"
- "result.results[0].partial === true; relayStatus.pointer shows relay.one/two accepted, relay.three rejected"
- "getQueuedPointers(actor).length === 0 after a successful snapshot"
- "resolvedItems[0].session !== true for a logged-in user"
determinism_controls:
- "poolHarness resolver with a fixed accept/reject plan per relay url"
- "session crypto + recordVideoView virtualized; stable relay set"
- "logged-in pubkey aligned with the queried actor (matches the convention used by the other tests in the file)"
anti_cheat_rationale:
prevents:
- "snapshot rubber-stamping"
- "hard-coded return value"
note: "Three corrections in this file, all matching shipped behavior. (1) The
partial-acceptance test asserted the INVERSE of intended behavior — that
snapshot() THROWS on partial acceptance and retries until EVERY relay accepts —
encoding exactly the all-relays false-failure bug 565a9618 fixed. Rewritten to
the success path (no throw, partial flag, per-relay status, queue drained, no
spurious republish). (2) testWatchHistoryServiceIntegration set the logged-in
pubkey to a value DIFFERENT from the actor it queried; since watch history now
files under the logged-in pubkey, the queue assertions looked at the wrong key.
Aligned pubkey to the queried actor (the convention every other test uses) — a
setup fix, no assertion weakened. (3) The same test asserted session===true for
a logged-in watch, which is the pre-fix bug (logged-in watches were wrongly
flagged session by comparing against the view actor); corrected to session!==true
per the current rule that session marks only logged-out, local-only entries.
With these the full file is green and was REMOVED from the run-unit-tests
QUARANTINE."
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
---
```yaml
test_integrity_note:
date: 2026-06-23
files:
- tests/zap-shared-state.test.mjs
- tests/video-modal-zap.test.mjs
change_type: ["spec_correction", "refactor_tests"]
scenarios:
- id: SCN-platform-fee-unparseable-override
given: "An unparseable platform-fee override value (e.g. 'not-a-number') with PLATFORM_FEE_PERCENT configured > 0"
when: "resolvePlatformFeePercent runs"
then: "It falls back to the configured platform default (getDefaultPlatformFeePercent), NOT to 0 — a junk override cannot silently disable/bypass the platform fee"
- id: SCN-zap-completion-state-resets-then-completes
given: "A zap whose receipts validate to a warning (awaiting validated receipt)"
when: "sendZap runs"
then: "The modal completion state is reset to false at the start of the send and set true at the terminal path, so two completion-state changes are recorded and the end state is true"
- id: SCN-zap-receipts-render-into-controller-element
given: "VideoModal.renderZapReceipts called with one validated (or one failed) receipt"
when: "rendering delegates to the inner zapController"
then: "Exactly one entry is rendered into the controller's modalZapReceipts element"
observable_outcomes:
- "resolvePlatformFeePercent('not-a-number') === getDefaultPlatformFeePercent() (> 0)"
- "completedStates grows by 2 (false then true) on a completing send; end state true"
- "controller.modalZapReceipts.children.length === 1 per rendered receipt"
determinism_controls:
- "pure fee function with explicit inputs; configured PLATFORM_FEE_PERCENT read via getDefaultPlatformFeePercent (not hard-coded)"
- "modal stub records setZapCompleted/resetZapForm calls; DummyElement DOM"
anti_cheat_rationale:
prevents:
- "hard-coded return value"
- "over-mocking internal logic"
note: "Three corrections, all to match shipped behavior. (1) The fee-fallback test
asserted 0; the function correctly falls back to the configured platform default
so a garbage override cannot zero out (bypass) the fee — corrected to assert
against getDefaultPlatformFeePercent and that the result is > 0 (STRICTER: pins
the no-bypass guarantee). (2) The completion-count test asserted +1; sendZap
legitimately resets completed=false at the start of every send and sets true at
the terminal path (+2), with the end-state==true assertion retained as the real
invariant. (3) The receipt-render tests set modalZapReceipts on the outer
VideoModal, but rendering was refactored to delegate to the inner zapController
which owns that element — set/assert on the controller's element (a harness fix,
no assertion weakened). NOTE: these accompany three PRODUCTION bug fixes found
via the same tests — NWC getPublicKey hex->bytes (connection was broken), the
in-flight 'Sending…' status mis-toned as warning, and the success path not using
the holistic resetZapForm."
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
---
```yaml
test_integrity_note:
date: 2026-06-23
file: tests/video-modal-zap.test.mjs
change_type: ["spec_correction"]
scenarios:
- id: SCN-zap-paid-but-receipt-unconfirmed-is-success
given: "A zap share whose PAYMENT succeeded (NWC returned a preimage) but whose kind-9735 zap receipt could not be validated on the advertised relays"
when: "sendZap finishes"
then: "It is reported as a SUCCESS (Sent N sats) with a soft note that the on-relay receipt couldn't be confirmed — NOT as a warning/error. The form resets and the attempt is marked completed."
observable_outcomes:
- "last zap status tone === 'success' (was 'warning')"
- "status message matches /sent 750 sats/i and /couldn't confirm the zap receipt/i (was /awaiting validated zap receipt/i)"
- "receipts rendered in partial mode; form reset; completedStates +2 ending true"
determinism_controls:
- "injected splitAndZap returns a paid receipt (status:success, payment.preimage) with validation.status:'failed'"
anti_cheat_rationale:
prevents:
- "snapshot rubber-stamping"
- "hard-coded return value"
note: "The previous expectation asserted the BUG: a paid share whose 9735 receipt
wasn't found was surfaced as a 'warning' error ('Awaiting validated zap receipt')
and routed through notifyError — telling users a SUCCESSFUL zap had failed
(confirmed live: the platform fee landed but the UI showed a red error). The NWC
payment preimage is proof the invoice was paid; the 9735 receipt is supplementary
and is commonly not seen by a static client (relay coverage/timing). Corrected to
assert success-with-a-soft-note. Genuine PAYMENT failures (status !== 'success')
still go through the failure/retry path, unchanged."
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
---
```yaml
test_integrity_note:
date: 2026-06-23
file: tests/zap-split.test.mjs
change_type: ["spec_correction"]
scenarios:
- id: SCN-zap-receipt-found-by-indexed-tags
given: "A published kind-9735 zap receipt for a video zap (the zap request carries #p recipient + #e event tags)"
when: "validateZapReceipt queries relays for the receipt"
then: "It filters by the reliably-indexed #e + #p tags (not #bolt11, which most relays don't index), then confirms via author pubkey + bolt11 + description hash"
observable_outcomes:
- "the relay query filter has filters[0]['#e'] === [eventId] and ['#p'] === [recipient]"
- "filters[0]['#bolt11'] is absent"
- "a matching receipt still validates as status:'passed'"
determinism_controls:
- "mock SimplePool.list captures the filter; fixed zap request tags + test bolt11 with matching description hash"
anti_cheat_rationale:
prevents:
- "snapshot rubber-stamping"
- "over-mocking internal logic"
note: "The previous test asserted filters[0]['#bolt11'][0] === invoice — encoding the
bug. Relays generally do not index arbitrary tags like bolt11, so that query found
nothing and a successfully-paid zap was reported as having no receipt. Corrected to
assert the reliably-indexed #e/#p anchors (added an #e tag to the request to model a
real video zap). The receipt-matching assertions (passed status, correct event,
checked relays) are unchanged/retained — coverage is stronger, not weaker."
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
---
```yaml
test_integrity_note:
date: 2026-06-24
file: tests/view-counter.test.mjs
change_type: ["spec_correction", "flake_fix"]
scenarios:
- id: SCN-view-counter-legacy-path
given: "The view-counter unit harness mocks the legacy per-pointer view APIs (list/count/subscribe) on the singleton client"
when: "A pointer is subscribed and hydrated"
then: "viewCounter exercises the legacy per-pointer hydrate/subscribe path (the one viewCounter.js explicitly designates 'for tests / mocks'), so the mocked APIs actually fire"
- id: SCN-view-counter-dedupe-window-aligned
given: "Two views by the same viewer within one fixed dedupe window"
when: "Counted"
then: "They dedupe to one (floor(created_at/window) bucket, matching the replaceable view-event d-tag)"
observable_outcomes:
- "hydration triggers a list()/count() call (legacy path) instead of the batched SubscriptionManager path silently taking over"
- "two same-pubkey views in one aligned bucket yield total === 1"
determinism_controls:
- "nostrClient.getSubscriptionManager pinned to () => null so the path is deterministic"
- "the dedupe test's base timestamp is aligned to a window-bucket boundary (was Date.now(), which made the +window/2 offset straddle a boundary depending on time of day)"
anti_cheat_rationale:
prevents:
- "over-mocking internal logic"
- "retry/sleep-based flake masking"
note: "The batched SubscriptionManager path was added later (relay-storm work); the
test mocks the legacy APIs but never pinned getSubscriptionManager, so the real
singleton's manager took over and the mocks never fired. Pinning it to null restores
the intended (and code-designated 'tests/mocks') legacy-path coverage. The dedupe
assertion (count === 1) is unchanged — only its setup was made deterministic."
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
---
```yaml
test_integrity_note:
date: 2026-06-24
file: tests/view-counter.test.mjs
change_type: ["spec_correction"]
scenarios:
- id: SCN-hydrate-root-timestamp
given: "A latest video revision whose root event is fetched by hydrateVideoHistory"
when: "History is hydrated, then the timestamp-sync step runs"
then: "rootCreatedAtByRoot caches the earliest (root) created_at, AND the active video's rootCreatedAt FIELD is set by syncActiveVideoRootTimestamp"
observable_outcomes:
- "nostrClient.rootCreatedAtByRoot.get(rootId) === root.created_at (hydrateVideoHistory's direct output)"
- "after syncActiveVideoRootTimestamp(...), latestVideo.rootCreatedAt === root.created_at"
determinism_controls:
- "pool.get returns a fixed root event; rootCreatedAtByRoot seeded empty"
anti_cheat_rationale:
prevents:
- "snapshot rubber-stamping"
note: "hydrateVideoHistory's contract is to populate the per-root created_at MAP; the
video OBJECT's rootCreatedAt field is applied by a separate util
(syncActiveVideoRootTimestamp) after a refactor moved that unit boundary. The test
previously expected hydrateVideoHistory to mutate the passed object directly, which it
no longer does. Corrected to assert the map (its real output) and to drive the sync
util for the field — stronger, not weaker (it now exercises the full real pipeline)."
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
---
## 2026-06-25 — todo-11b triage: un-quarantine nostr-boost-actions + admin-list-store
```yaml
test_integrity_note:
change_type: ["spec_correction", "refactor_tests"]
scenarios:
- id: SCN-repost-optional-relay
given: "A repost is built with no relay hint available (buildRepostEvent, nostrEventSchemas.js)"
when: "buildRepostEvent({ eventId, authorPubkey, targetKind }) is called without eventRelay"
then: "It returns a VALID repost (kind 16 for a non-kind-1 target, two-element ['e', id] tag, ['p', author], ['k', kind]) instead of throwing"
- id: SCN-community-blacklist-merge-batched
given: "An admin community-blacklist sources list references two curator kind-30000 lists, each with p-tag members"
when: "loadAdminState() runs and fetches curator lists via the BATCHED SubscriptionManager path"
then: "adminState.blacklist merges the curator members with the direct entry (deduped, guards respected)"
observable_outcomes:
- "buildRepostEvent output shape (kind/tags) with no relay — asserted exactly, stricter than the old throws-check"
- "adminState.blacklist === [direct, communityMemberOne, communityMemberTwo]"
determinism_controls:
- "pure builder (no IO) for repost; in-memory list registry + mocked pool.list AND getSubscriptionManager for the admin store"
anti_cheat_rationale:
prevents:
- "hard-coded return value"
- "over-mocking internal logic"
note: |
nostr-boost-actions: STALE SPEC. The test required buildRepostEvent to throw
/missing-event-relay/ when no relay hint is given, but a relay hint is a NIP-18
SHOULD (not MUST). The builder intentionally degrades to a two-element `e` tag so a
user can always repost. Replaced the throws-assertion with an EXACT assertion of the
valid output (kind 16 + e/p/k tags) — strictly stronger.
admin-list-store: STALE HARNESS, no production change, no assertion weakened. Three
fidelity fixes so the test exercises the real batched community-blacklist path:
(1) curator lists are now fetched via the batched SubscriptionManager (cold-start
relay-storm fix), which the harness didn't mock — added a getSubscriptionManager().list
mock serving the same registry; (2) curator coordinates now use REAL 64-char hex
pubkeys (the old mock-"hex" started with "npub", so parseCommunityBlacklistReferences'
isHexPubkey()/npub branch decoded them twice and the batched filter no longer matched);
(3) createListEvent now emits a `d` tag (real kind-30000 lists always have one;
selectNewestEventsForReferences matches curator events to references by their d tag).
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
## 2026-07-05 — todo-11b final: un-quarantine nwc-client (+ user-blocks / nostr-publish-rejection hangs)
```yaml
test_integrity_note:
change_type: ["spec_correction", "refactor_tests", "flake_fix"]
scenarios:
- id: SCN-nwc-unsupported-encryption-scheme
given: "An NWC wallet whose info event (kind 13194) advertises ONLY an encryption scheme bitvid does not implement (nip44_v3)"
when: "ensureEncryptionForContext(context) negotiates a scheme"
then: "It rejects with message /Wallet advertises unsupported encryption schemes: nip44_v3\\./ AND error.code === 'UNSUPPORTED_ENCRYPTION'"
- id: SCN-nwc-mock-toolkit-selection
given: "Each nwc-client section installs a per-section window.NostrTools mock to drive encryption-scheme selection"
when: "The nostr-tools bootstrap has installed its frozen canonical toolkit on the global scope"
then: "Removing scope.__BITVID_CANONICAL_NOSTR_TOOLS__ lets readToolkitFromScope fall through to the section mock, so the wiring under test runs against the mock (not real @noble against fake keys)"
observable_outcomes:
- "ensureEncryptionForContext rejection message + error.code (UNSUPPORTED_ENCRYPTION) — asserted exactly"
- "nip44_v2 section still resolves via the mock's nip44 (assert.match on the mock ciphertext shape)"
determinism_controls:
- "per-section in-memory window.NostrTools mocks; canonical-toolkit removal is deterministic (one-time, nothing re-installs it); no network"
anti_cheat_rationale:
prevents:
- "over-mocking internal logic"
- "hard-coded return value"
note: |
nwc-client: TWO issues. (1) MOCK SHADOW (not a spec change): the bootstrap installs a
frozen canonical toolkit that readToolkitFromScope() prefers over window.NostrTools, so
the per-section mocks never took effect and real @noble ran against the fake test keys
("bad point: is not on curve"). Deleting the scope canonical lets the resolver use each
section's mock — the tests exercise the NWC client's encryption-SELECTION wiring, not
@noble crypto (which is real elsewhere; the parseNwcUri hex-vs-bytes production bug is
guarded by tests/nwc-parse-uri.test.mjs). (2) SPEC CORRECTION: one section asserted a
rejection when the toolkit "lacks nip44" by omitting nip44 from the mock. That precondition
is architecturally unreachable — the bootstrap's mergeWithCanonical ALWAYS backfills the
canonical nip44 (even an explicit `nip44: null` is overwritten by `!merged.nip44`), so
nip44_v2 is never an unsupported scheme. Replaced with a wallet advertising a scheme bitvid
genuinely cannot provide (nip44_v3), exercising the SAME UNSUPPORTED_ENCRYPTION rejection
via a reachable scenario, and strengthened the check to also assert error.code — strictly
stronger than the old message-only match.
user-blocks + nostr-publish-rejection: FLAKE_FIX (no assertion changed). Both are
bare-assert files that PASS all assertions then hang on lingering handles after completion
(user-blocks: a never-resolving decrypt's background retry timer that manager.reset() doesn't
cancel; nostr-publish-rejection: js/app.js + subscriptions + userBlocks module-load
timers/connection managers). Applied the repo's established post-completion
`setTimeout(() => process.exit(0), 50)` exit after cleanup. No behavior or expectation was
altered — the process simply exits once the assertions have all run.
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
## 2026-07-05 — todo-11: uploadModal-reset zombie-callback test made deterministic (correct mock boundary)
```yaml
test_integrity_note:
change_type: ["flake_fix", "refactor_tests"]
scenarios:
- id: SCN-upload-zombie-callback-guard
given: "An upload is in progress (videoUploadState.status === 'uploading') via a controllable pending mediaUploader.uploadVideo"
when: "resetUploads() runs mid-upload (bumping videoUploadId), then the in-flight upload later resolves (a 'zombie' completion)"
then: "The stale completion is ignored — status stays 'idle' and results.videoUrl stays '' — because handleVideoSelection guards on videoUploadId !== currentUploadId"
observable_outcomes:
- "videoUploadState.status: 'uploading' after start, 'idle' after reset, still 'idle' after the zombie resolve"
- "results.videoUrl.value === '' after the zombie resolve"
- "test exits 0 with no async-after-test activity (was leaking webtorrent/Blob errors)"
determinism_controls:
- "mediaUploader.uploadVideo replaced with a manually-resolved promise; captureVideoMetadata stubbed; jsdom DOM; no webtorrent, no network, no real Blob ops"
anti_cheat_rationale:
prevents:
- "over-mocking internal logic"
- "retry/sleep-based flake masking"
note: |
FLAKE_FIX + REFACTOR_TESTS, no assertion weakened. The subtest PASSED its asserts but
leaked async activity after the test ended, which node:test flags as a failure. Root
cause: it mocked modal.generateTorrentMetadata / resolveUploadIdentifier, but
handleVideoSelection drives the upload through this.mediaUploader.uploadVideo — a
DIFFERENT object with its OWN helpers — so the mediaUploader still ran real
createTorrentMetadata()/hashing on a synthetic {name:'video.mp4'} file ("invalid input
type" async), and captureVideoMetadata() ran URL.createObjectURL(file) ("must be an
instance of Blob"). Fixed by mocking at the correct DI boundary
(modal.mediaUploader.uploadVideo returns the controllable pending promise) and stubbing
captureVideoMetadata. The unit under test is the modal's videoUploadId zombie-guard, so
mocking the uploader boundary is correct isolation, not over-mocking (real upload/torrent
paths are covered by the mediaUploader / s3-upload suites).
mutation_verification:
- "Broke the guard (`if (this.videoUploadId !== currentUploadId) return` → `if (false)`) → the test FAILS ('State should remain idle after zombie completion', # fail 1). Reverted."
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
## 2026-07-05 — todo-11b: un-quarantine dm-block-filter (hermetic subscription; no force-exit)
```yaml
test_integrity_note:
change_type: ["flake_fix"]
scenarios:
- id: SCN-dm-block-filter-hermetic
given: "The 'integration with loaded user block list' test loads standard + legacy block lists and filters incoming DMs"
when: "manager.loadBlocks() runs against stubbed fetchListIncrementally, with the live block-list subscription stubbed out"
then: "Both blocked contacts (standard + legacy) are filtered (dmMessages === [ALLOWED]) AND the process opens NO real relay sockets, so node:test exits naturally with the correct exit code"
observable_outcomes:
- "manager.isBlocked(legacy) && manager.isBlocked(standard) === true; dmMessages length 1 (ALLOWED only)"
- "process.getActiveResourcesInfo() shows no TCPSocketWrap after loadBlocks; node exits 0 on pass, 1 on a forced failure (verified — failures NOT masked)"
determinism_controls:
- "stubbed nostrClient.fetchListIncrementally; truthy stub nostrClient.pool; no-op nostrClient.getSubscriptionManager; window.nostr.nip04.decrypt stub. All restored in finally. No network."
anti_cheat_rationale:
prevents:
- "retry/sleep-based flake masking"
- "over-mocking internal logic"
note: |
FLAKE_FIX, no assertion changed. The file PASSED all subtests then hung: loadBlocks()
opens a LIVE block-list subscription through nostrClient's real SimplePool aimed at the
test's fake relay URLs, leaking TCP sockets + reconnect timers that outlive the run so
node:test never exits (surfaced only once the runner was given a per-file timeout).
Diagnosed with process.getActiveResourcesInfo() — 4 TCPSocketWrap + reconnect Timeouts;
NOT the decrypt-retry timer (verified absent). Nulling the pool did not help because
loadBlocks calls ensurePool() (userBlocks.js:967) to re-create+connect a real pool when
pool is falsy. Fix keeps the test hermetic: a TRUTHY stub pool (so ensurePool is skipped)
+ a no-op getSubscriptionManager (so the subscription opens nothing). The block-merge
logic under test still runs fully through the stubbed fetchListIncrementally. A force
`process.exit` in an after() hook was explicitly REJECTED after verifying it masks real
failures (process.exitCode is not set when the hook's timer fires — a deliberately broken
assertion still exited 0 under that approach). Under this fix, the forced-failure control
correctly exits 1.
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
-28
View File
@@ -1,28 +0,0 @@
# Telemetry Report - 2026-02-24
**Total Unique Issues:** 1
**Generated:** 2026-02-24T13:31:15.378Z
## Top 10 Priority Issues
| Priority | Count | Issue | Owner | Sources |
| :--- | :---: | :--- | :--- | :--- |
| **Medium** | 1 | Load Test Bottleneck: Cryptographic Bottleneck (Avg Sign ... | P2P Team | load-test:load-report-20260204.json |
## Detailed Breakdown
### [Medium] Load Test Bottleneck: Cryptographic Bottleneck (Avg Sign Time 5.87ms > 5ms)
- **Occurrences:** 1
- **Sources:** load-test:load-report-20260204.json
- **Suggested Owner:** P2P Team
- **Fingerprint:** `00257328`
**Stack Trace / Details:**
```
Bottleneck: Cryptographic Bottleneck (Avg Sign Time 5.87ms > 5ms)
Remediation: Client-side event signing is slow. Review cryptographic library (nostr-tools) usage or client hardware capabilities.
```
---
*Privacy Notice: PII (IPs, emails, keys) has been sanitized from this report.*
-72
View File
@@ -1,72 +0,0 @@
# Weekly Agent Synthesis — 2026-02-25 (covers 2026-02-18 → 2026-02-25)
## Summary
- Total PRs opened: 8 (currently open)
- Total PRs merged: 48 (in the last 7 days)
- Notable themes:
- **Testing & CI Stability:** Significant effort on stabilizing flaky E2E tests (#2683, #2669), improving coverage (#2707, #2667), and adding comprehensive E2E suites (#2666).
- **Performance:** Optimizations in feed moderation (#2665) and mute pruning (#2653).
- **Refactoring:** Cleaning up `app.js` (#2681) and test harnesses (#2688).
- **Routine Maintenance:** High volume of daily agent executions (todo-triage, style, audit).
## PRs Opened / Updated (Selected)
- **test(smoke): add smoke-test.mjs harness** — Open — Status: Open
- Notes: New harness for smoke testing.
- Risk: None (test infra).
- **docs(agents): sync scheduler rosters** — Open — Status: Open
- Notes: Routine scheduler updates.
- Risk: None.
- **Add unit tests for js/nostr/utils.js (test-coverage-agent)** — #2707 — Status: Merged
- Notes: Improved coverage for core utilities.
- Risk: None.
- **[ci] Stabilize flaky E2E tests & skip deploy-index verification** — #2683 — Status: Merged
- Notes: Addressed CI flakes.
- Risk: None (test infra).
- **refactor(ui): extract bindThumbnailFallbacks from app.js** — #2681 — Status: Merged
- Notes: Extracted logic from the main `bitvidApp` class.
- Risk: **Requires UI regression check** (thumbnails).
- **perf(feed): optimize moderation stage with batch author lookups** — #2665 — Status: Merged
- Notes: Changed how authors are looked up during feed rendering.
- Risk: **Requires protocol review** (feed correctness).
- **fix: race condition in app initialization** — #2674 — Status: Merged
- Notes: Fixed startup race.
- Risk: **Requires protocol review** (startup timing).
- **Replace alert() with non-blocking notifications in UploadModal** — #2652 — Status: Merged
- Notes: UX improvement.
- Risk: None.
## Issues Created / Updated
*(Derived from PR descriptions and titles, as direct issue fetching via git is limited)*
- **Stabilize flaky E2E tests** (referenced in #2683) — Priority: P1
- **Race condition in app initialization** (referenced in #2674) — Priority: P1
## Tests / Quality
- **Tests added/updated:**
- Unit tests for `js/nostr/utils.js` (#2707).
- Comprehensive E2E test suite for accessibility/playback (#2666).
- Refactored smoke test harness (#2688).
- **Coverage:**
- Coverage improved for `js/nostr/utils.js` and E2E scenarios.
## Security / Dependencies
- **Daily Agent: Audit Agent** (#2702) — #2702
- Routine audit run. No critical vulnerabilities flagged in summary.
- **daily: nip-research-agent NIP-42 update** — #2661
- Rationale: Touches authentication protocols.
- Risk: **requires-protocol-review**.
## Requires Human Review (Do Not Auto-Merge)
- **#2661 (NIP-42 update)** — Touches authentication logic — [PR #2661](https://github.com/PR0M3TH3AN/bitvid/pull/2661)
- **#2665 (Batch author lookups)** — Complex logic change in feed rendering — [PR #2665](https://github.com/PR0M3TH3AN/bitvid/pull/2665)
- **#2681 (Refactor app.js)** — Core UI refactor — [PR #2681](https://github.com/PR0M3TH3AN/bitvid/pull/2681)
## Top 5 Recommended Human Actions (ranked)
1. **Verify NIP-42/Auth Changes (#2661, #2669):** Ensure authentication flows remain secure and functional.
2. **Review Feed Optimization (#2665):** Verify that batch author lookups correctly handle all moderation cases.
3. **Validate App.js Refactor (#2681):** Check thumbnail loading across the app to ensure no regressions.
4. **Confirm CI Stability (#2683, #2703):** Monitor CI for recurrence of flakes; verify tests weren't simply disabled/loosened.
5. **Review New E2E Suite (#2666):** Ensure new tests align with "Scenario-First" guidelines.
## Method & Limitations
- Data sources used: GitHub API (`curl`), `git log`.
- Links are derived from API output.
@@ -1,224 +0,0 @@
{
"grandfathered_files": 43,
"grandfathered_excess_lines": 45216,
"new_oversized_files": 0,
"new_excess_lines": 0,
"grandfathered_list": [
{
"path": "js/adminListStore.js",
"lines": 1033,
"excess": 33
},
{
"path": "js/app/authSessionCoordinator.js",
"lines": 1743,
"excess": 743
},
{
"path": "js/app/feedCoordinator.js",
"lines": 1767,
"excess": 767
},
{
"path": "js/app/playbackCoordinator.js",
"lines": 1217,
"excess": 217
},
{
"path": "js/app.js",
"lines": 5133,
"excess": 4133
},
{
"path": "js/channelProfile.js",
"lines": 5579,
"excess": 4579
},
{
"path": "js/feedEngine/stages.js",
"lines": 1350,
"excess": 350
},
{
"path": "js/historyView.js",
"lines": 2547,
"excess": 1547
},
{
"path": "js/index.js",
"lines": 1409,
"excess": 409
},
{
"path": "js/nostr/client.js",
"lines": 4001,
"excess": 3001
},
{
"path": "js/nostr/managers/SignerManager.js",
"lines": 1107,
"excess": 107
},
{
"path": "js/nostr/nip46Client.js",
"lines": 2104,
"excess": 1104
},
{
"path": "js/nostr/nip46Connector.js",
"lines": 1094,
"excess": 94
},
{
"path": "js/nostr/nip71.js",
"lines": 1600,
"excess": 600
},
{
"path": "js/nostr/publishHelpers.js",
"lines": 1415,
"excess": 415
},
{
"path": "js/nostr/watchHistory.js",
"lines": 2211,
"excess": 1211
},
{
"path": "js/nostrEventSchemas.js",
"lines": 3192,
"excess": 2192
},
{
"path": "js/payments/nwcClient.js",
"lines": 1642,
"excess": 642
},
{
"path": "js/services/authService.js",
"lines": 1321,
"excess": 321
},
{
"path": "js/services/commentThreadService.js",
"lines": 1122,
"excess": 122
},
{
"path": "js/services/hashtagPreferencesService.js",
"lines": 1435,
"excess": 435
},
{
"path": "js/services/moderationService.js",
"lines": 2557,
"excess": 1557
},
{
"path": "js/services/nostrService.js",
"lines": 2227,
"excess": 1227
},
{
"path": "js/services/playbackService.js",
"lines": 1316,
"excess": 316
},
{
"path": "js/services/r2Service.js",
"lines": 1194,
"excess": 194
},
{
"path": "js/state/cache.js",
"lines": 1829,
"excess": 829
},
{
"path": "js/subscriptions.js",
"lines": 2410,
"excess": 1410
},
{
"path": "js/ui/applicationBootstrap.js",
"lines": 1169,
"excess": 169
},
{
"path": "js/ui/components/EditModal.js",
"lines": 1130,
"excess": 130
},
{
"path": "js/ui/components/RevertModal.js",
"lines": 1509,
"excess": 509
},
{
"path": "js/ui/components/SimilarContentCard.js",
"lines": 1303,
"excess": 303
},
{
"path": "js/ui/components/UploadModal.js",
"lines": 1320,
"excess": 320
},
{
"path": "js/ui/components/VideoCard.js",
"lines": 3130,
"excess": 2130
},
{
"path": "js/ui/components/VideoModal.js",
"lines": 4976,
"excess": 3976
},
{
"path": "js/ui/loginModalController.js",
"lines": 2574,
"excess": 1574
},
{
"path": "js/ui/moreMenuController.js",
"lines": 1601,
"excess": 601
},
{
"path": "js/ui/overlay/popoverEngine.js",
"lines": 1070,
"excess": 70
},
{
"path": "js/ui/profileModalController.js",
"lines": 5541,
"excess": 4541
},
{
"path": "js/ui/views/VideoListView.js",
"lines": 1392,
"excess": 392
},
{
"path": "js/ui/zapController.js",
"lines": 1206,
"excess": 206
},
{
"path": "js/userBlocks.js",
"lines": 2302,
"excess": 1302
},
{
"path": "js/watchHistoryService.js",
"lines": 1400,
"excess": 400
},
{
"path": "js/webtorrent.js",
"lines": 1038,
"excess": 38
}
],
"new_list": []
}
@@ -1,147 +0,0 @@
{
"total_assignments": 37,
"files_count": 28,
"top_offenders": [
{
"path": "js/ui/dm/DMSettingsModalController.js",
"count": 3,
"is_new": false
},
{
"path": "js/ui/loginModalController.js",
"count": 3,
"is_new": false
},
{
"path": "js/ui/views/VideoListView.js",
"count": 3,
"is_new": false
},
{
"path": "js/viewManager.js",
"count": 3,
"is_new": false
},
{
"path": "js/utils/qrcode.js",
"count": 2,
"is_new": false
},
{
"path": "js/app/feedCoordinator.js",
"count": 1,
"is_new": false
},
{
"path": "js/app.js",
"count": 1,
"is_new": false
},
{
"path": "js/docsView.js",
"count": 1,
"is_new": false
},
{
"path": "js/exploreView.js",
"count": 1,
"is_new": false
},
{
"path": "js/forYouView.js",
"count": 1,
"is_new": false
},
{
"path": "js/index.js",
"count": 1,
"is_new": false
},
{
"path": "js/kidsView.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/components/EditModal.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/components/EmbedVideoModal.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/components/EventDetailsModal.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/components/UploadModal.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/components/VideoModal.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/components/nip71FormManager.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/dm/AppShell.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/dm/Composer.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/dm/ConversationList.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/dm/DMRelaySettings.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/dm/MessageThread.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/dm/NotificationCenter.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/moreMenuController.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/profileModalController.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/subscriptionHistoryController.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/videoListViewController.js",
"count": 1,
"is_new": false
}
],
"violations": []
}
@@ -1,11 +0,0 @@
{
"total_failures": 0,
"files_with_errors": [],
"skipped_checks": [
{
"check": "lint:assets",
"reason": "Missing dist/asset-manifest.json.",
"details": "asset reference check (build required)."
}
]
}
@@ -1,47 +0,0 @@
43 grandfathered oversized file(s):
⚠ grandfathered: js/adminListStore.js (1033 lines)
⚠ grandfathered: js/app/authSessionCoordinator.js (1743 lines)
⚠ grandfathered: js/app/feedCoordinator.js (1767 lines)
⚠ grandfathered: js/app/playbackCoordinator.js (1217 lines)
⚠ grandfathered: js/app.js (5133 lines)
⚠ grandfathered: js/channelProfile.js (5579 lines)
⚠ grandfathered: js/feedEngine/stages.js (1350 lines)
⚠ grandfathered: js/historyView.js (2547 lines)
⚠ grandfathered: js/index.js (1409 lines)
⚠ grandfathered: js/nostr/client.js (4001 lines)
⚠ grandfathered: js/nostr/managers/SignerManager.js (1107 lines)
⚠ grandfathered: js/nostr/nip46Client.js (2104 lines)
⚠ grandfathered: js/nostr/nip46Connector.js (1094 lines)
⚠ grandfathered: js/nostr/nip71.js (1600 lines)
⚠ grandfathered: js/nostr/publishHelpers.js (1415 lines)
⚠ grandfathered: js/nostr/watchHistory.js (2211 lines)
⚠ grandfathered: js/nostrEventSchemas.js (3192 lines)
⚠ grandfathered: js/payments/nwcClient.js (1642 lines)
⚠ grandfathered: js/services/authService.js (1321 lines)
⚠ grandfathered: js/services/commentThreadService.js (1122 lines)
⚠ grandfathered: js/services/hashtagPreferencesService.js (1435 lines)
⚠ grandfathered: js/services/moderationService.js (2557 lines)
⚠ grandfathered: js/services/nostrService.js (2227 lines)
⚠ grandfathered: js/services/playbackService.js (1316 lines)
⚠ grandfathered: js/services/r2Service.js (1194 lines)
⚠ grandfathered: js/state/cache.js (1829 lines)
⚠ grandfathered: js/subscriptions.js (2410 lines)
⚠ grandfathered: js/ui/applicationBootstrap.js (1169 lines)
⚠ grandfathered: js/ui/components/EditModal.js (1130 lines)
⚠ grandfathered: js/ui/components/RevertModal.js (1509 lines)
⚠ grandfathered: js/ui/components/SimilarContentCard.js (1303 lines)
⚠ grandfathered: js/ui/components/UploadModal.js (1320 lines)
⚠ grandfathered: js/ui/components/VideoCard.js (3130 lines)
⚠ grandfathered: js/ui/components/VideoModal.js (4976 lines)
⚠ grandfathered: js/ui/loginModalController.js (2574 lines)
⚠ grandfathered: js/ui/moreMenuController.js (1601 lines)
⚠ grandfathered: js/ui/overlay/popoverEngine.js (1070 lines)
⚠ grandfathered: js/ui/profileModalController.js (5541 lines)
⚠ grandfathered: js/ui/views/VideoListView.js (1392 lines)
⚠ grandfathered: js/ui/zapController.js (1206 lines)
⚠ grandfathered: js/userBlocks.js (2302 lines)
⚠ grandfathered: js/watchHistoryService.js (1400 lines)
⚠ grandfathered: js/webtorrent.js (1038 lines)
No violations. 43 grandfathered file(s) remain as decomposition targets.
-48
View File
@@ -1,48 +0,0 @@
> [email protected] lint
> npm run lint:css && npm run lint:hex && npm run lint:inline-styles && npm run lint:tokens && npm run lint:tailwind-brackets && npm run lint:tailwind-colors && npm run lint:file-size && npm run lint:innerhtml && npm run lint:assets && npm run lint:sw-compat
> [email protected] lint:css
> stylelint css/tailwind.source.css css/tokens.css css/docs.css
> [email protected] lint:hex
> node scripts/check-hex.js
> [email protected] lint:inline-styles
> node scripts/check-inline-styles.mjs
No inline style usage found.
> [email protected] lint:tokens
> node scripts/check-design-tokens.mjs --check=tokens
> [email protected] lint:tailwind-brackets
> node scripts/check-design-tokens.mjs --check=brackets
> [email protected] lint:tailwind-colors
> node scripts/check-tailwind-colors.mjs
> [email protected] lint:file-size
> node scripts/check-file-size.mjs
> [email protected] lint:innerhtml
> node scripts/check-innerhtml.mjs
> [email protected] lint:assets
> node scripts/check-asset-references.mjs
[lint:assets] Missing dist/asset-manifest.json. Skipping asset reference check (build required).
> [email protected] lint:sw-compat
> node scripts/check-sw-compat.mjs
[sw-compat] Skipping check: unable to determine HEAD^ or git history unavailable.
[sw-compat] No commit delta found; skipping compatibility check.
-47
View File
@@ -1,47 +0,0 @@
# Audit Report — 2026-02-21 (unstable)
**Summary**
* **Date:** 2026-02-21 15:15 UTC
* **Node:** v22.22.0 / **OS:** Linux
* **Status:** ✅ Clean. Significant improvements in technical debt metrics.
**Metrics**
* **Grandfathered oversized files:** 43 files
* Total excess lines: **45,216** (was 49,463)
* Delta: **-4,247 lines** (Improvement)
* **New oversized files:** 0 files (total excess lines: 0)
* **Total innerHTML assignments:** **37** (was 87)
* Delta: **-50 assignments** (Improvement)
* Files with innerHTML: 28 (was 34)
**Top offenders (innerHTML):**
1. `js/ui/dm/DMSettingsModalController.js` — 3
2. `js/ui/loginModalController.js` — 3
3. `js/ui/views/VideoListView.js` — 3
4. `js/viewManager.js` — 3
5. `js/utils/qrcode.js` — 2
* **Lint failures:** 0 (files: 0)
**Delta vs previous (2026-02-12)**
* **Grandfathered:** 0 files, **-4,247** excess lines
* **InnerHTML:** **-50** total assignments, **-6** files
* **Lint:** 0 failures
**High-priority items**
* No new regressions.
* Continue targeting the remaining 43 grandfathered files for decomposition.
* Continue migrating the remaining 37 `innerHTML` assignments to safe DOM APIs.
**Artifacts**
* `file-size-report.json`
* `innerhtml-report.json`
* `lint-report.json`
* `raw-check-file-size.log`
* `raw-check-innerhtml.log`
* `raw-lint.log`
@@ -1,224 +0,0 @@
{
"grandfathered_files": 43,
"grandfathered_excess_lines": 45288,
"new_oversized_files": 0,
"new_excess_lines": 0,
"grandfathered_list": [
{
"path": "js/adminListStore.js",
"lines": 1078,
"excess": 78
},
{
"path": "js/app/authSessionCoordinator.js",
"lines": 1770,
"excess": 770
},
{
"path": "js/app/feedCoordinator.js",
"lines": 1767,
"excess": 767
},
{
"path": "js/app/playbackCoordinator.js",
"lines": 1217,
"excess": 217
},
{
"path": "js/app.js",
"lines": 5077,
"excess": 4077
},
{
"path": "js/channelProfile.js",
"lines": 5579,
"excess": 4579
},
{
"path": "js/feedEngine/stages.js",
"lines": 1388,
"excess": 388
},
{
"path": "js/historyView.js",
"lines": 2613,
"excess": 1613
},
{
"path": "js/index.js",
"lines": 1409,
"excess": 409
},
{
"path": "js/nostr/client.js",
"lines": 4004,
"excess": 3004
},
{
"path": "js/nostr/managers/SignerManager.js",
"lines": 1135,
"excess": 135
},
{
"path": "js/nostr/nip46Client.js",
"lines": 2104,
"excess": 1104
},
{
"path": "js/nostr/nip46Connector.js",
"lines": 1094,
"excess": 94
},
{
"path": "js/nostr/nip71.js",
"lines": 1600,
"excess": 600
},
{
"path": "js/nostr/publishHelpers.js",
"lines": 1415,
"excess": 415
},
{
"path": "js/nostr/watchHistory.js",
"lines": 2335,
"excess": 1335
},
{
"path": "js/nostrEventSchemas.js",
"lines": 3192,
"excess": 2192
},
{
"path": "js/payments/nwcClient.js",
"lines": 1642,
"excess": 642
},
{
"path": "js/services/authService.js",
"lines": 1343,
"excess": 343
},
{
"path": "js/services/commentThreadService.js",
"lines": 1122,
"excess": 122
},
{
"path": "js/services/hashtagPreferencesService.js",
"lines": 1451,
"excess": 451
},
{
"path": "js/services/moderationService.js",
"lines": 2140,
"excess": 1140
},
{
"path": "js/services/nostrService.js",
"lines": 2227,
"excess": 1227
},
{
"path": "js/services/playbackService.js",
"lines": 1316,
"excess": 316
},
{
"path": "js/services/r2Service.js",
"lines": 1194,
"excess": 194
},
{
"path": "js/state/cache.js",
"lines": 1829,
"excess": 829
},
{
"path": "js/subscriptions.js",
"lines": 2437,
"excess": 1437
},
{
"path": "js/ui/applicationBootstrap.js",
"lines": 1169,
"excess": 169
},
{
"path": "js/ui/components/EditModal.js",
"lines": 1130,
"excess": 130
},
{
"path": "js/ui/components/RevertModal.js",
"lines": 1509,
"excess": 509
},
{
"path": "js/ui/components/SimilarContentCard.js",
"lines": 1303,
"excess": 303
},
{
"path": "js/ui/components/UploadModal.js",
"lines": 1326,
"excess": 326
},
{
"path": "js/ui/components/VideoCard.js",
"lines": 3141,
"excess": 2141
},
{
"path": "js/ui/components/VideoModal.js",
"lines": 4976,
"excess": 3976
},
{
"path": "js/ui/loginModalController.js",
"lines": 2574,
"excess": 1574
},
{
"path": "js/ui/moreMenuController.js",
"lines": 1601,
"excess": 601
},
{
"path": "js/ui/overlay/popoverEngine.js",
"lines": 1070,
"excess": 70
},
{
"path": "js/ui/profileModalController.js",
"lines": 5572,
"excess": 4572
},
{
"path": "js/ui/views/VideoListView.js",
"lines": 1392,
"excess": 392
},
{
"path": "js/ui/zapController.js",
"lines": 1206,
"excess": 206
},
{
"path": "js/userBlocks.js",
"lines": 2403,
"excess": 1403
},
{
"path": "js/watchHistoryService.js",
"lines": 1400,
"excess": 400
},
{
"path": "js/webtorrent.js",
"lines": 1038,
"excess": 38
}
],
"new_list": []
}
@@ -1,147 +0,0 @@
{
"total_assignments": 37,
"files_count": 28,
"top_offenders": [
{
"path": "js/ui/dm/DMSettingsModalController.js",
"count": 3,
"is_new": false
},
{
"path": "js/ui/loginModalController.js",
"count": 3,
"is_new": false
},
{
"path": "js/ui/views/VideoListView.js",
"count": 3,
"is_new": false
},
{
"path": "js/viewManager.js",
"count": 3,
"is_new": false
},
{
"path": "js/utils/qrcode.js",
"count": 2,
"is_new": false
},
{
"path": "js/app/feedCoordinator.js",
"count": 1,
"is_new": false
},
{
"path": "js/app.js",
"count": 1,
"is_new": false
},
{
"path": "js/docsView.js",
"count": 1,
"is_new": false
},
{
"path": "js/exploreView.js",
"count": 1,
"is_new": false
},
{
"path": "js/forYouView.js",
"count": 1,
"is_new": false
},
{
"path": "js/index.js",
"count": 1,
"is_new": false
},
{
"path": "js/kidsView.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/components/EditModal.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/components/EmbedVideoModal.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/components/EventDetailsModal.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/components/UploadModal.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/components/VideoModal.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/components/nip71FormManager.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/dm/AppShell.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/dm/Composer.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/dm/ConversationList.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/dm/DMRelaySettings.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/dm/MessageThread.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/dm/NotificationCenter.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/moreMenuController.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/profileModalController.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/subscriptionHistoryController.js",
"count": 1,
"is_new": false
},
{
"path": "js/ui/videoListViewController.js",
"count": 1,
"is_new": false
}
],
"violations": []
}
@@ -1,11 +0,0 @@
{
"total_failures": 0,
"files_with_errors": [],
"skipped_checks": [
{
"check": "lint:assets",
"reason": "Missing dist/asset-manifest.json.",
"details": "asset reference check (build required)."
}
]
}
@@ -1,47 +0,0 @@
43 grandfathered oversized file(s):
⚠ grandfathered: js/adminListStore.js (1078 lines)
⚠ grandfathered: js/app/authSessionCoordinator.js (1770 lines)
⚠ grandfathered: js/app/feedCoordinator.js (1767 lines)
⚠ grandfathered: js/app/playbackCoordinator.js (1217 lines)
⚠ grandfathered: js/app.js (5077 lines)
⚠ grandfathered: js/channelProfile.js (5579 lines)
⚠ grandfathered: js/feedEngine/stages.js (1388 lines)
⚠ grandfathered: js/historyView.js (2613 lines)
⚠ grandfathered: js/index.js (1409 lines)
⚠ grandfathered: js/nostr/client.js (4004 lines)
⚠ grandfathered: js/nostr/managers/SignerManager.js (1135 lines)
⚠ grandfathered: js/nostr/nip46Client.js (2104 lines)
⚠ grandfathered: js/nostr/nip46Connector.js (1094 lines)
⚠ grandfathered: js/nostr/nip71.js (1600 lines)
⚠ grandfathered: js/nostr/publishHelpers.js (1415 lines)
⚠ grandfathered: js/nostr/watchHistory.js (2335 lines)
⚠ grandfathered: js/nostrEventSchemas.js (3192 lines)
⚠ grandfathered: js/payments/nwcClient.js (1642 lines)
⚠ grandfathered: js/services/authService.js (1343 lines)
⚠ grandfathered: js/services/commentThreadService.js (1122 lines)
⚠ grandfathered: js/services/hashtagPreferencesService.js (1451 lines)
⚠ grandfathered: js/services/moderationService.js (2140 lines)
⚠ grandfathered: js/services/nostrService.js (2227 lines)
⚠ grandfathered: js/services/playbackService.js (1316 lines)
⚠ grandfathered: js/services/r2Service.js (1194 lines)
⚠ grandfathered: js/state/cache.js (1829 lines)
⚠ grandfathered: js/subscriptions.js (2437 lines)
⚠ grandfathered: js/ui/applicationBootstrap.js (1169 lines)
⚠ grandfathered: js/ui/components/EditModal.js (1130 lines)
⚠ grandfathered: js/ui/components/RevertModal.js (1509 lines)
⚠ grandfathered: js/ui/components/SimilarContentCard.js (1303 lines)
⚠ grandfathered: js/ui/components/UploadModal.js (1326 lines)
⚠ grandfathered: js/ui/components/VideoCard.js (3141 lines)
⚠ grandfathered: js/ui/components/VideoModal.js (4976 lines)
⚠ grandfathered: js/ui/loginModalController.js (2574 lines)
⚠ grandfathered: js/ui/moreMenuController.js (1601 lines)
⚠ grandfathered: js/ui/overlay/popoverEngine.js (1070 lines)
⚠ grandfathered: js/ui/profileModalController.js (5572 lines)
⚠ grandfathered: js/ui/views/VideoListView.js (1392 lines)
⚠ grandfathered: js/ui/zapController.js (1206 lines)
⚠ grandfathered: js/userBlocks.js (2403 lines)
⚠ grandfathered: js/watchHistoryService.js (1400 lines)
⚠ grandfathered: js/webtorrent.js (1038 lines)
No violations. 43 grandfathered file(s) remain as decomposition targets.
@@ -1,30 +0,0 @@
innerHTML usage: 37 assignments across 28 files
js/ui/dm/DMSettingsModalController.js: 3
js/ui/loginModalController.js: 3
js/ui/views/VideoListView.js: 3
js/viewManager.js: 3
js/utils/qrcode.js: 2
js/app/feedCoordinator.js: 1
js/app.js: 1
js/docsView.js: 1
js/exploreView.js: 1
js/forYouView.js: 1
js/index.js: 1
js/kidsView.js: 1
js/ui/components/EditModal.js: 1
js/ui/components/EmbedVideoModal.js: 1
js/ui/components/EventDetailsModal.js: 1
js/ui/components/UploadModal.js: 1
js/ui/components/VideoModal.js: 1
js/ui/components/nip71FormManager.js: 1
js/ui/dm/AppShell.js: 1
js/ui/dm/Composer.js: 1
js/ui/dm/ConversationList.js: 1
js/ui/dm/DMRelaySettings.js: 1
js/ui/dm/MessageThread.js: 1
js/ui/dm/NotificationCenter.js: 1
js/ui/moreMenuController.js: 1
js/ui/profileModalController.js: 1
js/ui/subscriptionHistoryController.js: 1
js/ui/videoListViewController.js: 1
-48
View File
@@ -1,48 +0,0 @@
> [email protected] lint
> npm run lint:css && npm run lint:hex && npm run lint:inline-styles && npm run lint:tokens && npm run lint:tailwind-brackets && npm run lint:tailwind-colors && npm run lint:file-size && npm run lint:innerhtml && npm run lint:assets && npm run lint:sw-compat
> [email protected] lint:css
> stylelint css/tailwind.source.css css/tokens.css css/docs.css
> [email protected] lint:hex
> node scripts/check-hex.js
> [email protected] lint:inline-styles
> node scripts/check-inline-styles.mjs
No inline style usage found.
> [email protected] lint:tokens
> node scripts/check-design-tokens.mjs --check=tokens
> [email protected] lint:tailwind-brackets
> node scripts/check-design-tokens.mjs --check=brackets
> [email protected] lint:tailwind-colors
> node scripts/check-tailwind-colors.mjs
> [email protected] lint:file-size
> node scripts/check-file-size.mjs
> [email protected] lint:innerhtml
> node scripts/check-innerhtml.mjs
> [email protected] lint:assets
> node scripts/check-asset-references.mjs
[lint:assets] Missing dist/asset-manifest.json. Skipping asset reference check (build required).
> [email protected] lint:sw-compat
> node scripts/check-sw-compat.mjs
[sw-compat] Skipping check: unable to determine HEAD^ or git history unavailable.
[sw-compat] No commit delta found; skipping compatibility check.
-20
View File
@@ -1,20 +0,0 @@
# CI Health Report - 2026-02-21 (Evening Run)
## Summary
- **Agent:** ci-health-agent
- **Command:** `npm run test:unit`, `npm run test:dm:unit`, `npm run test:dm:integration`
- **Status:** PASS (with one timeout)
- **Flakes Detected:** 0 (Reliable reproduction failed)
## Details
Executed the unit test suite 2 times locally:
- **Run 1:** Passed successfully.
- **Run 2:** Timed out after 400s. The log ended during `tests/nostr/nip46Connector.test.mjs`.
Targeted execution of `tests/nostr/nip46Connector.test.mjs` (10 times) passed reliably and quickly (~30ms per run). The timeout in Run 2 is attributed to environmental load (running multiple suites in parallel or resource contention) rather than a code flake.
DM tests (`test:dm:unit`, `test:dm:integration`) passed successfully.
## Recommendations
- Monitor `tests/nostr/nip46Connector.test.mjs` in CI for potential hangs, though none were reproduced in isolation.
- `tests/visual/overlay-layers.spec.ts` contains potential timing issues (`waitForTimeout`) but was not modified as visual tests cannot be verified in the current environment.
+23 -28
View File
@@ -1,33 +1,28 @@
# Dependency Report - 2026-02-20
# Dependency Audit Report
## Vulnerabilities
### HIGH (3)
- **minimatch**: minimatch has a ReDoS via repeated wildcards with non-matching literal in pattern
- Path: Direct dependency? No
- Fix Available: true
- **serve**: ajv, serve-handler
- Path: Direct dependency? Yes
- Fix Available: {"name":"serve","version":"6.5.8","isSemVerMajor":true}
- **serve-handler**: minimatch
- Path: Direct dependency? No
- Fix Available: true
### MODERATE (1)
- **ajv**: ajv has ReDoS when using `$data` option
## 1. Vulnerability Summary
- Total Vulnerabilities: 0
- Critical: 0
- High: 0
- Moderate: 0
- Low: 0
## Outdated Packages
### Major Updates (Risky)
- **pixelmatch** (undefined): 5.3.0 -> 7.1.0
- **postcss-import** (undefined): 15.1.0 -> 16.1.1
- **prettier-plugin-tailwindcss** (undefined): 0.6.14 -> 0.7.2
- **stylelint-config-standard** (undefined): 36.0.1 -> 40.0.0
- **tailwindcss** (undefined): 3.4.19 -> 4.2.0
No vulnerabilities found.
### Minor Updates (Safe-ish)
- **nostr-tools** (undefined): 2.19.4 -> 2.23.1 (Latest: 2.23.1)
- **stylelint** (undefined): 16.12.0 -> 16.26.1 (Latest: 17.3.0)
## 2. Outdated Packages
### Safe Upgrades (Patch/Minor)
- **jsdom** (dependencies): 28.0.0 -> 28.1.0 (Latest: 28.1.0)
- **stylelint** (dependencies): 16.12.0 -> 16.26.1 (Latest: 17.3.0)
### Patch Updates (Safe)
### Major Upgrades (Risky)
- **pixelmatch** (dependencies): 5.3.0 -> 7.1.0
- **postcss-import** (dependencies): 15.1.0 -> 16.1.1
- **prettier-plugin-tailwindcss** (dependencies): 0.6.14 -> 0.7.2
- **stylelint-config-standard** (dependencies): 36.0.1 -> 40.0.0
- **tailwindcss** (dependencies): 3.4.19 -> 4.1.18
## Triage & Actions
- [ ] **HIGH**: Check for safe upgrades for minimatch, serve, serve-handler.
- [ ] **Safe Candidates**: nostr-tools, stylelint
### Security/Protocol Libraries (Manual Review Required)
- **nostr-tools** (dependencies): 2.19.4 -> 2.23.1 (Latest: 2.23.1)
## 3. Recommendations
- **Action Item**: Attempt safe upgrade of `jsdom` from `28.0.0` to `28.1.0`.
+6 -106
View File
@@ -1,122 +1,22 @@
{
"auditReportVersion": 2,
"vulnerabilities": {
"ajv": {
"name": "ajv",
"severity": "moderate",
"isDirect": false,
"via": [
{
"source": 1113214,
"name": "ajv",
"dependency": "ajv",
"title": "ajv has ReDoS when using `$data` option",
"url": "https://github.com/advisories/GHSA-2g4f-4pwh-qvx6",
"severity": "moderate",
"cwe": [
"CWE-400"
],
"cvss": {
"score": 0,
"vectorString": null
},
"range": "<8.18.0"
}
],
"effects": [
"serve"
],
"range": "<8.18.0",
"nodes": [
"node_modules/ajv"
],
"fixAvailable": {
"name": "serve",
"version": "6.5.8",
"isSemVerMajor": true
}
},
"minimatch": {
"name": "minimatch",
"severity": "high",
"isDirect": false,
"via": [
{
"source": 1113296,
"name": "minimatch",
"dependency": "minimatch",
"title": "minimatch has a ReDoS via repeated wildcards with non-matching literal in pattern",
"url": "https://github.com/advisories/GHSA-3ppc-4f35-3m26",
"severity": "high",
"cwe": [
"CWE-1333"
],
"cvss": {
"score": 0,
"vectorString": null
},
"range": "<10.2.1"
}
],
"effects": [
"serve-handler"
],
"range": "<10.2.1",
"nodes": [
"node_modules/minimatch"
],
"fixAvailable": true
},
"serve": {
"name": "serve",
"severity": "high",
"isDirect": true,
"via": [
"ajv",
"serve-handler"
],
"effects": [],
"range": ">=7.0.0",
"nodes": [
"node_modules/serve"
],
"fixAvailable": {
"name": "serve",
"version": "6.5.8",
"isSemVerMajor": true
}
},
"serve-handler": {
"name": "serve-handler",
"severity": "high",
"isDirect": false,
"via": [
"minimatch"
],
"effects": [],
"range": ">=1.1.0",
"nodes": [
"node_modules/serve-handler"
],
"fixAvailable": true
}
},
"vulnerabilities": {},
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 1,
"high": 3,
"moderate": 0,
"high": 0,
"critical": 0,
"total": 4
"total": 0
},
"dependencies": {
"prod": 111,
"dev": 340,
"dev": 337,
"optional": 53,
"peer": 0,
"peerOptional": 0,
"total": 477
"total": 474
}
}
}
+8 -1
View File
@@ -1,4 +1,11 @@
{
"jsdom": {
"current": "28.0.0",
"wanted": "28.1.0",
"latest": "28.1.0",
"dependent": "app",
"location": "/app/node_modules/jsdom"
},
"nostr-tools": {
"current": "2.19.4",
"wanted": "2.23.1",
@@ -44,7 +51,7 @@
"tailwindcss": {
"current": "3.4.19",
"wanted": "3.4.19",
"latest": "4.2.0",
"latest": "4.1.18",
"dependent": "app",
"location": "/app/node_modules/tailwindcss"
}
+1
View File
@@ -0,0 +1 @@
js/ui/profileModalContract.js:146: "Formats npub strings for display using the canonical short representation (npubXXXX…XXXX).",
-121
View File
@@ -1,121 +0,0 @@
// Mock Globals BEFORE imports
global.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {}
};
global.window = { localStorage: global.localStorage };
// Dynamic imports to ensure globals are set first
const { ModerationService } = await import('../js/services/moderationService.js');
const { createModerationStage } = await import('../js/feedEngine/stages.js');
const { performance } = await import('node:perf_hooks');
// Mock Dependencies
const mockClient = {
pool: {},
ensurePool: async () => {},
pubkey: 'viewer-pubkey',
};
const mockLogger = {
log: () => {},
dev: { log: () => {}, debug: () => {} },
user: { warn: () => {}, info: () => {} }
};
const service = new ModerationService({
nostrClient: mockClient,
logger: mockLogger,
});
// Helper to generate valid hex
function makeHex(prefix, index) {
return `${prefix}${index.toString(16).padStart(64 - prefix.length, '0')}`;
}
// Setup Data
const AUTHOR_COUNT = 500;
const MUTERS_PER_AUTHOR = 100;
const now = Date.now() / 1000;
console.log(`Populating ${AUTHOR_COUNT} authors with ${MUTERS_PER_AUTHOR} muters each...`);
for (let i = 0; i < AUTHOR_COUNT; i++) {
const author = makeHex('abc', i);
const muters = new Map();
const categories = new Map();
for (let j = 0; j < MUTERS_PER_AUTHOR; j++) {
const muter = makeHex('def', j);
muters.set(muter, now); // Not expired
}
// Add some categories
const catMuters = new Map();
const firstMuter = makeHex('def', 0);
catMuters.set(firstMuter, now);
categories.set('spam', catMuters);
// We manually inject into internal map to bypass setter logic
service.trustedMutedAuthors.set(author, {
muters,
categories,
count: muters.size,
lastPrunedAt: 0 // Force prune check
});
}
// Prepare items for stage
const ITEM_COUNT = 5000;
const items = [];
for (let i = 0; i < ITEM_COUNT; i++) {
const authorIndex = i % AUTHOR_COUNT;
const author = makeHex('abc', authorIndex);
items.push({
video: {
id: makeHex('123', i),
pubkey: author,
kind: 30078,
moderation: {}
},
metadata: {}
});
}
const context = {
runtime: {
moderationThresholds: {
autoplayBlockThreshold: 1,
blurThreshold: 1,
trustedSpamHideThreshold: 1,
trustedMuteHideThresholds: {}
},
isAuthorBlocked: () => false
},
log: () => {},
addWhy: () => {}
};
async function runBenchmark() {
console.log('Running Moderation Stage Benchmark (Sequential Lookups)...');
const moderationStage = createModerationStage({
service: service,
stageName: 'bench-moderation-sequential'
});
// Warmup
await moderationStage(items.slice(0, 100), context);
// Measure
const start = performance.now();
await moderationStage(items, context);
const end = performance.now();
console.log(`Time: ${(end - start).toFixed(2)}ms`);
}
runBenchmark().catch(console.error);
-74
View File
@@ -1,74 +0,0 @@
import { ModerationService } from '../js/services/moderationService.js';
import { performance } from 'perf_hooks';
// Mock dependencies
const mockClient = {
pool: {},
};
const mockLogger = {
log: () => {},
dev: { log: () => {} },
user: { warn: () => {}, info: () => {} }
};
const service = new ModerationService({
nostrClient: mockClient,
logger: mockLogger,
});
async function runBenchmark() {
console.log('Starting Moderation Service Stress Benchmark...');
// Setup Data
// 100 authors, each having 500 muters (spam scenario).
// 50 muters are expired.
const AUTHOR_COUNT = 100;
const MUTERS_PER_AUTHOR = 500;
const now = Date.now() / 1000;
const expiredTime = now - (61 * 24 * 60 * 60);
console.log(`Populating ${AUTHOR_COUNT} authors with ${MUTERS_PER_AUTHOR} muters each...`);
for (let i = 0; i < AUTHOR_COUNT; i++) {
const author = `author-${i}`.padEnd(64, '0');
const muters = new Map();
for (let j = 0; j < MUTERS_PER_AUTHOR; j++) {
const muter = `muter-${j}`.padEnd(64, '0');
// 10% expired
const timestamp = (j % 10 === 0) ? expiredTime : now;
muters.set(muter, timestamp);
}
service.trustedMutedAuthors.set(author, {
muters,
categories: new Map(),
count: muters.size
});
}
// Benchmark Read
console.log('Benchmarking getActiveTrustedMutersForAuthor (Read + Prune)...');
const READ_ITERATIONS = 10000; // 10k reads
const start = performance.now();
for (let i = 0; i < READ_ITERATIONS; i++) {
const authorIndex = i % AUTHOR_COUNT;
const author = `author-${authorIndex}`.padEnd(64, '0');
// This call triggers pruneTrustedMuteAggregates EVERY time
service.getActiveTrustedMutersForAuthor(author);
}
const end = performance.now();
const totalTime = end - start;
const avgTime = totalTime / READ_ITERATIONS;
console.log('---------------------------------------------------');
console.log(`Total time: ${totalTime.toFixed(2)}ms`);
console.log(`Average time per call: ${(avgTime * 1000).toFixed(4)}µs`);
console.log('---------------------------------------------------');
}
runBenchmark().catch(console.error);
-150
View File
@@ -1,150 +0,0 @@
import { createModerationStage } from '../js/feedEngine/stages.js';
import { performance } from 'perf_hooks';
// Mock Logger
const logger = {
log: () => {},
dev: { log: () => {}, debug: () => {} },
user: { warn: () => {}, info: () => {} }
};
// Realistic Mock Service
const mutedAuthors = new Set();
const reportedVideos = new Map();
// Seed data
for (let i = 0; i < 100; i++) {
if (Math.random() < 0.1) {
mutedAuthors.add(`pubkey-${i}`);
}
}
// Simulate pruning cost
function pruneSimulation() {
const map = new Map();
for(let i=0; i<5; i++) map.set(i, Date.now()); // Simulate 5 muters
for(const [k,v] of map) {
if (v < 0) map.delete(k);
}
}
const mockService = {
refreshViewerFromClient: async () => {},
setActiveEventIds: async () => {},
getAdminListSnapshot: () => ({
whitelist: new Set(),
whitelistHex: new Set(),
blacklist: new Set(),
blacklistHex: new Set()
}),
getAccessControlStatus: (pubkey) => ({
hex: pubkey,
whitelisted: false,
blacklisted: false
}),
getTrustedReportSummary: (id) => {
if (reportedVideos.has(id)) {
return reportedVideos.get(id);
}
return null;
},
trustedReportCount: (id) => {
const summary = reportedVideos.get(id);
return summary ? summary.totalTrusted : 0;
},
getTrustedReporters: (id) => [],
isAuthorMutedByTrusted: (pubkey) => {
// Simulate the real service logic which calls prune
pruneSimulation();
return mutedAuthors.has(pubkey);
},
getTrustedMutersForAuthor: (pubkey) => {
// Simulate prune here too if called
pruneSimulation();
if (mutedAuthors.has(pubkey)) {
return ['trusted-muter-1', 'trusted-muter-2'];
}
return [];
},
getTrustedMuteCountsForAuthor: (pubkey) => {
pruneSimulation();
if (mutedAuthors.has(pubkey)) {
return { total: 2, categories: { nudity: 1, spam: 1 } };
}
return { total: 0, categories: {} };
},
isAuthorMutedByViewer: (pubkey) => false,
};
async function runBenchmark() {
console.log('Starting Moderation Stage Benchmark (with prune cost)...');
const ITEM_COUNT = 10000;
const items = [];
for (let i = 0; i < ITEM_COUNT; i++) {
const videoId = `video-${i}`;
const pubkey = `pubkey-${i % 100}`;
// Simulate some reported videos
if (Math.random() < 0.05) {
reportedVideos.set(videoId, {
eventId: videoId,
totalTrusted: 2,
types: { nudity: { trusted: 2, total: 5, latest: Date.now() } },
updatedAt: Date.now()
});
}
items.push({
video: {
id: videoId,
pubkey: pubkey,
kind: 30078,
tags: []
},
metadata: {}
});
}
const context = {
runtime: {
moderationThresholds: {
autoplayBlockThreshold: 1,
blurThreshold: 1,
trustedSpamHideThreshold: 1,
trustedMuteHideThresholds: {}
},
isAuthorBlocked: () => false
},
log: logger.dev.log,
addWhy: () => {}
};
const moderationStage = createModerationStage({
service: mockService,
stageName: 'bench-moderation'
});
// Warmup
console.log('Warming up...');
await moderationStage(items.slice(0, 100), context);
// Run
console.log(`Processing ${ITEM_COUNT} items with ~10% muted authors and prune simulation...`);
const start = performance.now();
const result = await moderationStage(items, context);
const end = performance.now();
const totalTime = end - start;
const avgTime = totalTime / ITEM_COUNT;
console.log('---------------------------------------------------');
console.log(`Total time: ${totalTime.toFixed(2)}ms`);
console.log(`Average time per item: ${(avgTime * 1000).toFixed(4)}µs`);
console.log(`Items processed: ${result.length}`);
console.log('---------------------------------------------------');
}
runBenchmark().catch(console.error);
+570 -54
View File
@@ -57,14 +57,492 @@
</div>
</div>
<!-- Hashtags (kept from the old NIP-71 section: they feed bitvid's hashtag
feeds and the NIP-71 mirror; everything else in that section fed the
disabled FEATURE_PUBLISH_NIP71 path and was removed for a cleaner UI.
Existing videos' extra NIP-71 metadata is preserved on save. -->
<div class="space-y-4 rounded-lg border border-border/70 bg-overlay-black/30 p-4">
<!-- NOTE: Keep this NIP-71 markup in sync with the upload and revert modals. -->
<div
class="space-y-4 rounded-lg border border-border/70 bg-overlay-black/30 p-4"
id="editNip71EventDetails"
data-nip71-section="event-details"
>
<fieldset
id="editNip71KindFieldset"
class="space-y-2"
data-nip71-fieldset="kind"
>
<legend class="text-sm font-semibold text-text">
NIP-71 event type
</legend>
<p class="text-xs text-muted">
Choose the video format. <span class="font-medium text-muted-strong">Kind 21</span> is a full video, while
<span class="font-medium text-muted-strong">Kind 22</span> is a short.
</p>
<div
class="form-control"
role="radiogroup"
aria-labelledby="editNip71KindFieldset"
>
<div class="menu sm:flex-row sm:flex-wrap sm:py-0" role="presentation">
<label class="menu__item gap-3 px-0 py-0" for="editNip71KindLong">
<input
type="radio"
name="editNip71Kind"
id="editNip71KindLong"
value="21"
checked
data-nip71-input="kind"
class="h-4 w-4 border-border bg-panel text-info focus:ring-info"
/>
<span class="text-sm text-text">Normal video (kind 21)</span>
</label>
<label class="menu__item gap-3 px-0 py-0" for="editNip71KindShort">
<input
type="radio"
name="editNip71Kind"
id="editNip71KindShort"
value="22"
data-nip71-input="kind"
class="h-4 w-4 border-border bg-panel text-info focus:ring-info"
/>
<span class="text-sm text-text">Short video (kind 22)</span>
</label>
</div>
</div>
</fieldset>
<div class="grid gap-4 md:grid-cols-2" data-nip71-grid="metadata">
<div class="space-y-2">
<label for="editNip71PublishedAt" class="block text-sm font-medium text-text">
Published at
</label>
<input
type="datetime-local"
id="editNip71PublishedAt"
data-nip71-input="published_at"
class="input"
/>
<p class="text-xs text-muted">
Set the canonical publish time for cross-client ordering.
</p>
</div>
<div class="space-y-2">
<label for="editNip71AltText" class="block text-sm font-medium text-text">
Alt text
</label>
<input
type="text"
id="editNip71AltText"
data-nip71-input="alt"
placeholder="Describe the visuals for screen readers"
class="input"
/>
<p class="text-xs text-muted">
Plain-language fallback shown when the media cannot load.
</p>
</div>
<div class="space-y-2">
<label for="editNip71Duration" class="block text-sm font-medium text-text">
Duration (seconds)
</label>
<input
type="number"
id="editNip71Duration"
min="0"
step="0.001"
data-nip71-input="duration"
class="input"
/>
<p class="text-xs text-muted">
Optional legacy fallback in seconds (fractions allowed) for clients that expect a top-level duration.
</p>
</div>
<div class="space-y-2">
<label for="editNip71ContentWarning" class="block text-sm font-medium text-text">
Content warning
</label>
<input
type="text"
id="editNip71ContentWarning"
data-nip71-input="content-warning"
placeholder="e.g., Flashing lights"
class="input"
/>
<p class="text-xs text-muted">
Leave blank if the video is safe for all audiences.
</p>
</div>
</div>
<div class="space-y-2">
<label for="editNip71Summary" class="block text-sm font-medium text-text">
Summary
</label>
<textarea
id="editNip71Summary"
rows="3"
data-nip71-input="summary"
class="textarea"
placeholder="Short synopsis used as the Nostr content field"
></textarea>
<p class="text-xs text-muted">
This becomes the event content. Keep it concise for Nostr readers.
</p>
</div>
</div>
<fieldset
id="editNip71TagsFieldset"
class="space-y-6 rounded-lg border border-border/70 bg-overlay-black/30 p-4"
data-nip71-section="tags"
>
<legend class="relative text-sm font-semibold text-text">
<span class="inline-block -ml-2 rounded px-2 bg-overlay-black/30">NIP-71 tags</span>
</legend>
<p class="text-xs text-muted">
Use the controls below to describe media variants, captions, chapters, participants, and references.
</p>
<section class="space-y-4" aria-labelledby="editNip71ImetaLabel" data-nip71-repeater="imeta">
<div class="flex items-center justify-between">
<h3 id="editNip71ImetaLabel" class="text-sm font-medium text-text">Image metadata (imeta)</h3>
<button
type="button"
class="btn-ghost focus-ring text-xs font-semibold"
data-nip71-add="imeta"
aria-describedby="editNip71ImetaHelp"
>
Add variant
</button>
</div>
<p id="editNip71ImetaHelp" class="text-xs text-muted">
Provide dimensions, MIME type, and resource links for each variant. Nested repeaters let you attach multiple
<code>image</code>, <code>fallback</code>, or <code>service</code> values.
</p>
<div id="editNip71ImetaList" class="space-y-4" data-nip71-list="imeta"></div>
<template id="editNip71ImetaTemplate" data-nip71-template="imeta">
<article class="card space-y-3 bg-surface/60 p-4" data-nip71-entry="imeta">
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">
MIME type (<code>m</code>)
</label>
<input
type="text"
data-nip71-field="m"
placeholder="video/mp4"
class="input"
/>
<p class="text-3xs text-muted">RFC 2046 media type for this encoding.</p>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">
Pixel dimensions (<code>dim</code>)
</label>
<input
type="text"
data-nip71-field="dim"
placeholder="1920x1080"
class="input"
/>
<p class="text-3xs text-muted">Formatted as widthxheight.</p>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">
Streaming URL (<code>url</code>)
</label>
<input
type="url"
data-nip71-field="url"
placeholder="https://cdn.example.com/variant.m3u8"
class="input"
/>
<p class="text-3xs text-muted">HTTPS variant URL shared across clients.</p>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">
Magnet (<code>x</code>)
</label>
<input
type="text"
data-nip71-field="x"
placeholder="magnet:?xt=urn:btih:..."
class="input"
/>
<p class="text-3xs text-muted">Optional magnet pointer if the variant is seeded via P2P.</p>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">
Duration seconds (<code>duration</code>)
</label>
<input
type="number"
data-nip71-field="duration"
min="0"
step="0.001"
placeholder="29.97"
class="input"
/>
<p class="text-3xs text-muted">Fractional seconds supported; clients use this for precise timelines.</p>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">
Bitrate bps (<code>bitrate</code>)
</label>
<input
type="number"
data-nip71-field="bitrate"
min="0"
step="1"
placeholder="3500000"
class="input"
/>
<p class="text-3xs text-muted">Average bits per second for this variant.</p>
</div>
</div>
<div class="space-y-3" data-nip71-nested="image">
<div class="flex items-center justify-between">
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">Image values</h4>
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-nested-add="image"
>
Add image URL
</button>
</div>
<div class="space-y-2" data-nip71-nested-list="image"></div>
<template data-nip71-nested-template="image">
<div class="flex items-center gap-2" data-nip71-nested-entry="image">
<input
type="url"
placeholder="https://cdn.example.com/poster.jpg"
data-nip71-nested-field="image"
class="flex-1 input"
/>
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-remove="nested"
aria-label="Remove image URL"
>
Remove
</button>
</div>
</template>
</div>
<div class="space-y-3" data-nip71-nested="fallback">
<div class="flex items-center justify-between">
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">Fallback URLs</h4>
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-nested-add="fallback"
>
Add fallback
</button>
</div>
<div class="space-y-2" data-nip71-nested-list="fallback"></div>
<template data-nip71-nested-template="fallback">
<div class="flex items-center gap-2" data-nip71-nested-entry="fallback">
<input
type="url"
placeholder="https://cdn.backup.net/video.mp4"
data-nip71-nested-field="fallback"
class="flex-1 input"
/>
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-remove="nested"
aria-label="Remove fallback URL"
>
Remove
</button>
</div>
</template>
</div>
<div class="space-y-3" data-nip71-nested="service">
<div class="flex items-center justify-between">
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">Services</h4>
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-nested-add="service"
>
Add service
</button>
</div>
<div class="space-y-2" data-nip71-nested-list="service"></div>
<template data-nip71-nested-template="service">
<div class="flex items-center gap-2" data-nip71-nested-entry="service">
<input
type="text"
placeholder="https://api.example.com"
data-nip71-nested-field="service"
class="flex-1 input"
/>
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-remove="nested"
aria-label="Remove service entry"
>
Remove
</button>
</div>
</template>
</div>
<div class="flex justify-end">
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-remove="imeta"
>
Remove variant
</button>
</div>
</article>
</template>
</section>
<section class="space-y-4" aria-labelledby="editNip71TextTracksLabel" data-nip71-repeater="text-track">
<div class="flex items-center justify-between">
<h3 id="editNip71TextTracksLabel" class="text-sm font-medium text-text">
Caption &amp; subtitle tracks (text-track)
</h3>
<button
type="button"
class="btn-ghost focus-ring text-xs font-semibold"
data-nip71-add="text-track"
aria-describedby="editNip71TextTracksHelp"
>
Add track
</button>
</div>
<p id="editNip71TextTracksHelp" class="text-xs text-muted">
Supply caption or subtitle URLs along with file type and language so players can surface accessibility features.
</p>
<div id="editNip71TextTrackList" class="space-y-4" data-nip71-list="text-track"></div>
<template id="editNip71TextTrackTemplate" data-nip71-template="text-track">
<article class="card space-y-3 bg-surface/60 p-4" data-nip71-entry="text-track">
<div class="grid gap-3 md:grid-cols-3">
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Track URL</label>
<input
type="url"
data-nip71-field="url"
placeholder="https://cdn.example.com/subs.vtt"
class="input"
/>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Type</label>
<input
type="text"
data-nip71-field="type"
placeholder="text/vtt"
class="input"
/>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Language</label>
<input
type="text"
data-nip71-field="language"
placeholder="en"
class="input"
/>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-remove="text-track"
>
Remove track
</button>
</div>
</article>
</template>
</section>
<section class="space-y-4" aria-labelledby="editNip71SegmentsLabel" data-nip71-repeater="segment">
<div class="flex items-center justify-between">
<h3 id="editNip71SegmentsLabel" class="text-sm font-medium text-text">Chapters &amp; segments (segment)</h3>
<button
type="button"
class="btn-ghost focus-ring text-xs font-semibold"
data-nip71-add="segment"
aria-describedby="editNip71SegmentsHelp"
>
Add segment
</button>
</div>
<p id="editNip71SegmentsHelp" class="text-xs text-muted">
Outline chapters with start and end times, titles, and optional thumbnails.
</p>
<div id="editNip71SegmentList" class="space-y-4" data-nip71-list="segment"></div>
<template id="editNip71SegmentTemplate" data-nip71-template="segment">
<article class="card space-y-3 bg-surface/60 p-4" data-nip71-entry="segment">
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Start (seconds)</label>
<input
type="number"
data-nip71-field="start"
min="0"
step="1"
class="input"
/>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">End (seconds)</label>
<input
type="number"
data-nip71-field="end"
min="0"
step="1"
class="input"
/>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Title</label>
<input
type="text"
data-nip71-field="title"
placeholder="Chapter title"
class="input"
/>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Thumbnail URL</label>
<input
type="url"
data-nip71-field="thumbnail"
placeholder="https://cdn.example.com/frame.jpg"
class="input"
/>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-remove="segment"
>
Remove segment
</button>
</div>
</article>
</template>
</section>
<section class="space-y-4" aria-labelledby="editNip71HashtagsLabel" data-nip71-repeater="t">
<div class="flex items-center justify-between">
<h3 id="editNip71HashtagsLabel" class="text-sm font-medium text-text">Hashtags</h3>
<h3 id="editNip71HashtagsLabel" class="text-sm font-medium text-text">Hashtags (<code>t</code>)</h3>
<button
type="button"
class="btn-ghost focus-ring text-xs font-semibold"
@@ -74,7 +552,7 @@
</button>
</div>
<p class="text-xs text-muted">
Tag your video so it shows up in hashtag feeds. Leading <code>#</code> characters are stripped automatically.
Enter keywords; leading <code>#</code> characters are stripped automatically when saving.
</p>
<div id="editNip71HashtagList" class="space-y-2" data-nip71-list="t"></div>
<template id="editNip71HashtagTemplate" data-nip71-template="t">
@@ -95,7 +573,85 @@
</div>
</template>
</section>
</div>
<section class="space-y-4" aria-labelledby="editNip71ParticipantsLabel" data-nip71-repeater="p">
<div class="flex items-center justify-between">
<h3 id="editNip71ParticipantsLabel" class="text-sm font-medium text-text">Participants (<code>p</code>)</h3>
<button
type="button"
class="btn-ghost focus-ring text-xs font-semibold"
data-nip71-add="p"
>
Add participant
</button>
</div>
<div id="editNip71ParticipantList" class="space-y-3" data-nip71-list="p"></div>
<template id="editNip71ParticipantTemplate" data-nip71-template="p">
<article class="card space-y-3 bg-surface/60 p-4" data-nip71-entry="p">
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Participant pubkey</label>
<input
type="text"
data-nip71-field="pubkey"
placeholder="npub1... or hex"
class="input"
/>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Relay hint</label>
<input
type="text"
data-nip71-field="relay"
placeholder="wss://relay.example.com"
class="input"
/>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-remove="p"
>
Remove participant
</button>
</div>
</article>
</template>
</section>
<section class="space-y-4" aria-labelledby="editNip71ReferencesLabel" data-nip71-repeater="r">
<div class="flex items-center justify-between">
<h3 id="editNip71ReferencesLabel" class="text-sm font-medium text-text">Reference links (<code>r</code>)</h3>
<button
type="button"
class="btn-ghost focus-ring text-xs font-semibold"
data-nip71-add="r"
>
Add reference
</button>
</div>
<div id="editNip71ReferenceList" class="space-y-2" data-nip71-list="r"></div>
<template id="editNip71ReferenceTemplate" data-nip71-template="r">
<div class="flex items-center gap-2" data-nip71-entry="r">
<input
type="url"
data-nip71-field="url"
placeholder="https://example.com/context"
class="flex-1 input"
/>
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-remove="r"
>
Remove
</button>
</div>
</template>
</section>
</fieldset>
<div class="space-y-1">
<label for="editVideoUrl" class="block text-sm font-medium text-text">
@@ -116,39 +672,19 @@
Edit field
</button>
</div>
<div class="flex items-center gap-2 flex-wrap">
<input type="file" id="editVideoFile" accept="video/*" class="hidden" />
<button
type="button"
id="editVideoFileBtn"
class="btn-ghost focus-ring text-xs"
>
Replace video file…
</button>
<p
id="editVideoUploadStatus"
class="text-2xs text-muted empty:hidden"
role="status"
aria-live="polite"
></p>
</div>
<p class="text-xs text-muted">
Uploading a new file replaces the URL, magnet, and torrent hints.
Requires unlocked storage.
</p>
</div>
<div class="space-y-1">
<label for="editVideoWs" class="block text-sm font-medium text-text">
Web seeds (ws=, one per line)
Web seed (ws=)
</label>
<div class="flex flex-col gap-2 sm:flex-row">
<textarea
<input
type="url"
id="editVideoWs"
rows="2"
class="flex-1 textarea"
placeholder="https://cdn.example.com/video.mp4&#10;https://backup.example.com/video.mp4"
></textarea>
class="flex-1 input"
placeholder="https://cdn.example.com/video.mp4"
/>
<button
type="button"
class="btn-ghost focus-ring hidden text-xs font-semibold uppercase tracking-wide"
@@ -157,10 +693,6 @@
Edit field
</button>
</div>
<p class="text-xs text-muted">
Add a backup origin so the swarm keeps streaming if the primary is
overloaded. Each must serve the exact same file.
</p>
</div>
<div class="space-y-1">
@@ -224,22 +756,6 @@
Edit field
</button>
</div>
<div class="flex items-center gap-2 flex-wrap">
<input type="file" id="editThumbnailFile" accept="image/*" class="hidden" />
<button
type="button"
id="editThumbnailFileBtn"
class="btn-ghost focus-ring text-xs"
>
Replace thumbnail file…
</button>
<p
id="editThumbnailUploadStatus"
class="text-2xs text-muted empty:hidden"
role="status"
aria-live="polite"
></p>
</div>
</div>
<div class="space-y-1">
@@ -9,8 +9,6 @@
<link rel="stylesheet" href="../../css/tailwind.generated.css" />
<!-- Load nostr‑tools v2.17.0 -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/nostr.bundle.min.js"></script>
<!-- Shared anti-spam npub validation (sets window.validateNpubHex) -->
<script type="module" src="../../js/utils/validateNpub.js"></script>
</head>
<body class="iframe-form" data-ds="new">
<div class="container">
@@ -279,12 +277,11 @@
// Set the recipient's NPUB (your personal NPUB) here.
const recipientNpub =
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx";
"npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe";
const RELAYS = [
"wss://relay.snort.social",
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nos.lol",
];
const pool = new SimplePool();
@@ -298,21 +295,6 @@
const applicantNpub = document
.getElementById("applicantNpub")
.value.trim();
// Anti-spam: the application identifier must be a valid npub.
const applicantPubHex =
typeof window.validateNpubHex === "function"
? window.validateNpubHex(applicantNpub)
: null;
if (!applicantPubHex) {
log(
"Please enter a valid Nostr public key (npub1…) before submitting. Your application was not sent.",
"error"
);
document.getElementById("applicantNpub")?.focus();
return;
}
const contactMethod = document
.getElementById("contactMethod")
.value.trim();
@@ -371,7 +353,6 @@
**1. Applicant Information**
- **Nostr Public Key (npub):** ${applicantNpub}
- **Pubkey (hex):** ${applicantPubHex}
- **Preferred Contact Method:** ${contactMethod || "N/A"}
- **Username or Alias:** ${username || "N/A"}
@@ -438,27 +419,23 @@ For further questions, contact us through bitvid’s Nostr support channels.
const ephemeralPubHex = getPublicKey(ephemeralPriv);
log("Ephemeral pubkey: " + ephemeralPubHex.slice(0, 16) + "...");
// Build a structured bitvid submission (kind 30083, PUBLIC — no
// encryption). Published from the ephemeral key; the admin's
// Submissions tab lists these and can approve → add to whitelist.
// Encrypt the application content.
log("Encrypting application content (nip04)...");
const ciphertext = await nip04.encrypt(
ephemeralPriv,
targetPubHex,
applicationContent
);
log("[DEBUG] Ciphertext: " + ciphertext);
log("Encryption done.");
// Build the Nostr event template.
const now = Math.floor(Date.now() / 1000);
const submissionId =
"sub-" +
now.toString(36) +
"-" +
Math.random().toString(36).slice(2, 10);
const eventTemplate = {
kind: 30083,
kind: 4,
created_at: now,
tags: [
["d", submissionId],
["k", "application"],
["applicant", applicantNpub],
["p", targetPubHex],
["t", "bitvid-submission"],
["client", "bitvid"],
],
content: applicationContent,
tags: [["p", targetPubHex]],
content: ciphertext,
};
log(
"[DEBUG] Event template before finalizing: " +
@@ -478,7 +455,7 @@ For further questions, contact us through bitvid’s Nostr support channels.
for (const url of RELAYS) {
log("Connecting to " + url + " for subscription...");
const relay = await Relay.connect(url);
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [30083] }], {
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [4] }], {
onEvent(foundEvent) {
if (foundEvent.id === event.id) {
log(
@@ -8,8 +8,6 @@
<link rel="stylesheet" href="../../css/tailwind.generated.css" />
<!-- Load nostr‑tools v2.17.0 -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/nostr.bundle.min.js"></script>
<!-- Shared anti-spam npub validation (sets window.validateNpubHex) -->
<script type="module" src="../../js/utils/validateNpub.js"></script>
</head>
<body class="iframe-form" data-ds="new">
<div class="container">
@@ -261,12 +259,11 @@
// Set the recipient's NPUB (your personal NPUB)
const recipientNpub =
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx";
"npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe";
const RELAYS = [
"wss://relay.snort.social",
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nos.lol",
];
const pool = new SimplePool();
@@ -278,22 +275,6 @@
try {
// Section 1: User Information
const userNpub = document.getElementById("userNpub").value.trim();
// Anti-spam: npub is optional here, but if provided it must be a
// valid npub so the submission can't carry free-text garbage.
if (
userNpub &&
(typeof window.validateNpubHex !== "function" ||
!window.validateNpubHex(userNpub))
) {
log(
"That Nostr public key isn't valid. Enter a valid npub1… or leave it blank. Your submission was not sent.",
"error"
);
document.getElementById("userNpub")?.focus();
return;
}
const roleNodes = document.querySelectorAll(
'input[name="userRole"]:checked'
);
@@ -418,31 +399,23 @@ For urgent issues, contact us through bitvid’s Nostr support channels.
const ephemeralPubHex = getPublicKey(ephemeralPriv);
log("Ephemeral pubkey: " + ephemeralPubHex.slice(0, 16) + "...");
// Build a structured bitvid submission (kind 30083, PUBLIC — no
// encryption). Published from the ephemeral key.
const now = Math.floor(Date.now() / 1000);
const submissionId =
"sub-" +
now.toString(36) +
"-" +
Math.random().toString(36).slice(2, 10);
const tags = [
["d", submissionId],
["k", "bug"],
];
if (userNpub) {
tags.push(["applicant", userNpub]);
}
tags.push(
["p", targetPubHex],
["t", "bitvid-submission"],
["client", "bitvid"]
// Encrypt the bug report content.
log("Encrypting bug report content (nip04)...");
const ciphertext = await nip04.encrypt(
ephemeralPriv,
targetPubHex,
bugReportContent
);
log("[DEBUG] Ciphertext: " + ciphertext);
log("Encryption done.");
// Build the event template.
const now = Math.floor(Date.now() / 1000);
const eventTemplate = {
kind: 30083,
kind: 4,
created_at: now,
tags,
content: bugReportContent,
tags: [["p", targetPubHex]],
content: ciphertext,
};
log(
"[DEBUG] Event template before finalizing: " +
@@ -462,7 +435,7 @@ For urgent issues, contact us through bitvid’s Nostr support channels.
for (const url of RELAYS) {
log("Connecting to " + url + " for subscription...");
const relay = await Relay.connect(url);
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [30083] }], {
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [4] }], {
onEvent(foundEvent) {
if (foundEvent.id === event.id) {
log(
@@ -45,18 +45,6 @@
placeholder="Enter the magnet link"
/>
<label for="eventPointer">Blocked video link or event ID:</label>
<input
class="form-control"
type="text"
id="eventPointer"
placeholder="Paste the video's share link, nevent1…, note1…, or 64-char event id"
/>
<p class="form-hint">
Required — open the blocked video's ⋯ menu, choose “Copy link”, and
paste it here so a moderator can locate and unblock the exact video.
</p>
<label for="submissionDate">Date of Content Submission:</label>
<input class="form-control" type="date" id="submissionDate" />
@@ -205,46 +193,15 @@
// Set the recipient's NPUB (your personal NPUB) here.
const recipientNpub =
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx";
"npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe";
const RELAYS = [
"wss://relay.snort.social",
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nos.lol",
];
const pool = new SimplePool();
// Validate + normalize the blocked-video reference to a 64-char hex
// event id. Accepts a bitvid share link (which embeds an nevent), a raw
// nevent1…/note1… pointer, or a bare hex id. Returns "" when nothing
// valid is found so the caller can block submission.
const HEX_EVENT_ID = /^[0-9a-f]{64}$/i;
function extractEventHex(input) {
const trimmed = String(input || "").trim();
if (!trimmed) {
return "";
}
if (HEX_EVENT_ID.test(trimmed)) {
return trimmed.toLowerCase();
}
// Pull an nevent/note token out of a URL or free text.
const match = trimmed.match(/(nevent1[0-9a-z]+|note1[0-9a-z]+)/i);
const token = match ? match[1] : trimmed;
try {
const decoded = nip19.decode(token);
if (decoded.type === "nevent" && HEX_EVENT_ID.test(decoded.data?.id || "")) {
return decoded.data.id.toLowerCase();
}
if (decoded.type === "note" && HEX_EVENT_ID.test(decoded.data || "")) {
return decoded.data.toLowerCase();
}
} catch (err) {
// not a decodable pointer
}
return "";
}
document
.getElementById("dm-form")
.addEventListener("submit", async (ev) => {
@@ -263,17 +220,6 @@
const magnetLink = document
.getElementById("magnetLink")
.value.trim();
const eventPointer = document
.getElementById("eventPointer")
.value.trim();
const targetEventHex = extractEventHex(eventPointer);
if (!targetEventHex) {
log(
"Please paste a valid video share link, nevent1…, note1…, or 64-char event id so a moderator can locate the blocked video.",
"error"
);
return;
}
const submissionDate = document
.getElementById("submissionDate")
.value.trim();
@@ -315,7 +261,6 @@
**2. Content Details**
- **Title of the Video:** ${videoTitle}
- **Magnet Link:** ${magnetLink}
- **Blocked Event ID:** ${targetEventHex}
- **Date of Content Submission:** ${submissionDate}
**3. Reason for Appeal**
@@ -370,27 +315,23 @@ For further questions, reach out through bitvid’s Nostr support channels.
const ephemeralPubHex = getPublicKey(ephemeralPriv);
log("Ephemeral pubkey: " + ephemeralPubHex.slice(0, 16) + "...");
// Build a structured bitvid submission (kind 30083, PUBLIC — no
// encryption). Published from the ephemeral key.
log("Building appeal submission (kind 30083, public)...");
// Encrypt the appeal content.
log("Encrypting appeal content (nip04)...");
const ciphertext = await nip04.encrypt(
ephemeralPriv,
targetPubHex,
appealContent
);
log("[DEBUG] Ciphertext: " + ciphertext);
log("Encryption done.");
// Build the event template.
const now = Math.floor(Date.now() / 1000);
const submissionId =
"sub-" +
now.toString(36) +
"-" +
Math.random().toString(36).slice(2, 10);
const eventTemplate = {
kind: 30083,
kind: 4,
created_at: now,
tags: [
["d", submissionId],
["k", "appeal"],
["p", targetPubHex],
["e", targetEventHex],
["t", "bitvid-submission"],
["client", "bitvid"],
],
content: appealContent,
tags: [["p", targetPubHex]],
content: ciphertext,
};
log(
"[DEBUG] Event template before finalizing: " +
@@ -410,7 +351,7 @@ For further questions, reach out through bitvid’s Nostr support channels.
for (const url of RELAYS) {
log("Connecting to " + url + " for subscription...");
const relay = await Relay.connect(url);
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [30083] }], {
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [4] }], {
onEvent(foundEvent) {
if (foundEvent.id === event.id) {
log(
@@ -8,8 +8,6 @@
<link rel="stylesheet" href="../../css/tailwind.generated.css" />
<!-- Load nostr‑tools v2.17.0 -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/nostr.bundle.min.js"></script>
<!-- Shared anti-spam npub validation (sets window.validateNpubHex) -->
<script type="module" src="../../js/utils/validateNpub.js"></script>
</head>
<body class="iframe-form" data-ds="new">
<div class="container">
@@ -197,12 +195,11 @@
// Set the recipient's NPUB (your personal NPUB)
const recipientNpub =
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx";
"npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe";
const RELAYS = [
"wss://relay.snort.social",
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nos.lol",
];
const pool = new SimplePool();
@@ -215,22 +212,6 @@
try {
// Section 1: User Information
const userNpub = document.getElementById("userNpub").value.trim();
// Anti-spam: npub is optional here, but if provided it must be a
// valid npub so the submission can't carry free-text garbage.
if (
userNpub &&
(typeof window.validateNpubHex !== "function" ||
!window.validateNpubHex(userNpub))
) {
log(
"That Nostr public key isn't valid. Enter a valid npub1… or leave it blank. Your submission was not sent.",
"error"
);
document.getElementById("userNpub")?.focus();
return;
}
const roleNodes = document.querySelectorAll(
'input[name="userRole"]:checked'
);
@@ -319,31 +300,21 @@ For additional discussions, reach out via bitvid’s Nostr support channels.
// Encrypt the feedback content.
log("Encrypting feedback content (nip04)...");
// Build a structured bitvid submission (kind 30083, PUBLIC — no
// encryption). Published from the ephemeral key.
const now = Math.floor(Date.now() / 1000);
const submissionId =
"sub-" +
now.toString(36) +
"-" +
Math.random().toString(36).slice(2, 10);
const tags = [
["d", submissionId],
["k", "feedback"],
];
if (userNpub) {
tags.push(["applicant", userNpub]);
}
tags.push(
["p", targetPubHex],
["t", "bitvid-submission"],
["client", "bitvid"]
const ciphertext = await nip04.encrypt(
ephemeralPriv,
targetPubHex,
feedbackContent
);
log("[DEBUG] Ciphertext: " + ciphertext);
log("Encryption done.");
// Build the event template.
const now = Math.floor(Date.now() / 1000);
const eventTemplate = {
kind: 30083,
kind: 4,
created_at: now,
tags,
content: feedbackContent,
tags: [["p", targetPubHex]],
content: ciphertext,
};
log(
"[DEBUG] Event template before finalizing: " +
@@ -363,7 +334,7 @@ For additional discussions, reach out via bitvid’s Nostr support channels.
for (const url of RELAYS) {
log("Connecting to " + url + " for subscription...");
const relay = await Relay.connect(url);
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [30083] }], {
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [4] }], {
onEvent(foundEvent) {
if (foundEvent.id === event.id) {
log(
@@ -8,8 +8,6 @@
<link rel="stylesheet" href="../../css/tailwind.generated.css" />
<!-- Load nostr‑tools v2.17.0 -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/nostr.bundle.min.js"></script>
<!-- Shared anti-spam npub validation (sets window.validateNpubHex) -->
<script type="module" src="../../js/utils/validateNpub.js"></script>
</head>
<body class="iframe-form" data-ds="new">
<div class="container">
@@ -221,12 +219,11 @@
// Set the recipient's NPUB (your personal NPUB)
const recipientNpub =
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx";
"npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe";
const RELAYS = [
"wss://relay.snort.social",
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nos.lol",
];
const pool = new SimplePool();
@@ -238,21 +235,6 @@
try {
// Section 1: User Information
const userNpub = document.getElementById("userNpub").value.trim();
// Anti-spam: npub is optional here, but if provided it must be a
// valid npub so the submission can't carry free-text garbage.
if (
userNpub &&
(typeof window.validateNpubHex !== "function" ||
!window.validateNpubHex(userNpub))
) {
log(
"That Nostr public key isn't valid. Enter a valid npub1… or leave it blank. Your submission was not sent.",
"error"
);
document.getElementById("userNpub")?.focus();
return;
}
const roleNodes = document.querySelectorAll(
'input[name="userRole"]:checked'
);
@@ -361,31 +343,21 @@ For further discussions, reach out through bitvid’s Nostr support channels.
// Encrypt the feature request content
log("Encrypting feature request content (nip04)...");
// Build a structured bitvid submission (kind 30083, PUBLIC — no
// encryption). Published from the ephemeral key.
const now = Math.floor(Date.now() / 1000);
const submissionId =
"sub-" +
now.toString(36) +
"-" +
Math.random().toString(36).slice(2, 10);
const tags = [
["d", submissionId],
["k", "feature"],
];
if (userNpub) {
tags.push(["applicant", userNpub]);
}
tags.push(
["p", targetPubHex],
["t", "bitvid-submission"],
["client", "bitvid"]
const ciphertext = await nip04.encrypt(
ephemeralPriv,
targetPubHex,
featureRequestContent
);
log("[DEBUG] Ciphertext: " + ciphertext);
log("Encryption done.");
// Build the event template
const now = Math.floor(Date.now() / 1000);
const eventTemplate = {
kind: 30083,
kind: 4,
created_at: now,
tags,
content: featureRequestContent,
tags: [["p", targetPubHex]],
content: ciphertext,
};
log(
"[DEBUG] Event template before finalizing: " +
@@ -405,7 +377,7 @@ For further discussions, reach out through bitvid’s Nostr support channels.
for (const url of RELAYS) {
log("Connecting to " + url + " for subscription...");
const relay = await Relay.connect(url);
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [30083] }], {
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [4] }], {
onEvent(foundEvent) {
if (foundEvent.id === event.id) {
log(
+12 -706
View File
@@ -62,35 +62,6 @@
</button>
</div>
<!--
Active-identity card (#43): visible on EVERY pane, straddling the
header divider, so users always see which profile they're acting as
(e.g. before uploading). Clicking it jumps to the Account pane.
-->
<button
id="profileModalIdentityCard"
type="button"
class="profile-modal__identity focus-ring hidden"
aria-hidden="true"
aria-label="Active profile — open account settings"
>
<span class="profile-modal__identity-avatar" aria-hidden="true">
<img
id="profileModalIdentityAvatar"
src="/assets/svg/default-profile.svg"
alt=""
/>
</span>
<span class="profile-modal__identity-copy">
<span class="profile-modal__identity-kicker">Acting as</span>
<span
id="profileModalIdentityName"
class="profile-modal__identity-name"
>No active profile</span
>
</span>
</button>
<!-- Modal Body -->
<div class="profile-modal__body">
<div
@@ -189,50 +160,6 @@
</svg>
Storage
</button>
<button
id="profileNavMyVideos"
type="button"
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
aria-controls="profilePaneMyVideos"
aria-selected="false"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4h16v16H4V4zm0 5h16M9 4v16m6-16v16"
/>
</svg>
My Videos
</button>
<button
id="profileNavPlaylists"
type="button"
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
aria-controls="profilePanePlaylists"
aria-selected="false"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h11M4 10h11M4 14h7m3 5l6-3.5L14 12v7z"
/>
</svg>
Playlists
</button>
<button
id="profileNavHashtags"
type="button"
@@ -423,37 +350,11 @@
/>
</svg>
Admin (Moderation)
<span
id="profileNavAdminUnreadDot"
class="profile-nav__unread-dot"
aria-hidden="true"
></span>
</button>
<button
id="profileMobileLockNowBtn"
type="button"
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium mt-6 lg:hidden hidden"
title="Forget your unlocked key on this device and re-lock storage + wallet now"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
Lock this device
</button>
<button
id="profileMobileLogoutBtn"
type="button"
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium mt-2 lg:hidden"
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium mt-6 lg:hidden"
data-variant="critical"
>
<svg
@@ -482,19 +383,16 @@
>
<div
id="profilePermissionPrompt"
data-testid="profile-permission-prompt"
class="card hidden flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between"
role="status"
aria-live="polite"
>
<p
id="profilePermissionPromptMessage"
data-testid="profile-permission-prompt-message"
class="text-sm text-muted"
></p>
<button
id="profilePermissionPromptButton"
data-testid="profile-permission-prompt-button"
type="button"
class="btn focus-ring"
>
@@ -819,12 +717,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
</button>
</div>
<div
id="relayHealthSuggestion"
class="hidden card border border-status-warning/40 bg-status-warning/10 p-3 text-xs text-text"
role="status"
aria-live="polite"
></div>
<ul id="relayList" class="grid-stack" data-orientation="vertical"></ul>
<div class="space-y-2 px-1">
<label
@@ -860,51 +752,16 @@
<p class="text-sm text-muted">
Connect a Nostr Wallet Connect (NWC) compatible wallet to zap creators directly from bitvid.
</p>
<div id="profileWalletBitcoinConnectRow" class="hidden space-y-2">
<button
id="profileWalletBitcoinConnect"
type="button"
class="btn focus-ring inline-flex items-center gap-2"
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M13 2 4 14h6l-1 8 9-12h-6l1-8Z"></path>
</svg>
Connect wallet
</button>
<p class="text-xs text-muted">
One-tap connect via Bitcoin Connect (NWC) — or paste a connection manually below.
</p>
</div>
<div class="space-y-4">
<details id="profileWalletAdvanced" class="wallet-advanced">
<summary
id="profileWalletAdvancedSummary"
class="wallet-advanced__summary cursor-pointer text-xs font-medium text-muted-strong hover:text-text"
>
Advanced — paste a connection manually
</summary>
<label
class="mt-3 block text-sm text-muted-strong"
for="profileWalletUri"
>
<span class="mb-2 block font-medium">Wallet Connect URI</span>
<input
id="profileWalletUri"
type="url"
placeholder="nostr+walletconnect://<public-key>?relay=wss://relay.example.com"
class="input"
/>
</label>
</details>
<label class="block text-sm text-muted-strong" for="profileWalletUri">
<span class="mb-2 block font-medium">Wallet Connect URI</span>
<input
id="profileWalletUri"
type="url"
placeholder="nostr+walletconnect://<public-key>?relay=wss://relay.example.com"
class="input"
/>
</label>
<label class="block text-sm text-muted-strong" for="profileWalletDefaultZap">
<span class="mb-2 block font-medium">Default zap amount (sats)</span>
<input
@@ -948,33 +805,6 @@
role="status"
aria-live="polite"
></p>
<div id="profileWalletSync" class="mt-2 rounded-lg border border-default p-4 space-y-3 hidden">
<div class="space-y-1">
<h4 class="text-sm font-semibold text-text">Cross-device sync</h4>
<p class="text-xs text-muted">
Save an <strong>encrypted</strong> copy of your wallet
connection to your Nostr account so you can use it on other
devices without re-pasting the URI.
</p>
</div>
<label class="flex items-start gap-2 text-sm text-muted-strong">
<input id="walletSyncToggle" type="checkbox" class="checkbox mt-0.5" />
<span>Sync my wallet connection to my Nostr account (encrypted)</span>
</label>
<p class="text-xs text-status-danger">
<strong>Spending warning:</strong> a wallet connect URI can spend
from your wallet. The encrypted copy lives on public relays —
anyone who controls your Nostr key could decrypt it and spend
from this wallet. Only enable this if you accept that risk.
</p>
<div class="flex flex-wrap items-center gap-3">
<button id="walletSyncRestoreBtn" type="button" class="btn-ghost focus-ring">
Restore from my Nostr account
</button>
</div>
<p id="walletSyncStatus" class="text-xs" role="status" aria-live="polite"></p>
</div>
</div>
</section>
@@ -1010,47 +840,15 @@
<span class="mb-2 block font-medium">Provider</span>
<select id="storageProvider" class="input">
<option value="cloudflare_r2" selected>Cloudflare R2</option>
<option value="backblaze_b2">Backblaze B2</option>
<option value="generic_s3">Custom S3</option>
<option id="storageProviderBlossomOption" value="blossom" class="hidden">Blossom (Nostr-native)</option>
</select>
</label>
<!-- Blossom (Nostr-native) — keyless, multi-server. Shown when the
Blossom provider is selected; auth is your Nostr signer. -->
<div id="storageBlossomSection" class="hidden space-y-3">
<div class="card border border-info/30 bg-info/10 p-3 text-xs text-info">
Blossom stores files on nostr-native blob servers. No access keys — uploads are authorized by your Nostr signer. Add one or more servers (uploads mirror to all for resilience). The first server is the primary.
</div>
<label class="block text-sm text-muted-strong" for="storageBlossomServers">
<span class="mb-2 block font-medium">Blossom servers (one URL per line)</span>
<textarea id="storageBlossomServers" rows="3" class="input" placeholder="https://blossom.example.com
https://another.blossom.example"></textarea>
</label>
<!-- BUD-03 kind-10063 discovery: import the list you've published, or publish the list above. -->
<div class="flex flex-wrap gap-2">
<button id="storageBlossomImportBtn" type="button" class="btn-ghost focus-ring text-xs">
Import from my server list
</button>
<button id="storageBlossomPublishBtn" type="button" class="btn-ghost focus-ring text-xs">
Publish my server list
</button>
</div>
<p id="storageBlossomServerListStatus" class="text-xs text-muted" role="status"></p>
</div>
<!-- S3-family fields (R2 / B2 / Custom S3). Hidden for Blossom. -->
<div id="storageS3Fields" class="space-y-4">
<!-- R2 Helper -->
<div id="storageR2Helper" class="card border border-info/30 bg-info/10 p-3 text-xs text-info">
Enter your Cloudflare R2 credentials. Your Account ID (from dashboard) is used to connect. For Public Access URL, use your custom domain or R2.dev subdomain. Ensure the token has "Object Read & Write" permissions.
</div>
<!-- B2 Helper -->
<div id="storageB2Helper" class="card border border-info/30 bg-info/10 p-3 text-xs text-info hidden">
Enter your Backblaze B2 Application Key. Set Region to your bucket's region (e.g. us-west-004) — bitvid derives the endpoint and the public URL (https://&lt;bucket&gt;.s3.&lt;region&gt;.backblazeb2.com). Your bucket must be Public for playback; override the Public Access URL if you serve files from a custom domain/CDN.
</div>
<!-- S3 Helper -->
<div id="storageS3Helper" class="card border border-info/30 bg-info/10 p-3 text-xs text-info hidden">
Enter your S3-compatible storage details. The Endpoint URL is your provider's API address (e.g., https://s3.amazonaws.com or your MinIO URL).
@@ -1094,8 +892,6 @@ https://another.blossom.example"></textarea>
The public URL where your files will be accessible (CDN).
</span>
</label>
</div>
<!-- /storageS3Fields -->
<label class="flex items-center gap-2 text-sm text-muted-strong">
<input id="storageDefault" type="checkbox" class="checkbox" />
@@ -1113,179 +909,12 @@ https://another.blossom.example"></textarea>
<button id="storageClearBtn" type="button" class="btn-ghost focus-ring text-status-danger">
Clear
</button>
<button id="storageCorsHelpBtn" type="button" class="btn-ghost focus-ring text-xs">
CORS setup help
</button>
</div>
<p id="storageFormStatus" class="text-sm" role="status" aria-live="polite"></p>
<div id="profileStorageSync" class="mt-2 rounded-lg border border-default p-4 space-y-3 hidden">
<div class="space-y-1">
<h4 class="text-sm font-semibold text-text">Cross-device sync</h4>
<p class="text-xs text-muted">
Save an <strong>encrypted</strong> copy of these storage
settings to your Nostr account so you don't have to
re-enter them on other devices.
</p>
</div>
<label class="flex items-start gap-2 text-sm text-muted-strong">
<input id="storageSyncToggle" type="checkbox" class="checkbox mt-0.5" />
<span>Sync these settings to my Nostr account (encrypted)</span>
</label>
<p class="text-xs text-status-warning">
The encrypted copy lives on public relays. It is readable
only with your Nostr key, but anyone who controls your key
could decrypt it — and the relays can see that your account
stores bitvid settings.
</p>
<div class="flex flex-wrap items-center gap-3">
<button id="storageSyncRestoreBtn" type="button" class="btn-ghost focus-ring">
Restore from my Nostr account
</button>
</div>
<p id="storageSyncStatus" class="text-xs" role="status" aria-live="polite"></p>
</div>
</div>
</div>
</section>
<section
id="profilePaneMyVideos"
class="profile-pane hidden space-y-4"
role="tabpanel"
aria-labelledby="profileNavMyVideos"
>
<p class="text-sm text-muted">
Every video you’ve published, with health checks so you can spot
broken or orphaned items and keep your library and storage tidy.
</p>
<div class="flex items-center justify-between gap-3">
<h3 class="text-base font-semibold text-text">My Videos</h3>
<button
id="profileMyVideosRefreshBtn"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
aria-label="Refresh my videos"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
</button>
</div>
<p
id="profileMyVideosSummary"
class="text-sm text-muted"
role="status"
aria-live="polite"
></p>
<label id="profileMyVideosAutoShareRow" class="hidden flex items-start gap-2 text-sm text-muted-strong">
<input id="profileMyVideosAutoShare" type="checkbox" class="checkbox mt-0.5" />
<span>Auto-share new public videos to other Nostr apps (NIP-71)</span>
</label>
<p
id="profileMyVideosLoading"
class="text-sm text-muted hidden"
role="status"
aria-live="polite"
>
Loading your videos…
</p>
<div
id="profileMyVideosEmpty"
class="card border border-dashed border-border/60 p-6 text-center text-sm text-muted hidden"
>
You haven’t published any videos yet.
</div>
<ul
id="profileMyVideosList"
class="grid-stack"
data-orientation="vertical"
></ul>
<div id="profileMyVideosOrphans" class="space-y-2 hidden">
<div class="flex items-center justify-between gap-3">
<h4 class="text-sm font-semibold text-text">
Orphaned storage objects
</h4>
<button
id="profileMyVideosOrphanDeleteAll"
type="button"
class="btn-ghost focus-ring text-xs text-status-danger"
>
Delete all
</button>
</div>
<p class="text-2xs text-muted">
Files in your bucket that no current video references — safe to
remove to reclaim space.
</p>
<ul
id="profileMyVideosOrphanList"
class="grid-stack"
data-orientation="vertical"
></ul>
</div>
</section>
<section
id="profilePanePlaylists"
class="profile-pane hidden space-y-4"
role="tabpanel"
aria-labelledby="profileNavPlaylists"
>
<p class="text-sm text-muted">
Manage your playlists — rename them, reorder or remove videos, or
delete a playlist. Add videos to a playlist from any video’s ⋯
menu.
</p>
<div class="flex items-center justify-between gap-3">
<h3 class="text-base font-semibold text-text">Playlists</h3>
<button
id="profilePlaylistsRefreshBtn"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
aria-label="Refresh playlists"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
></path>
<path d="M3 3v5h5"></path>
<path
d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"
></path>
<path d="M16 21h5v-5"></path>
</svg>
</button>
</div>
<p
id="profilePlaylistsLoading"
class="text-sm text-muted hidden"
role="status"
aria-live="polite"
>
Loading your playlists…
</p>
<p id="profilePlaylistsEmpty" class="text-sm text-muted hidden">
You haven’t made any playlists yet. Open any video’s ⋯ menu and
choose “Add to playlist” to start one.
</p>
<ul
id="profilePlaylistsList"
class="grid-stack"
data-orientation="vertical"
></ul>
</section>
<section
id="profilePaneHashtags"
class="profile-pane hidden space-y-6"
@@ -1298,7 +927,6 @@ https://another.blossom.example"></textarea>
</p>
<p
id="profileHashtagStatus"
data-testid="hashtags-sync-status"
class="text-sm text-muted"
role="status"
aria-live="polite"
@@ -1425,7 +1053,6 @@ https://another.blossom.example"></textarea>
</p>
<p
id="subscriptionsStatus"
data-testid="subscriptions-sync-status"
class="text-sm text-muted"
role="status"
aria-live="polite"
@@ -1675,56 +1302,6 @@ https://another.blossom.example"></textarea>
data-orientation="vertical"
></ul>
</div>
<div class="space-y-3 border-t border-border/60 pt-4">
<div class="space-y-1">
<h4 class="text-sm font-semibold text-text">
Trusted creators
</h4>
<p class="text-xs text-muted">
Turn off web-of-trust warnings for a creator across all
their videos. They stay listed here until you reset them.
</p>
</div>
<div class="flex flex-wrap items-end gap-2">
<label class="min-w-0 flex-1 space-y-1">
<span class="text-xs font-medium text-muted"
>Creator npub</span
>
<input
id="profileAuthorOverrideInput"
type="text"
class="form-control"
placeholder="npub1…"
autocomplete="off"
spellcheck="false"
/>
</label>
<button
id="profileAuthorOverrideAddBtn"
type="button"
class="btn focus-ring"
>
Always show
</button>
</div>
<p
id="profileAuthorOverrideStatus"
class="text-xs text-muted"
role="status"
aria-live="polite"
></p>
<div
id="profileAuthorOverridesEmpty"
class="card border border-dashed border-border/60 p-4 text-center text-xs text-muted"
>
No trusted creators yet.
</div>
<ul
id="profileAuthorOverridesList"
class="grid-stack hidden"
data-orientation="vertical"
></ul>
</div>
</div>
<div id="profileLinkPreviewSettings" class="card space-y-4 p-5">
@@ -1762,7 +1339,6 @@ https://another.blossom.example"></textarea>
<h3 class="text-base font-semibold text-text">Blocked creators</h3>
<button
id="blockedRefreshBtn"
data-testid="blocked-refresh-button"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
@@ -1793,17 +1369,11 @@ https://another.blossom.example"></textarea>
</div>
<div
id="blockedEmpty"
data-testid="blocked-empty-state"
class="card border border-dashed border-border/60 p-6 text-center text-sm text-muted"
>
You haven’t blocked any creators yet.
</div>
<ul
id="blockedList"
data-testid="blocked-list"
class="grid-stack"
data-orientation="vertical"
></ul>
<ul id="blockedList" class="grid-stack" data-orientation="vertical"></ul>
</section>
<section
id="profilePaneMessages"
@@ -1944,135 +1514,6 @@ https://another.blossom.example"></textarea>
</p>
</div>
<div
id="adminSubtabBar"
class="flex flex-wrap gap-2 border-b border-border/60 pb-3"
role="tablist"
aria-label="Admin tools"
>
<button
id="adminSubtabWhitelist"
type="button"
role="tab"
data-admin-subtab="whitelist"
aria-controls="adminWhitelistSection"
aria-selected="true"
data-state="active"
class="btn-ghost focus-ring px-3 py-1.5 text-sm font-medium"
>
Whitelist
</button>
<button
id="adminSubtabBlacklist"
type="button"
role="tab"
data-admin-subtab="blacklist"
aria-controls="adminBlacklistSection"
aria-selected="false"
class="btn-ghost focus-ring px-3 py-1.5 text-sm font-medium"
>
Blacklist
</button>
<button
id="adminSubtabBlockedVideos"
type="button"
role="tab"
data-admin-subtab="blockedVideos"
aria-controls="adminBlockedVideosSection"
aria-selected="false"
class="btn-ghost focus-ring px-3 py-1.5 text-sm font-medium"
>
Blocked videos
</button>
<button
id="adminSubtabModerators"
type="button"
role="tab"
data-admin-subtab="moderators"
aria-controls="adminModeratorsSection"
aria-selected="false"
class="btn-ghost focus-ring px-3 py-1.5 text-sm font-medium hidden"
>
Moderators
</button>
<button
id="adminSubtabSubmissions"
type="button"
role="tab"
data-admin-subtab="submissions"
aria-controls="adminSubmissionsSection"
aria-selected="false"
class="admin-subtab-has-dot btn-ghost focus-ring px-3 py-1.5 text-sm font-medium hidden"
>
Submissions
<span
id="adminSubtabSubmissionsDot"
class="admin-subtab-dot"
aria-hidden="true"
></span>
</button>
</div>
<div
id="adminSubmissionsSection"
class="space-y-4 hidden"
aria-hidden="true"
>
<div class="flex items-center justify-between gap-3">
<div>
<h4 class="text-base font-semibold text-text">Submissions</h4>
<p class="text-2xs text-muted">
Applications, appeals, and reports from the public forms.
Approve an application to add it to the whitelist.
</p>
</div>
<button
id="adminSubmissionsRefreshBtn"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
aria-label="Refresh submissions"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
></path>
<path d="M3 3v5h5"></path>
<path
d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"
></path>
<path d="M16 21h5v-5"></path>
</svg>
</button>
</div>
<p
id="adminSubmissionsLoading"
class="text-sm text-muted hidden"
role="status"
aria-live="polite"
>
Loading submissions…
</p>
<p id="adminSubmissionsEmpty" class="text-sm text-muted hidden">
No pending submissions.
</p>
<ul
id="adminSubmissionsList"
class="grid-stack"
data-orientation="vertical"
></ul>
</div>
<div id="adminModeratorsSection" class="space-y-4 hidden" aria-hidden="true">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
@@ -2170,7 +1611,7 @@ https://another.blossom.example"></textarea>
<ul id="adminWhitelistList" class="grid-stack" data-orientation="vertical"></ul>
</div>
<div id="adminBlacklistSection" class="space-y-4 hidden" aria-hidden="true">
<div id="adminBlacklistSection" class="space-y-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
<h4 class="text-base font-semibold text-text">Blacklist</h4>
@@ -2216,53 +1657,6 @@ https://another.blossom.example"></textarea>
</div>
<ul id="adminBlacklistList" class="grid-stack" data-orientation="vertical"></ul>
</div>
<div id="adminBlockedVideosSection" class="space-y-4 hidden" aria-hidden="true">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
<h4 class="text-base font-semibold text-text">Blocked videos</h4>
<button
id="adminBlockedVideosRefreshBtn"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
aria-label="Refresh blocked videos"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
</button>
</div>
</div>
<p class="text-sm text-muted">
Individual videos hidden everywhere on bitvid without blocking their creator. Block a video from its ⋯ menu, or paste an event id / nevent below.
</p>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label class="flex-1 text-sm text-muted-strong" for="adminBlockedVideoInput">
<span class="mb-2 block font-medium">Event id or nevent</span>
<input
id="adminBlockedVideoInput"
type="text"
inputmode="text"
autocomplete="off"
class="input"
placeholder="nevent1… or hex event id"
/>
</label>
<button
id="adminAddBlockedVideoBtn"
type="button"
class="btn focus-ring"
>
Block video
</button>
</div>
<div
id="adminBlockedVideosEmpty"
class="card border border-dashed border-border/60 p-4 text-center text-sm text-muted"
>
No videos are individually blocked yet.
</div>
<ul id="adminBlockedVideosList" class="grid-stack" data-orientation="vertical"></ul>
</div>
</section>
</div>
</div>
@@ -2270,22 +1664,6 @@ https://another.blossom.example"></textarea>
<!-- Modal Footer -->
<div class="hidden lg:flex items-center justify-end gap-3 border-t border-border/60 p-4">
<button
id="profileTakeTourBtn"
type="button"
class="btn-ghost focus-ring"
title="Replay the quick guided tour of bitvid"
>
Take the tour
</button>
<button
id="profileLockNowBtn"
type="button"
class="btn-ghost focus-ring hidden"
title="Forget your unlocked key on this device and re-lock storage + wallet now"
>
Lock this device
</button>
<button id="profileLogoutBtn" class="btn focus-ring" data-variant="danger">
Logout
</button>
@@ -2293,75 +1671,3 @@ https://another.blossom.example"></textarea>
</div>
</div>
</div>
<!-- Storage CORS setup helper (B2 / S3-compatible). Shown on demand from the
Storage pane; renders above the profile modal via modal-always-on-top. -->
<div
id="storageCorsModal"
class="bv-modal modal-always-on-top hidden items-start justify-center md:items-center"
role="dialog"
aria-modal="true"
aria-labelledby="storageCorsModalTitle"
aria-describedby="storageCorsModalIntro"
data-ds="new"
>
<div class="bv-modal-backdrop" data-storage-cors-dismiss></div>
<div class="modal-sheet max-h-modal-shell overflow-hidden flex flex-col w-full max-w-2xl" tabindex="-1">
<div class="modal-header">
<h2 id="storageCorsModalTitle" class="text-xl font-bold text-text">
Allow browser uploads (bucket CORS)
</h2>
<button
id="storageCorsModalClose"
class="modal-close"
type="button"
aria-label="Close CORS setup help"
data-storage-cors-dismiss
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="modal-body overflow-y-auto p-5 space-y-4 text-sm text-muted-strong">
<p id="storageCorsModalIntro">
Uploading from your browser uses an S3 <code>PUT</code>/<code>POST</code>,
which your bucket must allow via CORS.
</p>
<div>
<div class="mb-1 flex items-center justify-between gap-2">
<span class="font-medium text-text">CORS rules JSON</span>
<button id="storageCorsCopyJsonBtn" type="button" class="btn-ghost focus-ring text-xs">
Copy JSON
</button>
</div>
<pre class="card border border-default bg-surface-strong/40 p-3 overflow-x-auto text-xs leading-relaxed"><code id="storageCorsJson" class="whitespace-pre text-text"></code></pre>
<p class="mt-1 text-xs text-muted">
Origins are pre-filled from this site. Add your other bitvid domains, or use
<code>["*"]</code> to allow every origin.
</p>
</div>
<div>
<div class="mb-1 flex items-center justify-between gap-2">
<span id="storageCorsCmdLabel" class="font-medium text-text">Apply it</span>
<button id="storageCorsCopyCmdBtn" type="button" class="btn-ghost focus-ring text-xs">
Copy command
</button>
</div>
<pre class="card border border-default bg-surface-strong/40 p-3 overflow-x-auto text-xs leading-relaxed"><code id="storageCorsCli" class="whitespace-pre text-text"></code></pre>
</div>
<p id="storageCorsNotes" class="whitespace-pre-line text-xs text-muted"></p>
</div>
<div class="modal-footer flex items-center justify-end gap-3 border-t border-border/60 p-4">
<button id="storageCorsDoneBtn" type="button" class="btn focus-ring" data-storage-cors-dismiss>
Done
</button>
</div>
</div>
</div>
+13 -43
View File
@@ -54,49 +54,6 @@
/>
<span class="sidebar-text">For You</span>
</a>
<a
id="trendingLink"
href="#view=trending"
class="sidebar-nav-link"
title="Trending"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="sidebar-icon"
aria-hidden="true"
>
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline>
<polyline points="17 6 23 6 23 12"></polyline>
</svg>
<span class="sidebar-text">Trending</span>
</a>
<a
id="mostZappedLink"
href="#view=most-zapped"
class="sidebar-nav-link"
title="Most Zapped"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="sidebar-icon"
aria-hidden="true"
>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
</svg>
<span class="sidebar-text">Most Zapped</span>
</a>
<a href="#view=most-recent-videos" class="sidebar-nav-link" title="Recently Added">
<img
src="assets/svg/globe-icon.svg"
@@ -294,6 +251,19 @@
/>
<span class="sidebar-text">βeacon</span>
</a>
<a
href="#view=dev/agent-dashboard"
class="sidebar-nav-link sidebar-nav-link--sub"
title="Dev Tools"
>
<img
src="assets/svg/video-settings-gear.svg"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">Dev Tools</span>
</a>
<a
href="#"
data-beta-link
+48 -17
View File
@@ -153,11 +153,6 @@
<button type="button" id="btn-manage-storage" class="text-xs text-muted hover:text-text hover:underline">Manage</button>
</div>
</div>
<!-- #44: switch upload destination without leaving the modal -->
<div id="storage-connection-picker" class="hidden space-y-1">
<label for="select-storage-connection" class="text-xs text-muted">Upload destination</label>
<select id="select-storage-connection" class="input input-sm text-xs"></select>
</div>
<div class="grid grid-cols-3 gap-2 text-xs">
<div class="min-w-0">
<span class="block text-muted">Provider</span>
@@ -254,10 +249,6 @@
<!-- Hashtags -->
<div class="space-y-2">
<label class="block text-sm font-medium text-text">Hashtags</label>
<div id="hashtag-suggestions-wrap" class="space-y-1 hidden">
<p class="text-xs text-muted">Reuse a tag from your videos:</p>
<div id="hashtag-suggestions" class="flex flex-wrap gap-2"></div>
</div>
<div
class="rounded-lg border border-border/70 bg-surface/30 p-3 space-y-3"
data-nip71-repeater="t"
@@ -335,18 +326,58 @@
<div id="section-advanced" class="hidden mt-4 space-y-6 pl-2 border-l-2 border-border/30">
<!-- The old "NIP-71 Technical" inputs (content warning, duration,
summary, IMETA variants) were removed for a cleaner UI: they fed
the disabled FEATURE_PUBLISH_NIP71 path, and everything the NIP-71
mirror needs (duration/dimensions, content warning from the NSFW
toggle, the primary imeta) is derived automatically. -->
<!-- NIP-71 Technical -->
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Content Warning</label>
<input id="input-content-warning" type="text" data-nip71-input="content-warning" class="input text-sm" placeholder="e.g. Nudity" />
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Duration (sec)</label>
<input id="input-duration" type="number" step="1" data-nip71-input="duration" class="input text-sm" />
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="block text-xs font-medium text-muted-strong">Summary (NIP-71 Content)</label>
<label class="flex items-center gap-2 text-xs text-muted cursor-pointer">
<input type="checkbox" id="check-summary-unlock" class="checkbox h-3 w-3" />
Customize
</label>
</div>
<textarea id="input-summary" rows="2" data-nip71-input="summary" class="textarea text-sm text-muted" readonly></textarea>
</div>
<!-- IMETA Editor -->
<div class="space-y-3" data-nip71-repeater="imeta">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-text">Video Variants (IMETA)</h4>
<button type="button" class="btn-ghost px-2 py-1 text-xs" data-nip71-add="imeta">Add Variant</button>
</div>
<div id="imeta-list" class="space-y-4" data-nip71-list="imeta"></div>
<!-- IMETA Template -->
<template data-nip71-template="imeta">
<article class="rounded-md border border-border/70 bg-surface/50 p-3 space-y-3" data-nip71-entry="imeta">
<div class="grid gap-2 md:grid-cols-2 text-xs">
<input type="text" data-nip71-field="m" placeholder="MIME (video/mp4)" class="input" />
<input type="text" data-nip71-field="dim" placeholder="Dimensions (1920x1080)" class="input" />
<input type="url" data-nip71-field="url" placeholder="URL" class="input md:col-span-2" />
<input type="text" data-nip71-field="x" placeholder="Magnet" class="input md:col-span-2" />
</div>
<div class="flex justify-end">
<button type="button" class="text-xs text-critical hover:underline" data-nip71-remove="imeta">Remove Variant</button>
</div>
</article>
</template>
</div>
<!-- Additional Technical Sections (Web Seeds, etc) -->
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Web seeds (one per line)</label>
<textarea id="input-ws" rows="2" class="textarea text-sm" placeholder="https://cdn.example.com/video.mp4&#10;https://backup.example.com/video.mp4"></textarea>
<p class="text-xs text-muted">Add a backup origin so the swarm can keep streaming if the primary is overloaded. Each must serve the exact same file.</p>
<label class="block text-xs font-medium text-muted-strong">Web Seed (ws)</label>
<input id="input-ws" type="url" class="input text-sm" placeholder="https://..." />
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Torrent File (xs)</label>
-10
View File
@@ -211,16 +211,6 @@
<span data-view-count-text>–</span>
</span>
</div>
<span
id="videoZapTotal"
class="modal-meta video-modal__zaps flex items-center gap-1.5 hidden"
title="Zap total (sats)"
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
</svg>
<span data-zap-total-text></span>
</span>
</div>
<div class="video-modal__actions-row">
+4 -106
View File
@@ -191,10 +191,7 @@ export const PLATFORM_FEE_PERCENT = 30;
* Super Admin profile publishing a `lud16` so bitvid still knows where to route
* fees when they are enabled.
*/
// coinos: Nostr-native, publishes NIP-57 zap receipts, reliable routing,
// 1-sat minimum. Replaced [email protected], whose LNURL was flaky and never
// published receipts, causing platform-share pay timeouts + missing zap tallies.
export const PLATFORM_LUD16_OVERRIDE = "[email protected]";
export const PLATFORM_LUD16_OVERRIDE = "[email protected]";
/**
* Optional list of relays to seed new sessions with instead of the defaults.
@@ -237,105 +234,6 @@ export const DEFAULT_WHITELIST_MODE_ENABLED = true;
*/
export const ALLOW_NSFW_CONTENT = false;
/**
* Whether the client ingests NIP-71 video events published by *other* Nostr
* apps (kinds 21/22 and addressable 34235/34236) and surfaces them in bitvid's
* feeds.
*
* Defaults to `true` so bitvid discovers videos from the wider Nostr ecosystem
* (Nostube, Flotilla, etc.). bitvid's own outbound mirrors are skipped to avoid
* duplicate listings, and ingested NSFW content still respects
* `ALLOW_NSFW_CONTENT` plus the standard trust/blacklist/mute gating.
*
* Set to `false` to disable all inbound NIP-71 ingest with a single switch if it
* ever causes problems (spam, low-quality content, performance).
*/
export const FEATURE_NIP71_INGEST = true;
/**
* Whether this deployment enables the Audio / Music / Podcast experience
* (dedicated Audio player, sidebar tab, and the channel / "My Content" Audio
* tabs — see docs/audio-integration-plan.md and TODO #60).
*
* Defaults to `false` — "off = no trace": with it off, nothing audio renders and
* audio-only notes stay filtered out of the video feeds (current behavior). Flip
* to `true` to light up the Audio surfaces. Watch/ingest-first; a separate
* publish flag will gate uploading audio later.
*/
export const FEATURE_AUDIO_INGEST = false;
/**
* Whether this deployment shows the "Connect wallet" button that uses Alby's
* Bitcoin Connect for a smooth NWC connect flow (docs/bitcoin-connect-plan.md,
* TODO #61). When on, the Wallet Connect pane offers Bitcoin Connect alongside
* the manual URI field (which stays as the backup). It only lazy-loads the
* vendored Bitcoin Connect bundle when the user actually clicks Connect.
*
* SHIPPING DEFAULT should be `false` ("off = no trace"). Currently `true` for
* local testing only — reset to `false` before pushing/shipping until verified.
*/
export const FEATURE_BITCOIN_CONNECT = true;
/**
* Whether this deployment offers Blossom (nostr-native blob storage) as a
* first-class upload provider alongside Cloudflare R2 / generic S3 / Backblaze B2
* (docs/blossom-plan.md, TODO #30). Blossom uploads authorize with the user's
* existing nostr signer (kind-24242 auth events) instead of S3 access keys, and
* mirror to multiple servers for resilience.
*
* Defaults to `false` ("off = no trace"): with it off the Storage pane shows only
* R2 / S3 / B2 as today and the vendored Blossom SDK is never imported. Flip to
* `true` to expose the Blossom provider.
*
* Currently `true` for LOCAL testing of Phase 1b — reset to `false` (shipping
* default) before pushing/promoting until the upload flow is verified.
*/
export const FEATURE_BLOSSOM_STORAGE = true;
/**
* FEATURE_BLOSSOM_TORRENT_METADATA — restore WebTorrent/P2P for Blossom-hosted
* videos without hosting a `.torrent` file. Blossom servers reject a `.torrent`
* (415), so a Blossom video's magnet ships webseed-only (no `xs=`) and can't
* bootstrap WebTorrent. With this on, bitvid publishes the torrent piece-map as a
* separate, infohash-keyed Nostr event (kind 30078, `d=bitvid:torrent:<infohash>`)
* and fetches it lazily only when the torrent path actually runs.
* See docs/blossom-torrent-metadata-plan.md.
*
* Defaults to `false` ("off = no trace"): off ⇒ no companion event is published
* and the playback lookup is skipped (Blossom stays URL-only, exactly as today).
*
* Currently `true` for LOCAL testing of the companion-event flow — reset to
* `false` (shipping default) before pushing/promoting.
*/
export const FEATURE_BLOSSOM_TORRENT_METADATA = true;
/**
* Card liveness visibility policy — what a non-owner video card does while its
* CDN/WebTorrent liveness probes are still running:
* - "show-pending" : show the card immediately, hide only if every source is
* confirmed dead (current behavior; can briefly flash a
* dead foreign card before the probe hides it).
* - "hide-foreign" : foreign/ingested cards stay hidden until a source is
* confirmed playable; bitvid-native/own cards show pending.
* - "hide-all" : every non-owner card stays hidden until confirmed playable.
* Owner's own uploads are always visible regardless of policy.
*
* Can be flipped live for A/B feel-testing without a rebuild by setting
* `window.__BITVID_CARD_LIVENESS_POLICY__ = "hide-foreign"` in the console (then
* refresh / scroll the grid). Invalid values fall back to "show-pending".
*/
export const CARD_LIVENESS_POLICY = "hide-foreign";
/**
* IntersectionObserver `rootMargin` for the card liveness probes (URL + WebTorrent).
* "0px" probes a card only once it touches the viewport; a positive margin (e.g.
* "600px") probes cards that distance *below* the fold so they're verified before
* the user scrolls to them — makes hide-until-verified feel instant. Probes stay
* viewport-gated + concurrency-capped, so a larger margin only reorders work.
* Override live with `window.__BITVID_LIVENESS_PREFETCH_MARGIN__`.
*/
export const LIVENESS_PROBE_PREFETCH_MARGIN = "600px";
/**
* Emergency fallback accounts for seeding the moderation graph.
*
@@ -359,7 +257,7 @@ 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 targets 3 trusted reports before blurring.
*/
export const DEFAULT_BLUR_THRESHOLD = 1;
@@ -369,7 +267,7 @@ 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.
* targets 2 trusted reports before blocking autoplay.
*/
export const DEFAULT_AUTOPLAY_BLOCK_THRESHOLD = 1;
@@ -388,7 +286,7 @@ 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 — 3 trusted reports is the upstream
* baseline.
*/
export const DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD = 1;
-20
View File
@@ -1,20 +0,0 @@
# Test Audit Context - 2025-02-18
## Mission
To audit the test suite for behavior fidelity, determinism, and cheat vectors. The goal is to build a daily test-audit workflow that identifies flaky tests, suspicious patterns, and coverage gaps in critical modules.
## Scope
- Unit tests (`tests/` excluding `visual/` and `e2e/`).
- Critical modules:
- `js/services/authService.js`
- `js/relayManager.js`
- `js/nostr/dmDecryptWorker.js`
- `js/nostr/watchHistory.js`
- `js/userBlocks.js`
## Definition of Done
- `test-audit-report-YYYY-MM-DD.md` generated.
- `test-audit/flakiness-matrix.json` generated.
- `test-audit/suspicious-tests.json` generated.
- `test-audit/coverage-gaps.json` generated.
- Prioritized remediation tasks identified.
-20
View File
@@ -1,20 +0,0 @@
# Context - Perf Agent - 2026-02-17
## Goal
Improve application responsiveness by bounding concurrency in background network tasks, specifically for DM signal events and DM service operations.
## Scope
- `js/nostr/dmSignalEvents.js`: Fix unbounded `Promise.all` in `publishDmReadReceipt` and `publishDmTypingIndicator`.
- `js/services/dmNostrService.js`: Fix unbounded `Promise.all` in `connect` and `publishSignedEvent`.
- `content/docs/guides/upload-content.md`: Fix documentation port number (5500 -> 3000).
## Assumptions
- `pMap` utility works as expected.
- `RELAY_BACKGROUND_CONCURRENCY` (3) is an appropriate limit for these operations.
## Definition of Done
- All `Promise.all` usages in targeted functions are replaced with `pMap` with concurrency limits.
- `npm run lint` passes.
- `npm run test:dm:unit` passes.
- `npm run test:dm:integration` passes.
- `content/docs/guides/upload-content.md` is updated.
+4
View File
@@ -0,0 +1,4 @@
# Context
- Goal: Sync scheduler rosters with prompt directories.
- Scope: `docs/agents/prompts/daily/` and `docs/agents/prompts/weekly/`.
- Status: Initial check complete. Rosters match files on disk.
+8
View File
@@ -0,0 +1,8 @@
# Daily Test Audit Context
Goal: Run daily test audit to detect flaky tests, coverage gaps, and suspicious patterns.
Scope: All unit tests in `tests/`.
Definition of Done:
- `test-audit-report-YYYY-MM-DD.md` generated.
- `test-audit/` artifacts populated.
- Tests run at least 3 times.
-20
View File
@@ -1,20 +0,0 @@
# Context: bitvid-perf-agent (Daily Run)
## Goal
Daily, measurable improvement of app responsiveness by finding and fixing background CPU/network work that degrades UX.
## Scope
- Focus on P0 (Login/auth blocking), P1 (Initial UI responsiveness), P2 (Heavy user features).
- Docs audit: Ensure `/content` upload docs match reality.
- Safe, incremental changes only.
## Assumptions
- Branch is `unstable`.
- We are in a simulated agent environment.
## Definition of Done
- Performance issues identified and logged.
- At least one safe fix implemented (if applicable).
- Docs audited.
- `daily-perf-report` generated.
- All changes verified and linted.
-14
View File
@@ -1,14 +0,0 @@
# Context: NIP Research Agent Run (2026-02-18)
## Goal
Execute the daily `nip-research-agent` task to research NIPs, verify compliance, and update the inventory.
## Scope
- Focus on NIP-65 / Kind 10002 compliance verification.
- Implement missing tests for `relayManager.js`.
- Update `NIP_INVENTORY.md`.
## Definition of Done
- `tests/nostr-specs/nip65_compliance.test.mjs` created and passing.
- `NIP_INVENTORY.md` updated.
- Completion log submitted.
-8
View File
@@ -1,8 +0,0 @@
# Context: Content Audit Agent (2026-02-19_18-55-56)
Goal: Make the public-facing help, guides and contribution docs in /content true, actionable, and executable — focusing especially on the uploading/contribution flows.
Scope:
- Docs: /content
- Code: js/services/s3UploadService.js, js/ui/initUploadModal.js, components/upload-modal.html
- Config: config/instance-config.js, js/constants.js
-29
View File
@@ -1,29 +0,0 @@
# Decomposition Context
- **Task:** Decompose `js/ui/profileModalController.js` (6258 lines).
- **Reason:** Largest grandfathered file, highly coupled UI logic.
- **Goal:** Extract "Block List" and "Edit Profile" logic into separate controllers to reduce line count by >200 lines and improve maintainability.
- **Target Branch:** `unstable` (via `agents/daily/...` branch).
- **Plan:**
1. Extract `ProfileBlockListController` to manage blocked users list, add/remove blocks.
2. Extract `ProfileEditController` to manage profile editing form, uploads, and saving.
3. Update `ProfileModalController` to delegate to these new controllers.
## Selected Blocks
1. **Block List Management**:
- `populateBlockedList`
- `handleAddBlockedCreator`
- `handleRemoveBlockedCreator`
- `ensureBlockListStatusElement`
- `setBlockListLoadingState`
- `mutateBlocklist`
- Event listeners and DOM cache for block list elements.
2. **Edit Profile Management**:
- `handleEditProfile`
- `populateEditPane`
- `checkStorageForUploads`
- `handleUpload`
- `handleSaveProfile`
- Event listeners and DOM cache for edit profile elements.
@@ -1,18 +0,0 @@
# Audit Context: Content Audit Agent
**Date:** 2026-02-19
**Agent:** content-audit-agent
**Branch:** agents/daily/content-audit-agent
## Goal
Make the public-facing help, guides, and contribution docs in `/content` true, actionable, and executable — focusing especially on the uploading/contribution flows.
## Scope
- Docs: `content/docs/guides/upload-content.md` and related files.
- Code: `js/services/s3UploadService.js`, `js/ui/components/UploadModal.js`, `components/upload-modal.html`.
## Plan
1. Inventory claims from docs.
2. Verify claims against code.
3. Update docs if necessary.
4. Validate changes.
@@ -1,5 +0,0 @@
# Context
Date: Fri Feb 20 14:39:21 UTC 2026
Package Manager: npm
Node: v22.22.0
CI Matrix: Standard
-14
View File
@@ -1,14 +0,0 @@
# Documentation Plan for js/services/playbackService.js
**File:** `js/services/playbackService.js`
**Lines:** ~650
**Reason:** Critical component for "Hybrid Playback Strategy", complex state machine (`PlaybackSession`), and fallback logic. Contains long methods like `execute()` that need flow documentation.
**Plan:**
1. **Analyze**: Understand `PlaybackService` and `PlaybackSession` interactions.
2. **Document**:
* Add JSDoc to `PlaybackService` methods (`probeHostedUrl`, `registerUrlPlaybackWatchdogs`, etc.).
* Add JSDoc to `PlaybackSession` methods (`start`, `execute`).
* Add flow comments to `execute()` explaining the fallback sequence.
3. **Overview**: Create `docs/playbackService-overview.md` with sequence diagrams (text) and usage examples.
4. **Verify**: Run `npm run lint` and `npm run test:unit`.
@@ -1,73 +0,0 @@
# Decomposition Plan for `js/ui/components/VideoModal.js`
**Date:** 2026-02-20
**Agent:** decompose-agent (via scheduler)
**Target File:** `js/ui/components/VideoModal.js`
**Current Size:** ~6000 lines (grandfathered)
## Rationale
`VideoModal.js` is one of the largest files in the codebase. It handles too many responsibilities beyond just managing the modal lifecycle and video playback. Specifically, it contains extensive logic for:
1. **Zap Dialog Management**: Handling the entire flow of zapping, including popover, form validation, receipts, and error handling.
2. **Link Previews**: Fetching and rendering link previews for the video description.
Extracting these into dedicated controllers will significantly reduce the size of `VideoModal.js` and improve cohesion.
## Extraction Plan
### 1. `ZapController`
**Source:** `js/ui/components/VideoModal.js`
**Destination:** `js/ui/components/video-modal/zapController.js`
**Responsibilities:**
- Manage the Zap dialog DOM elements (`modalZapDialog`, `modalZapForm`, etc.).
- Handle Zap interactions (open, close, submit).
- Render Zap receipts.
- Manage Zap visibility and state.
**Methods to Move:**
- `setupModalZapPopover`
- `openZapDialog`
- `closeZapDialog`
- `isZapDialogOpen`
- `focusZapAmount`
- `getZapAmountValue`, `setZapAmount`
- `getZapCommentValue`, `setZapComment`
- `resetZapForm`
- `setZapSplitSummary`
- `setZapStatus`
- `clearZapReceipts`, `renderZapReceipts`
- `setZapPending`, `setZapRetryPending`, `setZapCompleted`
- `applyZapSendButtonState`
- `setZapVisibility`
- `setWalletPromptVisible`
### 2. `LinkPreviewController`
**Source:** `js/ui/components/VideoModal.js`
**Destination:** `js/ui/components/video-modal/linkPreviewController.js`
**Responsibilities:**
- Manage link preview DOM elements (`videoDescriptionPreviews`).
- Fetch and render link previews.
- Handle link preview settings.
**Methods to Move:**
- `renderLinkPreviews`
- `clearLinkPreviews`, `clearLinkPreviewRequests`
- `extractDescriptionUrls`
- `normalizePreviewUrl`, `extractPreviewDomain`
- `createLinkPreviewCard`, `updateLinkPreviewCard`
- `setLinkPreviewStatus`
- `resolveLinkPreview`
- `handleLinkPreviewSettingsChange`
## Implementation Details
- `VideoModal` will instantiate these controllers in its constructor.
- `hydrate()` will call `controller.initialize()`.
- `destroy()` will call `controller.destroy()`.
- `VideoModal` will delegate relevant calls to these controllers.
- Existing event listeners in `VideoModal` that trigger these actions will be updated to use the controllers.
## Verification
- `npm run lint`
- `npm run test:unit`
- Manual verification via code review (simulated).
@@ -1,22 +0,0 @@
# Context: Documentation for `js/nostrEventSchemas.js`
**Agent**: docs-code-investigator
**Date**: 2026-02-20
**Selected File**: `js/nostrEventSchemas.js`
## Selection Reason
This file is a critical core module that defines the Nostr event schemas, Kinds, and helper functions for building events throughout the application. It is large (approx. 3000 LOC) and currently lacks a dedicated overview document in `docs/`. Documenting this file will provide significant value for understanding the protocol layer of the application.
## Plan
1. **Analyze**: Read the file to understand its exports, constants, and builder functions.
2. **Overview Document**: Create `docs/nostrEventSchemas-overview.md` to explain:
* The file's role as the central schema definition.
* Key constants (`NOTE_TYPES`, `KINDS`).
* Validation logic.
* How to use the builder functions.
3. **In-Code Documentation**:
* Add a top-level file comment explaining the module's purpose.
* Add JSDoc comments to exported functions and constants to improve IDE support and code readability.
4. **Verification**:
* Run `npm run lint` to ensure style compliance.
* Run `npm run test:unit` to ensure no regressions.
-17
View File
@@ -1,17 +0,0 @@
# Context: innerHTML Migration for ShareNostrModal.js
**Date:** 2026-02-20
**Agent:** bitvid-innerhtml-migration-agent (via Jules)
**Target File:** `js/ui/components/ShareNostrModal.js`
**Reason:** High count (3 assignments). Contains static SVG injection and template loading.
**Environment:**
- Node: v22.22.0
- npm: 11.7.0
**Plan:**
1. Analyze `innerHTML` assignments.
2. Replace `wrapper.innerHTML = html` with `DOMParser` or similar safe parsing.
3. Replace `this.relayPills.innerHTML = ""` with `replaceChildren()`.
4. Replace `removeButton.innerHTML = SVG` with `createElementNS` or safe SVG construction.
5. Verify with lint and tests.
@@ -1,19 +0,0 @@
# Context: NIP Research Agent Run (2026-02-21)
**Agent:** bitvid-nip-research-agent
**Date:** 2026-02-21
**Goal:** Research and verify compliance for NIP-09 (Deletion), NIP-21 (URI Scheme), and NIP-42 (Auth). Update NIP Inventory and generate report.
**Scope:**
- Verify `js/nostr/client.js` for NIP-09 and NIP-42 compliance.
- Verify `js/utils/nostrHelpers.js` for NIP-21 compliance.
- Fetch missing NIP specs (09, 21, 42).
- Update `NIP_INVENTORY.md`.
- Generate `nip-report-2026-02-21.md`.
**Definition of Done:**
- Context/Todo/Decisions/TestLog files created.
- NIP specs downloaded to `artifacts/nips/`.
- NIP-09, NIP-21, NIP-42 status updated in `NIP_INVENTORY.md` based on code analysis.
- `nip-report-2026-02-21.md` created/updated.
- `npm run lint` passes.
@@ -1 +0,0 @@
Context for audit-agent run on 2026-02-21
@@ -1,16 +0,0 @@
# Context: NIP-42 Research
**Agent:** nip-research-agent
**Date:** 2026-02-21
**Goal:** Research NIP-42 (Relay Authentication) compliance and update the NIP Inventory.
## Scope
- Fetch NIP-42 specification.
- Analyze `js/nostr/client.js` and other files for implementation of `AUTH` command.
- Update `NIP_INVENTORY.md` with findings.
- Generate daily report.
## Definition of Done
- `artifacts/nips/42.md` exists.
- `NIP_INVENTORY.md` updated with NIP-42 status.
- `nip-report-2026-02-21.md` created.
@@ -1,21 +0,0 @@
# Context: NIP Research Agent Run 2026-02-22
## Goal
Execute the daily NIP research task: inventory NIPs, map to code, validate compliance, and report findings.
## Scope
- Scan codebase for NIP/kind references.
- Update `NIP_INVENTORY.md`.
- Fetch missing NIP specs.
- Run validation checks (if applicable).
- Generate daily report.
## Timeline
- Start: 2026-02-22
- Duration: Single run
## Definition of Done
- Artifacts created.
- Inventory updated.
- Report generated.
- Lint passed.
-9
View File
@@ -1,9 +0,0 @@
# Context - Scheduler Update Agent (Completed)
**Date**: 2026-02-23_15-01-13
## Goal
Sync scheduler rosters with prompt files.
## Status
Completed. Rosters are in sync.
@@ -1,9 +0,0 @@
# Test Audit Report - 2026-02-23
This document tracks the execution of the daily test audit agent.
## Scope
- Run unit tests and capture logs.
- Detect flaky tests.
- Perform static analysis.
- Identify coverage gaps.
-4
View File
@@ -1,4 +0,0 @@
# Context: Scheduler Update Agent
- Date: 2026-02-23
- Task: Sync daily and weekly scheduler rosters with prompt files.
-13
View File
@@ -1,13 +0,0 @@
# Test Audit Context - 2026-02-23
## Mission
Daily audit of test suite health, flakiness, and coverage.
## Scope
- Unit tests
- Static analysis of test patterns
- Flakiness detection
## Definition of Done
- Report generated
- Critical issues logged
-7
View File
@@ -1,7 +0,0 @@
# Audit Agent Context
Target: `unstable` branch
Date: 2026-02-25
Purpose: Run daily audit scripts and report metrics.
Agent: audit-agent
Platform: jules
-20
View File
@@ -1,20 +0,0 @@
# Documentation Plan: js/nostr/nip71.js
## Selection
- **File**: `js/nostr/nip71.js`
- **Lines**: 1530
- **Reason**:
- Large Nostr helper file (>1500 LOC).
- Handles core domain logic for Video Events (NIP-71), which is critical for BitVid.
- Memory indicates it differs from other event builders (defined separately), making it a source of confusion.
- Likely contains complex logic for validation, parsing, and construction of video events.
- **Last Significant Commit**: f22253f5601669dc4a4475a0f350c59aa39bdc52 (2026-02-18) - touched by perf-agent but likely not fully documented.
## Plan
1. **Analyze**: Read the file to understand its exports, flows, and dependencies.
2. **Document**:
- Add file-level overview comment.
- Add JSDoc to all exported functions (builders, parsers, validators).
- Document internal helper functions where necessary.
- Create `docs/nip71-overview.md` if the logic is complex enough (likely yes, given 1.5k LOC).
3. **Verify**: Run `npm run test:unit` and `npm run lint`.
+6 -710
View File
@@ -252,61 +252,6 @@
display: block;
}
/* Admin "verified" star — sits in the corner opposite the notification dot.
A gold star on a page-colored disc so it stays legible on any avatar, and
pairs visually with the red dot. Positioned top-left by default (the dot is
top-right); per-surface rules below tune the inset for larger avatars. */
.admin-star {
position: absolute;
top: 0;
left: 0;
z-index: 3;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--space-sm);
height: var(--space-sm);
border-radius: var(--radius-full);
border: var(--border-width-hairline) solid var(--color-page);
background-color: var(--status-warning);
color: var(--color-warning-on);
font-size: var(--font-size-3xs);
line-height: 1;
pointer-events: none;
}
#profileButton .admin-star {
top: var(--space-2xs);
left: var(--space-2xs);
}
/* Non-clipped relative shell that decorateAdminAvatar wraps around a circular
avatar so the corner star isn't clipped. Sizes to the avatar it holds. */
.admin-avatar-wrap {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
/* Gold ring hugging an admin avatar. A thin page-colored gap makes it read as
a ring, not a border. box-shadow isn't clipped by the avatar's own
overflow, so it works on clipped circular avatars. */
.admin-ring {
box-shadow:
0 0 0 var(--border-width-hairline) var(--color-page),
0 0 0 calc(var(--border-width-hairline) * 3) var(--status-warning);
}
/* Gold ring around the avatar when the logged-in user is an admin — the
unmistakable "you're an admin" signal on your own profile button. A thin
page-colored gap separates the avatar from the ring so it reads as a ring,
not a border. Box-shadow isn't clipped by the avatar's own overflow. */
#profileButton.is-admin > div {
box-shadow:
0 0 0 var(--border-width-hairline) var(--color-page),
0 0 0 calc(var(--border-width-hairline) * 3) var(--status-warning);
}
.header-icon-toggle[data-theme-state="light"]:hover,
.header-icon-toggle[data-theme-state="light"]:focus-visible {
color: var(--color-text);
@@ -573,47 +518,6 @@
-webkit-backdrop-filter: blur(var(--overlay-backdrop-blur));
}
/* `display: flex` above overrides the UA `[hidden] { display: none }`, so an
overlay with the `hidden` attribute (e.g. the beacon processing spinner)
would render on load and cover the whole page. Restore hide-on-hidden. */
.ds-overlay-backdrop[hidden] {
display: none;
}
/* "Advanced — paste a connection manually" disclosure in the Wallet pane
(Bitcoin Connect). A subtle rotating caret replaces the default marker. */
.wallet-advanced__summary {
list-style: none;
}
.wallet-advanced__summary::-webkit-details-marker {
display: none;
}
.wallet-advanced__summary::before {
content: "▸ ";
color: var(--text-subtle);
}
.wallet-advanced[open] > .wallet-advanced__summary::before {
content: "▾ ";
}
.popularity-modal__backdrop {
z-index: var(--z-overlay-popover);
padding: var(--space-md);
}
.popularity-modal {
width: 100%;
max-width: var(--popularity-modal-max-width);
}
.popularity-modal__chart {
width: 100%;
height: var(--popularity-modal-chart-height);
}
.popover-panel[data-variant="zap"],
.popover__panel[data-variant="zap"] {
background: var(--color-overlay-veil);
@@ -1476,7 +1380,7 @@
overscroll-behavior: contain;
}
@media (max-width: calc(theme("screens.lg") - 0.02px)) {
@media (max-width: calc(theme("screens.lg") - var(--breakpoint-edge-offset))) {
.bv-modal {
align-items: stretch;
justify-content: stretch;
@@ -2686,14 +2590,6 @@
border-style: solid;
}
/* `display: inline-flex` above overrides the UA `[hidden] { display: none }`,
which left the zap button visible (and clickable-looking) while it was still
`hidden`+`disabled` during load — users clicked a dead button repeatedly. Make
the hidden attribute win so the button only appears once it's enabled/ready. */
.video-modal__action-button[hidden] {
display: none;
}
.video-modal__action-label {
@apply hidden sm:inline;
}
@@ -2787,11 +2683,8 @@
}
.comment-thread__avatar {
position: relative;
inline-size: var(--comment-avatar-size);
block-size: var(--comment-avatar-size);
/* rounded so the admin ring (box-shadow) reads as a circle */
border-radius: var(--radius-full);
}
.comment-thread__avatar img {
@@ -2828,10 +2721,6 @@
color: var(--text-primary);
line-height: 1.6;
word-break: break-word;
/* Comment content is inserted as a single text node, so preserve the
author's line breaks/paragraphs instead of collapsing them into one
run-on line. pre-wrap keeps newlines while still wrapping long lines. */
white-space: pre-wrap;
}
.comment-thread__actions {
@@ -2988,426 +2877,6 @@
transform-origin: top center;
}
/* Zap total on video cards (#47 follow-up): orange, pushed to the row's end
so views/comments stay left-aligned and sats sit right-aligned. Hidden
(via the utility class) until a nonzero total is known. */
.video-card__zaps {
color: var(--color-zap);
margin-inline-start: auto;
}
.video-card__zaps .zap-total-text {
font-variant-numeric: tabular-nums;
}
/* Same treatment in the player modal: orange sats on the far side of the
meta row (the row is justify-between, views stay left). */
.video-modal__zaps {
color: var(--color-zap);
font-variant-numeric: tabular-nums;
}
/* Zap orange as a currentColor source for the popularity chart's zaps line
+ legend swatch (mirrors how the views line uses .text-accent). */
.text-zap {
color: var(--color-zap);
}
.popularity-legend {
display: flex;
gap: var(--space-md);
align-items: center;
font-size: var(--font-size-2xs);
color: var(--color-text-muted);
margin-block-start: var(--space-2xs);
}
.popularity-legend__item {
display: inline-flex;
align-items: center;
gap: var(--space-3xs);
}
.popularity-legend__swatch {
inline-size: var(--space-sm);
block-size: var(--size-3px, 0.1875rem);
border-radius: var(--radius-full);
background-color: currentColor;
}
/* #37: channel playlists — each card is a stacked "pile of clips" cover with a
themed gradient scrim and the title set bold over it. */
.playlist-grid {
@apply grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4;
gap: var(--space-lg);
}
.playlist-card {
position: relative;
display: block;
aspect-ratio: 16 / 9;
text-decoration: none;
/* leave room for the offset stack layers to peek out */
margin-block-start: var(--space-2xs);
margin-inline-end: var(--space-xs);
transition: transform 0.14s ease-out;
}
/* Two offset layers behind the cover read as a stack of videos. */
.playlist-card::before,
.playlist-card::after {
content: "";
position: absolute;
inset: 0;
border-radius: var(--radius-md);
background-color: var(--color-page-alt);
border: var(--border-width-hairline) solid var(--color-overlay-strong);
box-shadow: var(--shadow-sm);
}
.playlist-card::before {
transform: translate(var(--size-5px), calc(-1 * var(--size-5px)))
rotate(2.5deg);
}
.playlist-card::after {
transform: translate(calc(-1 * var(--size-4px)), calc(-1 * var(--size-3px)))
rotate(-2deg);
}
.playlist-card__thumb {
position: absolute;
inset: 0;
z-index: 1;
border-radius: var(--radius-md);
overflow: hidden;
background-color: var(--color-page-alt);
box-shadow: var(--shadow-md);
}
.playlist-card:hover {
transform: translateY(-2px);
}
.playlist-card:hover .playlist-card__thumb {
box-shadow: var(--shadow-lg);
}
.playlist-card__image {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
object-fit: cover;
}
/* Themed scrim: accent-tinted dark at the base fading to clear, so the bold
title reads over any cover (always dark, so the title is always white). */
.playlist-card__scrim {
position: absolute;
inset: 0;
background-image: linear-gradient(
to top,
color-mix(in srgb, var(--color-accent-strong) 62%, black) 0%,
color-mix(in srgb, var(--color-accent) 26%, transparent) 45%,
transparent 74%
);
}
.playlist-card__count {
position: absolute;
z-index: 2;
inset-block-start: var(--space-2xs);
inset-inline-end: var(--space-2xs);
border-radius: var(--radius-sm);
padding-block: var(--space-3xs);
padding-inline: var(--space-2xs);
font-size: var(--font-size-2xs);
font-weight: 600;
color: var(--color-white);
background-color: var(--color-overlay-strong);
}
.playlist-card__title {
position: absolute;
z-index: 2;
inset-inline: 0;
inset-block-end: 0;
margin: 0;
padding: var(--space-sm);
font-size: var(--font-size-sm);
font-weight: 700;
line-height: 1.2;
color: var(--color-white);
text-shadow: 0 var(--size-2px) var(--size-3px) var(--color-overlay-black);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* #37: profile modal "Playlists" management pane — collapsible rows. */
.playlist-row {
border: var(--border-width-hairline) solid var(--color-border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
background-color: var(--color-page-alt);
}
.playlist-row + .playlist-row {
margin-block-start: var(--space-2xs);
}
.playlist-row__head {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm);
cursor: pointer;
}
.playlist-row__chevron svg {
color: var(--color-text-muted);
transition: transform 0.15s ease-out;
}
.playlist-row__chevron[data-open="true"] svg {
transform: rotate(90deg);
}
.playlist-row__meta {
flex: 1 1 auto;
min-inline-size: 0;
}
.playlist-row__title {
margin: 0;
font-weight: 600;
color: var(--color-text-strong);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-row__count {
margin: 0;
font-size: var(--font-size-2xs);
color: var(--color-text-muted);
}
.playlist-row__actions {
display: flex;
gap: var(--space-2xs);
flex-shrink: 0;
}
.playlist-row__videos {
border-block-start: var(--border-width-hairline) solid
var(--color-border-subtle);
padding: var(--space-2xs) var(--space-sm) var(--space-sm);
display: flex;
flex-direction: column;
gap: var(--space-2xs);
}
.playlist-row__empty {
padding-block: var(--space-2xs);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.playlist-video {
display: flex;
align-items: center;
gap: var(--space-sm);
cursor: grab;
}
.playlist-video:active {
cursor: grabbing;
}
.playlist-video--dragging {
opacity: 0.5;
}
.playlist-video--drop-target {
box-shadow: inset 0 var(--size-2px) 0 0 var(--color-accent);
}
.playlist-video__title {
flex: 1 1 auto;
min-inline-size: 0;
font-size: var(--font-size-sm);
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-video__controls {
display: flex;
gap: var(--space-3xs);
flex-shrink: 0;
}
/* #23: admin Submissions cards. */
.submission-card {
display: flex;
flex-direction: column;
gap: var(--space-2xs);
padding: var(--space-sm);
border: var(--border-width-hairline) solid var(--color-border-subtle);
border-radius: var(--radius-md);
background-color: var(--color-page-alt);
}
.submission-card + .submission-card {
margin-block-start: var(--space-2xs);
}
.submission-card__head {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
.submission-card__type {
padding-block: var(--space-3xs);
padding-inline: var(--space-2xs);
border-radius: var(--radius-full);
font-size: var(--font-size-2xs);
font-weight: 600;
color: var(--color-white);
background-color: var(--color-accent-strong);
}
.submission-card__applicant {
min-inline-size: 0;
font-size: var(--font-size-2xs);
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.submission-card__body {
display: flex;
flex-direction: column;
gap: var(--space-3xs);
}
.submission-card__heading {
margin: 0;
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text-strong);
}
.submission-card__line {
margin: 0;
font-size: var(--font-size-sm);
color: var(--color-text);
word-break: break-word;
}
.submission-card__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
align-items: flex-start;
}
/* Each action pairs its button with a one-line description so a moderator
knows exactly what the control does before clicking. */
.submission-card__action {
display: flex;
flex-direction: column;
gap: var(--space-3xs);
min-inline-size: 0;
}
.submission-card__action-hint {
font-size: var(--font-size-2xs);
color: var(--color-text-muted);
max-inline-size: 22ch;
}
/* #43: always-visible active-identity pill. Rendered after the header in
flow and pulled up with a negative block-start margin so it sits half-on /
half-off the header divider — every pane shows who you're acting as. */
.profile-modal__identity {
align-self: center;
display: inline-flex;
align-items: center;
gap: var(--space-xs);
max-inline-size: min(80%, calc(var(--profile-modal-active-min-width) * 2));
padding: var(--space-3xs);
padding-inline-end: var(--space-md);
border-radius: var(--radius-full);
border: var(--border-width-hairline) solid var(--color-border-translucent);
background-color: var(--color-panel);
color: var(--color-text);
box-shadow: var(--shadow-md);
position: relative;
z-index: 2;
margin-block-start: calc(-1 * var(--space-sm));
margin-block-end: calc(-1 * var(--space-3xs));
cursor: pointer;
text-align: start;
}
.profile-modal__identity.hidden {
display: none;
}
.profile-modal__identity:hover {
border-color: var(--color-accent);
}
.profile-modal__identity-avatar {
display: flex;
inline-size: var(--space-xl);
block-size: var(--space-xl);
flex-shrink: 0;
overflow: hidden;
border-radius: var(--radius-full);
border: var(--border-width-hairline) solid var(--color-accent);
background-color: var(--color-surface);
}
.profile-modal__identity-avatar img {
inline-size: 100%;
block-size: 100%;
object-fit: cover;
}
.profile-modal__identity-copy {
display: flex;
flex-direction: column;
min-inline-size: 0;
}
.profile-modal__identity-kicker {
font-size: var(--font-size-3xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: var(--tracking-extra-wide);
color: var(--color-accent);
line-height: var(--line-height-tight);
}
.profile-modal__identity-name {
font-size: var(--font-size-sm);
font-weight: 600;
line-height: var(--line-height-tight);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-modal__active-profile {
@apply focus-ring flex flex-wrap items-center gap-4 p-4;
}
@@ -3570,13 +3039,6 @@
overflow-x: visible;
}
/* Nav buttons host an absolutely-positioned unread dot, so they must be the
positioning context — otherwise the dot resolves against the whole nav
column (top:50%/left:-6px of the list, not the button). */
.profile-modal__nav > button {
position: relative;
}
.profile-nav__unread-dot {
position: absolute;
top: 50%;
@@ -3596,79 +3058,6 @@
display: block;
}
/* Pending-submissions dot on the Admin pane's "Submissions" subtab. The
subtabs sit in a horizontal row, so this dot rides the top-right corner
(not the left edge like the vertical nav dots). */
.admin-subtab-has-dot {
position: relative;
}
.admin-subtab-dot {
position: absolute;
top: calc(-1 * var(--space-3xs));
right: calc(-1 * var(--space-3xs));
width: var(--space-sm);
height: var(--space-sm);
border-radius: var(--radius-full);
border: var(--border-width-hairline) solid var(--surface-panel);
background-color: var(--status-danger);
display: none;
pointer-events: none;
}
.admin-subtab-dot.is-visible {
display: block;
}
/* Compact creator/account cards (Subscriptions, Friends, Blocked, Admin
lists, Moderation). Desktop lays them out in a row: summary left, actions
right. `.profile-account-card` is the card itself (padded); the
`--row` variant is for a nested flex row inside an already-padded card. */
.profile-account-card,
.profile-account-card__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
}
.profile-account-card {
padding: var(--space-md);
}
.profile-account-card__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--space-2xs);
}
/* Phones: stack the card so the name/npub get the full width (no more
4-line-wrapped npub in a crammed column), with the actions as a full-width
row of equal-width buttons below. */
@media (max-width: calc(theme("screens.sm") - 0.02px)) {
.profile-account-card,
.profile-account-card__row {
flex-direction: column;
align-items: stretch;
gap: var(--space-sm);
}
.profile-account-card {
padding: var(--space-sm);
}
.profile-account-card__actions > * {
flex: 1 1 auto;
}
/* tighter buttons so all three fit one clean row */
.profile-account-card__actions .btn-ghost {
padding-inline: var(--space-sm);
}
}
.profile-modal__panes {
display: flex;
flex: 1 1 auto;
@@ -3714,7 +3103,7 @@
}
}
@media (max-width: calc(theme("screens.lg") - 0.02px)) {
@media (max-width: calc(theme("screens.lg") - var(--breakpoint-edge-offset))) {
.profile-modal__layout[data-mobile-view="menu"]
.profile-modal__pane-wrapper {
display: none;
@@ -3725,65 +3114,6 @@
}
}
/* Mobile: the profile tab nav (full-width menu view) becomes an app-grid — icon
tiles with the title centered below each icon, instead of the desktop vertical
list. Desktop (>=lg) keeps its vertical nav (the min-width:lg rule below wins
at the exact breakpoint via source order). NOTE: the @media condition must NOT
use a CSS `var()` — custom properties are invalid inside media conditions and
Chromium drops the whole block. `theme("screens.lg")` resolves to a literal at
build time, which is valid. */
@media (max-width: calc(theme("screens.lg") - 0.02px)) {
.profile-modal__nav {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-xs);
/* Every tile the SAME height, so a 2-line label can't stretch its row
taller than the others (the "ragged grid of ellipses" fix). */
grid-auto-rows: var(--profile-modal-nav-tile-height);
}
.profile-modal__nav > button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: var(--space-2xs);
width: 100%;
block-size: 100%;
/* Consistent rounded-square tiles, not rounded-full ellipses (btn's
default) that turn into circles once a tile gets taller. */
border-radius: var(--radius-xl);
/* Subtle fill so they read as solid cards, not floating outlines. The
active tile's bg-primary (higher specificity) still wins. */
background-color: var(--color-panel);
padding-block: var(--space-xs);
padding-inline: var(--space-3xs);
line-height: 1.15;
overflow: hidden;
/* wrap labels at spaces, not mid-word */
overflow-wrap: break-word;
word-break: normal;
hyphens: none;
}
/* slightly larger glyph for the tile */
.profile-modal__nav > button svg {
width: var(--space-lg);
height: var(--space-lg);
flex-shrink: 0;
}
/* In the tile grid the unread dot rides the tile's top-right corner instead
of the vertical-nav left edge (where it landed in the grid gutter). */
.profile-modal__nav > button .profile-nav__unread-dot {
top: var(--space-2xs);
left: auto;
right: var(--space-2xs);
transform: none;
}
}
@media (min-width: theme("screens.lg")) {
.profile-modal {
max-block-size: var(--modal-shell-max-height);
@@ -4734,12 +4064,6 @@
margin-top: var(--space-xs);
}
body.iframe-form .form-hint {
margin-top: var(--space-xs);
font-size: var(--font-size-2xs);
color: var(--color-text-muted);
}
body.iframe-form .checkbox-group,
body.iframe-form .radio-group {
display: flex;
@@ -5861,21 +5185,9 @@
outline: none;
}
.dm-contact-row--active,
.dm-contact-row--active:hover,
.dm-contact-row--active:focus-visible {
background: color-mix(in srgb, var(--color-accent) 16%, transparent);
border-color: color-mix(
in srgb,
var(--color-accent) 60%,
var(--border-strong)
);
box-shadow: inset 3px 0 0 0 var(--color-accent);
}
.dm-contact-row--active .dm-contact-row__name {
color: var(--color-accent-strong);
font-weight: 700;
.dm-contact-row--active {
background: var(--surface-sunken);
border-color: var(--border-strong);
}
.dm-contact-row__content {
@@ -5921,14 +5233,6 @@
font-size: var(--font-size-3xs);
}
/* Non-clipped relative shell used only when a DM avatar needs an admin star
in its corner (the .dm-avatar itself clips to a circle). */
.dm-avatar-wrap {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.dm-avatar {
display: inline-flex;
align-items: center;
@@ -6057,14 +5361,6 @@
border: var(--border-width-hairline) solid var(--border-subtle);
}
/* Non-clipped relative shell used only when a message avatar needs an admin
star; the plain .dm-message-avatar img has no wrapper otherwise. */
.dm-message-avatar-wrap {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.dm-message-bubble {
max-width: 75%;
display: flex;
@@ -6754,7 +6050,7 @@
}
/* Mobile Active State */
@media (max-width: calc(theme("screens.lg") - 0.02px)) {
@media (max-width: calc(theme("screens.lg") - var(--breakpoint-edge-offset))) {
.card[data-mobile-active="true"] {
--video-card-lift-translate-y: var(--motion-video-card-hover-translate);
--video-card-backdrop-opacity: 0.425;
-11
View File
@@ -80,8 +80,6 @@
--blur-modal-alert: 14px;
--blur-thumbnail-blur: 18px;
--modal-nav-backdrop-blur: 6px;
--popularity-modal-max-width: 28rem;
--popularity-modal-chart-height: 130px;
--app-header-centerline-offset: calc(
var(--space-sm) + (var(--space-2xl-plus) / 2)
@@ -301,8 +299,6 @@
/* Measurement primitives */
--size-2px: 2px;
--size-3px: 3px;
--size-4px: 4px;
--size-5px: 5px;
--size-12px: 12px;
--size-24px: 24px;
--size-30px: 30px;
@@ -359,9 +355,6 @@
--notification-banner-max-inline: var(--size-28rem);
--profile-modal-nav-min-width: max-content;
--profile-modal-nav-max-width: max-content;
/* Uniform height for the mobile tab-grid tiles (roomy enough for a 2-line
label, so a wrapping tile never stretches its row taller than the rest). */
--profile-modal-nav-tile-height: 5rem;
--profile-modal-scrollbar-offset: var(--space-2xs);
--modal-embed-height: 80vh;
--profile-history-max-height: 26.25rem;
@@ -514,8 +507,6 @@
--color-accent: rgb(99 102 241);
--color-accent-strong: rgb(79 70 229);
--color-accent-pressed: rgb(67 56 202);
/* Zap totals (sats) — bitcoin orange, darker for light-bg contrast. */
--color-zap: rgb(217 119 6);
--color-accent-override: rgb(84 0 17);
--color-accent-strong-override: rgb(254 0 50);
--color-accent-pressed-override: rgb(169 0 33);
@@ -703,8 +694,6 @@
--color-accent: rgb(129 140 248);
--color-accent-strong: rgb(99 102 241);
--color-accent-pressed: rgb(79 70 229);
/* Zap totals (sats) — bitcoin orange. */
--color-zap: rgb(247 147 26);
--color-accent-override: rgb(84 0 17);
--color-accent-strong-override: rgb(254 0 50);
--color-accent-pressed-override: rgb(169 0 33);
+23 -24
View File
@@ -1,33 +1,32 @@
# Daily Performance Report - 2026-02-17
# Daily Perf Report: 2026-02-17
**Summary:** Bounded concurrency for DM signal events and DM service initialization to prevent network saturation. Fixed CI failure in `lint:hex` and `unit-tests`.
**Summary**: Initial run established baseline hits. Identified and fixed P0 unbounded concurrency in `relayManager.js` causing potential network saturation during relay list hydration.
## Findings & Fixes
## Findings
### P0: Unbounded Concurrency in DM Subsystem
- **Files:** `js/nostr/dmSignalEvents.js`, `js/services/dmNostrService.js`
- **Impact:** `Promise.all` was used to map over all relays (potential for 20+ connections simultaneously) for DM read receipts, typing indicators, and service connections.
- **Fix:** Replaced with `pMap` using `RELAY_BACKGROUND_CONCURRENCY || 3`.
- **Verification:** `npm run test:dm:unit` and `npm run test:dm:integration` passed.
### P0: Unbounded Relay List Hydration
- **File**: `js/relayManager.js`
- **Location**: `loadRelayList` (lines 376-384)
- **Impact**: Simultaneously opens connections to all relays in a user's list (often > 20) during hydration, causing main-thread stutter and potential socket timeouts.
- **Fix**: Implemented bounded concurrency using `pMap` (limit = 3).
- **Status**: **FIXED** (PR pending)
### CI Failure (lint:hex)
- **Problem:** The `npm run lint:hex` script failed in CI because `git grep` scanned `perf/hits-2026-02-17.txt` (which contained a dump of `webtorrent.min.js` from an agent search) and found hex codes, treating it as a violation.
- **Fix:** Updated `scripts/check-hex.js` to exclude agent artifact directories (`perf/`, `test_logs/`, `context/`, `decisions/`, `todo/`).
- **Additional Fix:** Increased `maxBuffer` in `scripts/check-hex.js` to 10MB to be robust against large matches.
### P1: Potential Unbounded Promise.all in App Initialization
- **File**: `js/app.js`
- **Location**: `refreshAllVideoGrids`
- **Impact**: Runs multiple refresh tasks in parallel. Currently low impact as tasks are fixed (subscription + channel), but warrants monitoring.
- **Status**: Monitoring.
### CI Failure (unit-tests)
- **Problem:** `tests/unit/services/exploreDataService.test.mjs` failed with `0 !== 1` for "visibility change triggers refresh". The test expected an immediate data refresh when `document.hidden` changed to `false`, but the implementation only started the interval timer.
- **Fix:** Updated `handleVisibility` in `js/services/exploreDataService.js` to trigger `refreshWatchHistoryTagCounts` and `refreshTagIdf` immediately when becoming visible.
### Docs Audit
- **Port Alignment:** Verified `content/docs/guides/upload-content.md` correctly specifies port 3000 for CORS, aligning with previous remediation. No changes needed.
### P2: WebTorrent Polling Interval
- **File**: `js/webtorrent.js`
- **Location**: `probePeers`
- **Impact**: Polling `setInterval` runs every ~2.5s during probes.
- **Mitigation**: Bounded by timeout and cleanup logic. Low priority.
## Metrics
- **Login Time:** N/A (change affects background network tasks).
- **DM Decrypt Queue:** N/A (network bounding).
- **Hits**: 1308
- **PRs Opened**: 1 (Fix unbounded relay concurrency)
## Decisions
- Used `RELAY_BACKGROUND_CONCURRENCY || 3` for DM operations to match relay manager behavior and ensure robustness against missing constants.
## PRs / Commits
- `perf: bound DM subsystem network concurrency & fix CI lint/tests`
- Using `RELAY_BACKGROUND_CONCURRENCY = 3` from `js/nostr/relayConstants.js`.
- Refactored `loadRelayList` to use `pMap`.
-11
View File
@@ -1,11 +0,0 @@
# Decisions - 2025-02-18
## Test Audit Implementation
- **Tooling**: Used `c8` for coverage and custom Node.js scripts for flakiness/static analysis.
- **Timeout**: Experienced timeouts with full suite coverage run; mitigation was to use the partial results which were sufficient for initial audit.
- **Coverage**: Mapped coverage to critical files using `coverage-summary.json`.
- **Artifacts**: Stored all artifacts in `test-audit/` for historical tracking.
## Findings
- Confirmed that critical modules (`userBlocks`, `relayManager`) have low test coverage.
- Identified widespread use of `sleep` and `fetch` in unit tests, indicating a need for better test isolation and determinism helpers.
-6
View File
@@ -1,6 +0,0 @@
# Decisions - Perf Agent - 2026-02-17
- **Decision:** Use `pMap` with `RELAY_BACKGROUND_CONCURRENCY` (3) for DM signal publishing and DM service connection/publishing.
- **Rationale:** Prevents network saturation when users have many relays configured. `Promise.all` launches all requests simultaneously, which can cause timeouts or UI lag.
- **Decision:** Update documentation port to 3000.
- **Rationale:** Matches standard dev environment default.
@@ -0,0 +1,2 @@
# Decisions
- No changes made to scheduler files because rosters matched the file inventory exactly.
@@ -0,0 +1,4 @@
# Decisions Log
- Using `npx c8` for coverage if available, falling back to basic test run.
- Using simple grep for static analysis to avoid complex parsing in script.
@@ -1,15 +0,0 @@
# Decisions - 2026-02-17 (perf-agent)
## Design Choices
### 1. Bounding Concurrency in `channelProfile.js`
- **Context:** The `loadUserVideos` function was firing requests to all user relays simultaneously.
- **Decision:** Use `pMap` with `RELAY_BACKGROUND_CONCURRENCY` (3).
- **Rationale:** This prevents network saturation when a user has many relays (e.g., 20+), which could block other critical requests (like login or feed loading).
- **Tradeoff:** Fetching from *all* relays might take slightly longer in wall-clock time if many are slow, but it keeps the browser responsive.
- **Implementation:** Wraps the `nostrClient.pool.list` call in a try/catch block inside the mapper to return `{ status, value/reason }` objects, mimicking `Promise.allSettled`.
### 2. Docs Audit Scope
- **Context:** The prompt requires verifying `/content` upload docs.
- **Decision:** Audited `content/docs/guides/upload-content.md` against baseline.
- **Rationale:** This is the primary guide for users. Verified port (3000) and limits (2GB).
@@ -1,13 +0,0 @@
# Decisions - 2026-02-18
File: `js/ui/components/DeleteModal.js`
1. **Template Loading (Line 108)**:
- **Before**: `wrapper.innerHTML = html`
- **After**: `const doc = new DOMParser().parseFromString(html, 'text/html'); wrapper.append(...doc.body.children);`
- **Rationale**: While the source is a local file (`components/delete-video-modal.html`), using `DOMParser` prevents execution of scripts by default and avoids raw HTML injection vulnerabilities if the template were ever compromised or modified. It aligns with the goal of removing `innerHTML`.
2. **Clearing List (Line 263 & 368)**:
- **Before**: `this.metadataList.innerHTML = ""`
- **After**: `this.metadataList.replaceChildren()`
- **Rationale**: `replaceChildren()` is the modern, performant way to empty an element. `textContent = ""` is also valid but `replaceChildren()` is explicitly for manipulating child nodes.
@@ -1,4 +0,0 @@
# Decisions: NIP Research Agent (2026-02-18)
- **Prioritize NIP-65**: The inventory lists NIP-65 as "Partial" due to missing tests. As a P0 item (Relay Lists), this is the highest priority gap to close.
- **Test Strategy**: Create a new test file `tests/nostr-specs/nip65_compliance.test.mjs` that mocks `nostrClient` to test `relayManager` in isolation.
@@ -1,3 +0,0 @@
# Scheduler Update Decisions
- None yet.
@@ -1 +0,0 @@
# Decisions (2026-02-19_18-55-56)
@@ -1,15 +0,0 @@
# Decomposition Decisions
- **File:** `js/ui/profileModalController.js`
- **Extracted Controllers:**
- `js/ui/profileModal/ProfileBlockListController.js` (Block list logic)
- `js/ui/profileModal/ProfileEditController.js` (Edit profile logic)
- **Rationale:**
- Block list management is a distinct feature set (add, remove, list, refresh).
- Edit profile is a self-contained form handling workflow (populate, upload, save).
- Reducing the main controller size improves readability and separation of concerns.
- **Backwards Compatibility:**
- New controllers alias DOM references back to the main controller (`this.mainController.blockList = ...`) to maintain compatibility with tests and potentially other external code that expects these properties on the main controller.
- Delegate methods added to `ProfileModalController` to forward calls to the new controllers, preserving the public API.
- **Baseline Update:**
- `js/ui/profileModalController.js` reduced from 6258 to 5541 lines (-717 lines).
@@ -1,3 +0,0 @@
# Decisions: Content Audit 2026-02-19
*None yet.*
@@ -1,4 +0,0 @@
## Decisions
- Upgraded stylelint to 16.26.1 (minor, safe dev dependency).
- Skipped nostr-tools (requires review).
- Created artifacts/deps-report.md.
@@ -1,37 +0,0 @@
# Decisions: innerHTML Migration for ShareNostrModal.js
## 1. Template Loading (L107)
**Original:** `wrapper.innerHTML = html;`
**New:**
```javascript
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
wrapper.replaceChildren(...doc.body.childNodes);
```
**Rationale:** Avoids innerHTML while parsing the fetched HTML string safely.
## 2. Clearing Content (L274)
**Original:** `this.relayPills.innerHTML = "";`
**New:** `this.relayPills.replaceChildren();`
**Rationale:** Modern, faster, and safer method to clear DOM nodes.
## 3. SVG Icon (L300)
**Original:** `removeButton.innerHTML = '<svg ...>...</svg>';`
**New:**
```javascript
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", "h-3 w-3");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", "M18 6L6 18M6 6l12 12");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
svg.appendChild(path);
removeButton.appendChild(svg);
```
**Rationale:** Using `createElementNS` is the correct way to build SVG elements without `innerHTML`.
@@ -1,5 +0,0 @@
# Decisions: NIP Research Agent Run (2026-02-21)
- **Assumption:** NIP-42 is non-compliant as per initial inventory. Verification will focus on confirming absence of `AUTH` handler.
- **Assumption:** NIP-21 is handled in `js/utils/nostrHelpers.js` or similar utility files.
- **Assumption:** NIP-09 deletion logic resides in `js/nostr/client.js` or `js/nostr/adapters/`.
@@ -1,16 +0,0 @@
# Decisions: NIP-42 Research
**Date:** 2026-02-21
## NIP Selection
- **Decision:** Focus on NIP-42.
- **Rationale:** Currently marked as "Unknown" in inventory. Relay authentication is a critical feature for private or paid relays.
## Implementation Status
- **Initial Observation:** `grep` for "AUTH" in `js/nostr/client.js` showed no relevant results for relay authentication command handling.
- **Codebase Analysis:**
- `js/nostr/client.js` and `js/nostr/managers/ConnectionManager.js` use `SimplePool` from `nostr-tools`.
- `nostr-tools` v2.19.4 is used.
- No listeners for `"AUTH"` messages found on relay instances.
- No logic to sign Kind 22242 events found.
- **Conclusion:** NIP-42 is **Non-compliant**.
@@ -1,3 +0,0 @@
# Decisions: NIP Research Agent Run 2026-02-22
- Proceeding with incremental update of `NIP_INVENTORY.md`.
@@ -1,5 +0,0 @@
# Decisions - Scheduler Update Agent
**Date**: 2026-02-23_15-01-13
- No changes made to rosters as they were already in sync.
@@ -1,4 +0,0 @@
# Decisions - 2026-02-23
- Using 'npm run test:unit' as the primary test command.
- Coverage collection may be skipped if not supported by current environment.
@@ -1,3 +0,0 @@
# Decisions: Scheduler Update Agent
- Date: 2026-02-23
@@ -1,6 +0,0 @@
# Decisions: Scheduler Update Agent
## 2026-02-23
- **Inventory Check**: Verified `daily/` and `weekly/` prompt directories against `daily-scheduler.md` and `weekly-scheduler.md`.
- **Decision**: No updates required. Files and rosters are in sync.
@@ -1,3 +0,0 @@
# Test Audit Decisions - 2026-02-23
- Skipping coverage metrics due to missing `c8`/`@vitest/coverage-v8` dependencies.
+2
View File
@@ -0,0 +1,2 @@
/*
Content-Security-Policy: frame-ancestors *
+1
View File
@@ -0,0 +1 @@
/* /index.html 200!
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Loaded 100 of 1559 files, more files were not shown because too many files have changed in this diff. Show more