Promote unstable → beta: encrypted settings sync, watch-history/zap/login fixes, My Videos

This commit is contained in:
thePR0M3TH3AN committed 2026-06-21 22:16:28 -04:00
commit f5ad166e2c
5116 files changed
+14031 -520907

No files matched your search

+48
View File
@@ -89,6 +89,54 @@ 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
+3
View File
@@ -16,3 +16,6 @@ TEST_LOG.mdtest_logs/nip-hits.txt
!artifacts/audit/
!artifacts/audit/*.md
!artifacts/audit/*.json
# Vercel CLI local link/state
.vercel
@@ -1,15 +0,0 @@
### 1. [2026-03-02] (scheduler, daily, retrieve)
Memory retrieval seed for daily :: scheduler memory retrieval
---
### 2. [2026-02-25] (scheduler, daily, retrieve)
Memory retrieval seed for daily :: scheduler memory retrieval
---
### 3. [2026-02-26] (scheduler, daily, retrieve)
Memory retrieval seed for daily :: scheduler memory retrieval
@@ -1 +0,0 @@
MEMORY_RETRIEVED
-1
View File
@@ -1 +0,0 @@
MEMORY_STORED
@@ -1,3 +0,0 @@
### 1. [2026-03-02] (scheduler, weekly, retrieve)
Memory retrieval seed for weekly :: Required startup + artifacts + memory + issue capture
@@ -1 +0,0 @@
MEMORY_RETRIEVED
-1
View File
@@ -1 +0,0 @@
MEMORY_STORED
-331
View File
@@ -1,331 +0,0 @@
[
[
"fd81cac3-2ced-46ed-954a-370e0e71de96",
{
"schema_version": 1,
"id": "fd81cac3-2ced-46ed-954a-370e0e71de96",
"agent_id": "scheduler-memory-daily",
"session_id": "session-mm2cwyuv",
"type": "batch_window",
"content": "Memory retrieval seed for daily :: scheduler memory retrieval",
"summary": "Memory retrieval seed for daily :: scheduler memory retrieval",
"tags": [
"scheduler",
"daily",
"retrieve"
],
"importance": 0.4,
"embedding_id": null,
"created_at": 1772043447415,
"last_seen": 1772136226128,
"source": "scheduler-retrieve",
"ttl_days": null,
"merged_into": null,
"pinned": false
}
],
[
"34dd591f-15cc-4914-8c70-ec7510b29abb",
{
"schema_version": 1,
"id": "34dd591f-15cc-4914-8c70-ec7510b29abb",
"agent_id": "scheduler-memory-daily",
"session_id": "session-mm3u2nd3",
"type": "batch_window",
"content": "Memory retrieval seed for daily :: scheduler memory retrieval",
"summary": "Memory retrieval seed for daily :: scheduler memory retrieval",
"tags": [
"scheduler",
"daily",
"retrieve"
],
"importance": 0.4,
"embedding_id": null,
"created_at": 1772132732103,
"last_seen": 1772136226128,
"source": "scheduler-retrieve",
"ttl_days": null,
"merged_into": null,
"pinned": false
}
],
[
"bdfe772c-3277-4fb0-aaeb-daf0d937911d",
{
"schema_version": 1,
"id": "bdfe772c-3277-4fb0-aaeb-daf0d937911d",
"agent_id": "scheduler-memory-daily",
"session_id": "session-mm3w5jd2",
"type": "batch_window",
"content": "Memory retrieval seed for daily :: scheduler memory retrieval",
"summary": "Memory retrieval seed for daily :: scheduler memory retrieval",
"tags": [
"scheduler",
"daily",
"retrieve"
],
"importance": 0.4,
"embedding_id": null,
"created_at": 1772136226118,
"last_seen": 1772136226128,
"source": "scheduler-retrieve",
"ttl_days": null,
"merged_into": null,
"pinned": false
}
],
[
"fdcbba05-bf54-4db1-8c04-83deef7daabd",
{
"schema_version": 1,
"id": "fdcbba05-bf54-4db1-8c04-83deef7daabd",
"agent_id": "scheduler-memory-daily",
"session_id": "session-mm42k8d6",
"type": "batch_window",
"content": "Memory retrieval seed for daily :: scheduler memory retrieval",
"summary": "Memory retrieval seed for daily :: scheduler memory retrieval",
"tags": [
"scheduler",
"daily",
"retrieve"
],
"importance": 0.4,
"embedding_id": null,
"created_at": 1772146989403,
"last_seen": 1772146989418,
"source": "scheduler-retrieve",
"ttl_days": null,
"merged_into": null,
"pinned": false
}
],
[
"45ed2411-fb1f-4293-94b1-b2fc8518b7d0",
{
"schema_version": 1,
"id": "45ed2411-fb1f-4293-94b1-b2fc8518b7d0",
"agent_id": "scheduler-memory-daily",
"session_id": "session-mm44rb5x",
"type": "batch_window",
"content": "Memory retrieval seed for daily :: scheduler memory retrieval",
"summary": "Memory retrieval seed for daily :: scheduler memory retrieval",
"tags": [
"scheduler",
"daily",
"retrieve"
],
"importance": 0.4,
"embedding_id": null,
"created_at": 1772150678854,
"last_seen": 1772335100834,
"source": "scheduler-retrieve",
"ttl_days": null,
"merged_into": null,
"pinned": false
}
],
[
"1be93fae-1b6b-4eec-8923-6a0b6474ef19",
{
"schema_version": 1,
"id": "1be93fae-1b6b-4eec-8923-6a0b6474ef19",
"agent_id": "scheduler-memory-daily",
"session_id": "session-mm4918uz",
"type": "batch_window",
"content": "Memory retrieval seed for daily :: scheduler memory retrieval",
"summary": "Memory retrieval seed for daily :: scheduler memory retrieval",
"tags": [
"scheduler",
"daily",
"retrieve"
],
"importance": 0.4,
"embedding_id": null,
"created_at": 1772157860891,
"last_seen": 1772161455747,
"source": "scheduler-retrieve",
"ttl_days": null,
"merged_into": null,
"pinned": false
}
],
[
"093de155-ce35-4d44-b1b8-5c70a757f02b",
{
"schema_version": 1,
"id": "093de155-ce35-4d44-b1b8-5c70a757f02b",
"agent_id": "scheduler-memory-daily",
"session_id": "session-mm49329u",
"type": "batch_window",
"content": "- The `todo-triage-agent` successfully ran but found 0 actionable TODOs in the codebase (excluding vendor files).\n- The regex used `grep -n -E \"TODO|FIXME|XXX\" -- '*.js' '*.html' 'docs/*'` correctly filtered the files.\n- The `js/ui/profileModalContract.js` match was a string literal description, not a comment.",
"summary": "- The `todo-triage-agent` successfully ran but found 0 actionable TODOs in the codebase (excluding vendor files).\n- The regex used `grep -n -E \"TODO|FIXME|XXX\" -- '*.js' '*.html' 'docs/*'` correctly filtered the files.\n- The `js/ui/profileModalContract.js` match was a string lit…",
"tags": [
"scheduler",
"daily",
"store",
"insight"
],
"importance": 0.8,
"embedding_id": null,
"created_at": 1772157945667,
"last_seen": 1772335100834,
"source": "agent-output",
"ttl_days": null,
"merged_into": null,
"pinned": false
}
],
[
"616c7e07-ef0e-484b-8d29-8bdff7459972",
{
"schema_version": 1,
"id": "616c7e07-ef0e-484b-8d29-8bdff7459972",
"agent_id": "scheduler-memory-daily",
"session_id": "session-mm4b6anr",
"type": "batch_window",
"content": "Memory retrieval seed for daily :: scheduler memory retrieval",
"summary": "Memory retrieval seed for daily :: scheduler memory retrieval",
"tags": [
"scheduler",
"daily",
"retrieve"
],
"importance": 0.4,
"embedding_id": null,
"created_at": 1772161455735,
"last_seen": 1772161455747,
"source": "scheduler-retrieve",
"ttl_days": null,
"merged_into": null,
"pinned": false
}
],
[
"38032ba9-614c-45ce-a097-534b7f074078",
{
"schema_version": 1,
"id": "38032ba9-614c-45ce-a097-534b7f074078",
"agent_id": "scheduler-memory-daily",
"session_id": "session-mm4b6zww",
"type": "batch_window",
"content": "- The `todo-triage-agent` successfully ran but found 0 actionable TODOs in the codebase (excluding vendor files).\n- The regex used `grep -n -E \"TODO|FIXME|XXX\" -- '*.js' '*.html' 'docs/*'` correctly filtered the files.\n- The `js/ui/profileModalContract.js` match was a string literal description, not a comment.",
"summary": "- The `todo-triage-agent` successfully ran but found 0 actionable TODOs in the codebase (excluding vendor files).\n- The regex used `grep -n -E \"TODO|FIXME|XXX\" -- '*.js' '*.html' 'docs/*'` correctly filtered the files.\n- The `js/ui/profileModalContract.js` match was a string lit…",
"tags": [
"scheduler",
"daily",
"store",
"insight"
],
"importance": 0.8,
"embedding_id": null,
"created_at": 1772161488465,
"last_seen": 1772335100834,
"source": "agent-output",
"ttl_days": null,
"merged_into": null,
"pinned": false
}
],
[
"04e9ad6f-0108-4fb2-95c6-a61a39ca58ae",
{
"schema_version": 1,
"id": "04e9ad6f-0108-4fb2-95c6-a61a39ca58ae",
"agent_id": "scheduler-memory-daily",
"session_id": "session-mm76k42d",
"type": "batch_window",
"content": "Memory retrieval seed for daily :: scheduler memory retrieval",
"summary": "Memory retrieval seed for daily :: scheduler memory retrieval",
"tags": [
"scheduler",
"daily",
"retrieve"
],
"importance": 0.4,
"embedding_id": null,
"created_at": 1772335100821,
"last_seen": 1772335100834,
"source": "scheduler-retrieve",
"ttl_days": null,
"merged_into": null,
"pinned": false
}
],
[
"626ddd64-0670-47e1-8d6e-8d2acab5783d",
{
"schema_version": 1,
"id": "626ddd64-0670-47e1-8d6e-8d2acab5783d",
"agent_id": "scheduler-memory-daily",
"session_id": "session-mm76kmwi",
"type": "batch_window",
"content": "- The `todo-triage-agent` successfully ran but found 0 actionable TODOs in the codebase (excluding vendor files).\n- The regex used `grep -n -E \"TODO|FIXME|XXX\" -- '*.js' '*.html' 'docs/*'` correctly filtered the files.\n- The `js/ui/profileModalContract.js` match was a string literal description, not a comment.",
"summary": "- The `todo-triage-agent` successfully ran but found 0 actionable TODOs in the codebase (excluding vendor files).\n- The regex used `grep -n -E \"TODO|FIXME|XXX\" -- '*.js' '*.html' 'docs/*'` correctly filtered the files.\n- The `js/ui/profileModalContract.js` match was a string lit…",
"tags": [
"scheduler",
"daily",
"store",
"insight"
],
"importance": 0.8,
"embedding_id": null,
"created_at": 1772335125235,
"last_seen": 1772335125235,
"source": "agent-output",
"ttl_days": null,
"merged_into": null,
"pinned": false
}
],
[
"5c5ebf15-a900-4d49-9e06-dfc42f41a7d1",
{
"schema_version": 1,
"id": "5c5ebf15-a900-4d49-9e06-dfc42f41a7d1",
"agent_id": "bug-reproducer-agent",
"session_id": "session-mm9dheyw",
"type": "batch_window",
"content": "Memory retrieval seed for weekly :: Required startup + artifacts + memory + issue capture",
"summary": "Memory retrieval seed for weekly :: Required startup + artifacts + memory + issue capture",
"tags": [
"scheduler",
"weekly",
"retrieve"
],
"importance": 0.4,
"embedding_id": null,
"created_at": 1772467664649,
"last_seen": 1772467664661,
"source": "scheduler-retrieve",
"ttl_days": null,
"merged_into": null,
"pinned": false
}
],
[
"61a3df52-20b2-444e-82fe-d7f9a5274949",
{
"schema_version": 1,
"id": "61a3df52-20b2-444e-82fe-d7f9a5274949",
"agent_id": "bug-reproducer-agent",
"session_id": "session-mm9dimkw",
"type": "batch_window",
"content": "- The `todo-triage-agent` successfully ran but found 0 actionable TODOs in the codebase (excluding vendor files).\n- The regex used `grep -n -E \"TODO|FIXME|XXX\" -- '*.js' '*.html' 'docs/*'` correctly filtered the files.\n- The `js/ui/profileModalContract.js` match was a string literal description, not a comment.",
"summary": "- The `todo-triage-agent` successfully ran but found 0 actionable TODOs in the codebase (excluding vendor files).\n- The regex used `grep -n -E \"TODO|FIXME|XXX\" -- '*.js' '*.html' 'docs/*'` correctly filtered the files.\n- The `js/ui/profileModalContract.js` match was a string lit…",
"tags": [
"scheduler",
"weekly",
"store",
"insight"
],
"importance": 0.8,
"embedding_id": null,
"created_at": 1772467721169,
"last_seen": 1772467721169,
"source": "agent-output",
"ttl_days": null,
"merged_into": null,
"pinned": false
}
]
]
@@ -1,18 +0,0 @@
{
"cadence": "daily",
"operation": "retrieve",
"runId": "session-mm3w5jd2",
"servicePath": "src/services/memory/index.js#getRelevantMemories",
"inputs": {
"agentId": "scheduler-memory-daily",
"promptPath": "",
"promptIntent": "scheduler memory retrieval",
"query": "scheduler | daily | agent | scheduler-memory-daily | intent | scheduler memory retrieval",
"events": 1
},
"outputs": {
"ingestedCount": 1,
"retrievedCount": 3
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_RETRIEVED
@@ -1,18 +0,0 @@
{
"cadence": "daily",
"operation": "store",
"runId": "session-mm3w84xd",
"servicePath": "src/services/memory/index.js#ingestEvents",
"inputs": {
"agentId": "scheduler-memory-daily",
"promptPath": "",
"promptIntent": "scheduler memory store",
"events": 0,
"sourceFile": "none"
},
"outputs": {
"storedCount": 0,
"summaries": []
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_STORED
@@ -1,18 +0,0 @@
{
"cadence": "daily",
"operation": "retrieve",
"runId": "session-mm3yar5e",
"servicePath": "src/services/memory/index.js#getRelevantMemories",
"inputs": {
"agentId": "scheduler-memory-daily",
"promptPath": "",
"promptIntent": "scheduler memory retrieval",
"query": "scheduler | daily | agent | scheduler-memory-daily | intent | scheduler memory retrieval",
"events": 1
},
"outputs": {
"ingestedCount": 1,
"retrievedCount": 3
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_RETRIEVED
@@ -1,18 +0,0 @@
{
"cadence": "daily",
"operation": "store",
"runId": "session-mm3ybzlq",
"servicePath": "src/services/memory/index.js#ingestEvents",
"inputs": {
"agentId": "scheduler-memory-daily",
"promptPath": "",
"promptIntent": "scheduler memory store",
"events": 0,
"sourceFile": "none"
},
"outputs": {
"storedCount": 0,
"summaries": []
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_STORED
@@ -1,18 +0,0 @@
{
"cadence": "daily",
"operation": "retrieve",
"runId": "session-mm42k8d6",
"servicePath": "src/services/memory/index.js#getRelevantMemories",
"inputs": {
"agentId": "scheduler-memory-daily",
"promptPath": "",
"promptIntent": "scheduler memory retrieval",
"query": "scheduler | daily | agent | scheduler-memory-daily | intent | scheduler memory retrieval",
"events": 1
},
"outputs": {
"ingestedCount": 1,
"retrievedCount": 3
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_RETRIEVED
@@ -1,18 +0,0 @@
{
"cadence": "daily",
"operation": "store",
"runId": "session-mm42lcmi",
"servicePath": "src/services/memory/index.js#ingestEvents",
"inputs": {
"agentId": "scheduler-memory-daily",
"promptPath": "",
"promptIntent": "scheduler memory store",
"events": 0,
"sourceFile": "none"
},
"outputs": {
"storedCount": 0,
"summaries": []
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_STORED
@@ -1,18 +0,0 @@
{
"cadence": "daily",
"operation": "retrieve",
"runId": "session-mm44rb5x",
"servicePath": "src/services/memory/index.js#getRelevantMemories",
"inputs": {
"agentId": "scheduler-memory-daily",
"promptPath": "",
"promptIntent": "scheduler memory retrieval",
"query": "scheduler | daily | agent | scheduler-memory-daily | intent | scheduler memory retrieval",
"events": 1
},
"outputs": {
"ingestedCount": 1,
"retrievedCount": 3
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_RETRIEVED
@@ -1,18 +0,0 @@
{
"cadence": "daily",
"operation": "store",
"runId": "session-mm44s72x",
"servicePath": "src/services/memory/index.js#ingestEvents",
"inputs": {
"agentId": "scheduler-memory-daily",
"promptPath": "",
"promptIntent": "scheduler memory store",
"events": 0,
"sourceFile": "none"
},
"outputs": {
"storedCount": 0,
"summaries": []
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_STORED
@@ -1,18 +0,0 @@
{
"cadence": "daily",
"operation": "retrieve",
"runId": "session-mm4918uz",
"servicePath": "src/services/memory/index.js#getRelevantMemories",
"inputs": {
"agentId": "scheduler-memory-daily",
"promptPath": "",
"promptIntent": "scheduler memory retrieval",
"query": "scheduler | daily | agent | scheduler-memory-daily | intent | scheduler memory retrieval",
"events": 1
},
"outputs": {
"ingestedCount": 1,
"retrievedCount": 3
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_RETRIEVED
@@ -1,20 +0,0 @@
{
"cadence": "daily",
"operation": "store",
"runId": "session-mm49329u",
"servicePath": "src/services/memory/index.js#ingestEvents",
"inputs": {
"agentId": "scheduler-memory-daily",
"promptPath": "",
"promptIntent": "scheduler memory store",
"events": 1,
"sourceFile": "memory-update.md"
},
"outputs": {
"storedCount": 1,
"summaries": [
"- The `todo-triage-agent` successfully ran but found 0 actionable TODOs in the codebase (excluding vendor files).\n- The regex used `grep -n -E \"TODO|FIXME|XXX\" -- '*.js' '*.html' 'docs/*'` correctly filtered the files.\n- The `js/ui/profileModalContract.js` match was a string lit…"
]
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_STORED
@@ -1,18 +0,0 @@
{
"cadence": "daily",
"operation": "retrieve",
"runId": "session-mm4b6anr",
"servicePath": "src/services/memory/index.js#getRelevantMemories",
"inputs": {
"agentId": "scheduler-memory-daily",
"promptPath": "",
"promptIntent": "scheduler memory retrieval",
"query": "scheduler | daily | agent | scheduler-memory-daily | intent | scheduler memory retrieval",
"events": 1
},
"outputs": {
"ingestedCount": 1,
"retrievedCount": 5
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_RETRIEVED
@@ -1,20 +0,0 @@
{
"cadence": "daily",
"operation": "store",
"runId": "session-mm4b6zww",
"servicePath": "src/services/memory/index.js#ingestEvents",
"inputs": {
"agentId": "scheduler-memory-daily",
"promptPath": "",
"promptIntent": "scheduler memory store",
"events": 1,
"sourceFile": "memory-update.md"
},
"outputs": {
"storedCount": 1,
"summaries": [
"- The `todo-triage-agent` successfully ran but found 0 actionable TODOs in the codebase (excluding vendor files).\n- The regex used `grep -n -E \"TODO|FIXME|XXX\" -- '*.js' '*.html' 'docs/*'` correctly filtered the files.\n- The `js/ui/profileModalContract.js` match was a string lit…"
]
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_STORED
@@ -1,18 +0,0 @@
{
"cadence": "daily",
"operation": "retrieve",
"runId": "session-mm76k42d",
"servicePath": "src/services/memory/index.js#getRelevantMemories",
"inputs": {
"agentId": "scheduler-memory-daily",
"promptPath": "",
"promptIntent": "scheduler memory retrieval",
"query": "scheduler | daily | agent | scheduler-memory-daily | intent | scheduler memory retrieval",
"events": 1
},
"outputs": {
"ingestedCount": 1,
"retrievedCount": 5
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_RETRIEVED
@@ -1,20 +0,0 @@
{
"cadence": "daily",
"operation": "store",
"runId": "session-mm76kmwi",
"servicePath": "src/services/memory/index.js#ingestEvents",
"inputs": {
"agentId": "scheduler-memory-daily",
"promptPath": "",
"promptIntent": "scheduler memory store",
"events": 1,
"sourceFile": "memory-update.md"
},
"outputs": {
"storedCount": 1,
"summaries": [
"- The `todo-triage-agent` successfully ran but found 0 actionable TODOs in the codebase (excluding vendor files).\n- The regex used `grep -n -E \"TODO|FIXME|XXX\" -- '*.js' '*.html' 'docs/*'` correctly filtered the files.\n- The `js/ui/profileModalContract.js` match was a string lit…"
]
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_STORED
@@ -1,18 +0,0 @@
{
"cadence": "weekly",
"operation": "retrieve",
"runId": "session-mm9dheyw",
"servicePath": "src/services/memory/index.js#getRelevantMemories",
"inputs": {
"agentId": "bug-reproducer-agent",
"promptPath": "torch/src/prompts/weekly/bug-reproducer-agent.md",
"promptIntent": "Required startup + artifacts + memory + issue capture",
"query": "scheduler | weekly | agent | bug-reproducer-agent | intent | Required startup + artifacts + memory + issue capture",
"events": 1
},
"outputs": {
"ingestedCount": 1,
"retrievedCount": 1
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_RETRIEVED
@@ -1,20 +0,0 @@
{
"cadence": "weekly",
"operation": "store",
"runId": "session-mm9dimkw",
"servicePath": "src/services/memory/index.js#ingestEvents",
"inputs": {
"agentId": "bug-reproducer-agent",
"promptPath": "torch/src/prompts/weekly/bug-reproducer-agent.md",
"promptIntent": "Required startup + artifacts + memory + issue capture",
"events": 1,
"sourceFile": "memory-update.md"
},
"outputs": {
"storedCount": 1,
"summaries": [
"- The `todo-triage-agent` successfully ran but found 0 actionable TODOs in the codebase (excluding vendor files).\n- The regex used `grep -n -E \"TODO|FIXME|XXX\" -- '*.js' '*.html' 'docs/*'` correctly filtered the files.\n- The `js/ui/profileModalContract.js` match was a string lit…"
]
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_STORED
@@ -1,20 +0,0 @@
{
"cadence": "daily",
"operation": "store",
"runId": "session-mm9ie85o",
"servicePath": "src/services/memory/index.js#ingestEvents",
"inputs": {
"agentId": "docs-alignment-agent",
"promptPath": "torch/src/prompts/daily/docs-alignment-agent.md",
"promptIntent": "Required startup + artifacts + memory + issue capture",
"events": 1,
"sourceFile": "memory-update.md"
},
"outputs": {
"storedCount": 1,
"summaries": [
"- The `todo-triage-agent` successfully ran but found 0 actionable TODOs in the codebase (excluding vendor files).\n- The regex used `grep -n -E \"TODO|FIXME|XXX\" -- '*.js' '*.html' 'docs/*'` correctly filtered the files.\n- The `js/ui/profileModalContract.js` match was a string lit…"
]
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_STORED
@@ -1,18 +0,0 @@
{
"cadence": "daily",
"operation": "retrieve",
"runId": "session-mm9mheg9",
"servicePath": "src/services/memory/index.js#getRelevantMemories",
"inputs": {
"agentId": "governance-agent",
"promptPath": "torch/src/prompts/daily/governance-agent.md",
"promptIntent": "Required startup + artifacts + memory + issue capture",
"query": "scheduler | daily | agent | governance-agent | intent | Required startup + artifacts + memory + issue capture",
"events": 1
},
"outputs": {
"ingestedCount": 1,
"retrievedCount": 1
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_RETRIEVED
@@ -1,20 +0,0 @@
{
"cadence": "daily",
"operation": "store",
"runId": "session-mm9mjswq",
"servicePath": "src/services/memory/index.js#ingestEvents",
"inputs": {
"agentId": "governance-agent",
"promptPath": "torch/src/prompts/daily/governance-agent.md",
"promptIntent": "Required startup + artifacts + memory + issue capture",
"events": 1,
"sourceFile": "memory-update.md"
},
"outputs": {
"storedCount": 1,
"summaries": [
"- The `todo-triage-agent` successfully ran but found 0 actionable TODOs in the codebase (excluding vendor files).\n- The regex used `grep -n -E \"TODO|FIXME|XXX\" -- '*.js' '*.html' 'docs/*'` correctly filtered the files.\n- The `js/ui/profileModalContract.js` match was a string lit…"
]
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_STORED
@@ -1,18 +0,0 @@
{
"cadence": "daily",
"operation": "retrieve",
"runId": "session-mm9qlhbh",
"servicePath": "src/services/memory/index.js#getRelevantMemories",
"inputs": {
"agentId": "known-issues-agent",
"promptPath": "torch/src/prompts/daily/known-issues-agent.md",
"promptIntent": "Known Issues Agent",
"query": "scheduler | daily | agent | known-issues-agent | intent | Known Issues Agent",
"events": 1
},
"outputs": {
"ingestedCount": 1,
"retrievedCount": 1
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_RETRIEVED
@@ -1,20 +0,0 @@
{
"cadence": "daily",
"operation": "store",
"runId": "session-mm9qm84y",
"servicePath": "src/services/memory/index.js#ingestEvents",
"inputs": {
"agentId": "known-issues-agent",
"promptPath": "torch/src/prompts/daily/known-issues-agent.md",
"promptIntent": "Known Issues Agent",
"events": 1,
"sourceFile": "memory-update.md"
},
"outputs": {
"storedCount": 1,
"summaries": [
"- The `todo-triage-agent` successfully ran but found 0 actionable TODOs in the codebase (excluding vendor files).\n- The regex used `grep -n -E \"TODO|FIXME|XXX\" -- '*.js' '*.html' 'docs/*'` correctly filtered the files.\n- The `js/ui/profileModalContract.js` match was a string lit…"
]
},
"status": "ok"
}
@@ -1 +0,0 @@
MEMORY_STORED
+32 -42
View File
@@ -63,6 +63,15 @@ 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
@@ -244,44 +253,14 @@ 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 Claiming Protocol (TORCH)
### Task Coordination
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 `torch/TORCH.md` for the full protocol documentation.
**Before starting any task:**
1. **Check for existing claims:**
```bash
node torch/bin/torch-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 torch/bin/torch-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 torch/bin/torch-lock.mjs list
```
**Scheduler agents:** Daily and weekly scheduler agents must follow this protocol in addition to their directory-based rotation logic (`task-logs/daily/` and `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.
### TORCH Memory Integration
You have access to the TORCH memory system.
1. **READ:** Check `.scheduler-memory/latest/${cadence}/memories.md` for past learnings.
2. **WRITE:** Before exiting, save new insights to `memory-update.md` so future runs can learn from this session.
> 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).
### Currently In-Flight Work
@@ -682,6 +661,22 @@ This agent’s goal is NOT “make CI green.” Its goal is “make the suite re
---
## 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.
@@ -692,8 +687,3 @@ Please read these documents next.
And if you need to create new nostr kinds please keep the logic centralized there in `nostrEventSchemas.js` and the `nostr-event-schemas.md` up to date.
**End of AGENTS.md**
## TORCH Memory Integration
You have access to the TORCH memory system.
1. READ: Check `.scheduler-memory/latest/${cadence}/memories.md` for past learnings.
2. WRITE: Before exiting, save new insights to `memory-update.md` so future runs can learn from this session.
+12 -13
View File
@@ -310,6 +310,18 @@ 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)
@@ -557,13 +569,6 @@ When assigning work to agents:
- Review and merge agent PRs promptly to keep the queue short — stale PRs compound conflicts
- Use PR title prefixes (`[nostr-core]`, `[ui]`, `[playback]`, etc.) to make scope visible
### TORCH Memory Integration
You have access to the TORCH memory system.
1. **READ:** Check `.scheduler-memory/latest/${cadence}/memories.md` for past learnings.
2. **WRITE:** Before exiting, save new insights to `memory-update.md` so future runs can learn from this session.
---
## Key Documentation
@@ -577,7 +582,6 @@ You have access to the TORCH memory system.
| `docs/playback-fallback.md` | URL-first strategy |
| `docs/moderation/README.md` | Moderation system |
| `docs/logging.md` | Logger usage |
| `torch/TORCH.md` | TORCH distributed task locking protocol |
| `context/` / `todo/` / `decisions/` / `test_logs/` | Agent persistent state files (see AGENTS.md §15) |
---
@@ -636,8 +640,3 @@ Validation: Must have `title` + at least one of `url` or `magnet`.
5. **Token-first styling** — no raw colors, no inline styles
6. **Keep magnets raw** — decode only at playback time
7. **Document rollback steps** — especially for playback changes
## TORCH Memory Integration
You have access to the TORCH memory system.
1. READ: Check `.scheduler-memory/latest/${cadence}/memories.md` for past learnings.
2. WRITE: Before exiting, save new insights to `memory-update.md` so future runs can learn from this session.
+17
View File
@@ -21,3 +21,20 @@
- **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`.
## Run Notes (2026-06-20)
### 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.
### `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.
### `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.
+46 -6
View File
@@ -672,19 +672,39 @@
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 seed (ws=)
Web seeds (ws=, one per line)
</label>
<div class="flex flex-col gap-2 sm:flex-row">
<input
type="url"
<textarea
id="editVideoWs"
class="flex-1 input"
placeholder="https://cdn.example.com/video.mp4"
/>
rows="2"
class="flex-1 textarea"
placeholder="https://cdn.example.com/video.mp4&#10;https://backup.example.com/video.mp4"
></textarea>
<button
type="button"
class="btn-ghost focus-ring hidden text-xs font-semibold uppercase tracking-wide"
@@ -693,6 +713,10 @@
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">
@@ -756,6 +780,22 @@
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">
+145
View File
@@ -160,6 +160,28 @@
</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="profileNavHashtags"
type="button"
@@ -720,6 +742,12 @@
<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
@@ -808,6 +836,33 @@
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>
@@ -914,10 +969,100 @@
</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>
<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">
<h4 class="text-sm font-semibold text-text">
Orphaned storage objects
</h4>
<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="profilePaneHashtags"
class="profile-pane hidden space-y-6"
-13
View File
@@ -251,19 +251,6 @@
/>
<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
+3 -2
View File
@@ -376,8 +376,9 @@
<!-- 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 Seed (ws)</label>
<input id="input-ws" type="url" class="input text-sm" placeholder="https://..." />
<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>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Torrent File (xs)</label>
+4
View File
@@ -2721,6 +2721,10 @@
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 {
+11
View File
@@ -2,6 +2,17 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<!--
Content-Security-Policy for the embeddable player. Same strict script-src
as index.html (same inline theme-bootstrap hash). frame-ancestors is left
unset on purpose: this page is meant to be embedded anywhere. If you
change the inline theme script below, update the sha256 hash here
(tests/csp-policy.test.mjs enforces this).
-->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'sha256-dnv/l3q8gLqdw0etekr5/atoIRbM/wSVSgQjZEsC7G0=' https://cdn.jsdelivr.net https://esm.sh; style-src 'self' 'unsafe-inline'; img-src 'self' https: data: blob:; media-src 'self' https: blob: mediastream:; connect-src 'self' https: wss: ws://localhost:* ws://127.0.0.1:* blob: data:; worker-src 'self' blob:; font-src 'self' data:; frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; manifest-src 'self'"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>bitvid | Embed</title>
+19
View File
@@ -2,6 +2,25 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<!--
Content-Security-Policy: defense-in-depth against XSS. The critical
directive is script-src, which is strict — only same-origin scripts, the
hash of the inline theme-bootstrap below, and the nostr-tools CDN
fallbacks (js/nostrToolsBootstrap.js). No 'unsafe-inline'/'unsafe-eval'
(verified: no bundle uses eval/Function/wasm). This blocks injected
script and object elements, inline handlers (none exist), and base-tag
hijack, so an injected payload cannot execute and read the in-memory
storage master key / signer / DM plaintext. connect/img/media stay broad
because relays, R2/S3 endpoints, and video/thumbnail URLs are
user/content defined. If you change the inline theme script below, update
the sha256 hash here (tests/csp-policy.test.mjs enforces this).
NOTE: any future view loaded by js/viewManager.js must use external-src
script tags (script-src 'self'), not inline scripts.
-->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'sha256-dnv/l3q8gLqdw0etekr5/atoIRbM/wSVSgQjZEsC7G0=' https://cdn.jsdelivr.net https://esm.sh; style-src 'self' 'unsafe-inline'; img-src 'self' https: data: blob:; media-src 'self' https: blob: mediastream:; connect-src 'self' https: wss: ws://localhost:* ws://127.0.0.1:* blob: data:; worker-src 'self' blob:; font-src 'self' data:; frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; manifest-src 'self'"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>bitvid | Decentralized Video Sharing</title>
+116 -3
View File
@@ -57,6 +57,9 @@ import {
import watchHistoryService from "./watchHistoryService.js";
import r2Service from "./services/r2Service.js";
import storageService from "./services/storageService.js";
import { storageSyncService } from "./services/storageSyncService.js";
import { createWalletSyncService } from "./services/walletSyncService.js";
import { createSettingsRestorePrompt } from "./services/settingsRestorePrompt.js";
import {
getVideoNoteErrorMessage,
normalizeVideoNotePayload,
@@ -86,6 +89,8 @@ import { buildDmRelayListEvent, buildShareEvent } from "./nostrEventSchemas.js";
import {
publishEventToRelays,
assertAnyRelayAccepted,
describePublishOutcome,
readRelayPublishSummary,
} from "./nostrPublish.js";
import {
getActiveSigner,
@@ -647,6 +652,7 @@ class Application {
userBlocks,
subscriptions,
hashtagPreferences,
watchHistoryService,
storageService,
relayManager,
torrentClient,
@@ -1621,6 +1627,57 @@ class Application {
);
}
}
if (pubkey) {
// Best-effort, never blocks login: offer to restore encrypted settings
// synced from another device.
this.maybeOfferSettingsRestore(pubkey);
}
}
// One-time offer to restore encrypted storage/wallet settings on login (todo
// #15). Existence check is list-only (no decrypt); decryption happens only if
// the user accepts. Deferred so relays can connect first; failures are silent.
maybeOfferSettingsRestore(pubkey) {
const key =
this.normalizeHexPubkey(pubkey) ||
(typeof pubkey === "string" ? pubkey : "");
if (!key) {
return;
}
setTimeout(() => {
try {
if (!this._settingsRestorePrompt) {
this._settingsRestorePrompt = createSettingsRestorePrompt({
storageSync: storageSyncService,
walletSync: createWalletSyncService({
nwcSettings: this.nwcSettingsService,
}),
});
}
Promise.resolve(
this._settingsRestorePrompt.maybeOffer(key, {
onRestored: (items) => {
const hasStorage = items.includes("storage");
const hasWallet = items.includes("wallet");
const label =
hasStorage && hasWallet
? "storage and wallet settings"
: hasWallet
? "wallet connection"
: "storage settings";
this.showSuccess(
`Restored your ${label} from your Nostr account.`,
);
},
}),
).catch((error) => {
devLogger.warn("[settingsRestore] offer failed:", error);
});
} catch (error) {
devLogger.warn("[settingsRestore] offer setup failed:", error);
}
}, 2000);
}
async handleLoginModalError(payload = {}) {
@@ -3060,8 +3117,15 @@ class Application {
return false;
}
let relaySummary = null;
try {
await this.nostrService.publishVideoNote(publishPayload, this.pubkey);
const publishResult = await this.nostrService.publishVideoNote(
publishPayload,
this.pubkey,
);
// The legacy (kind 30078) event carries the relay tally attached by
// publishVideo (non-enumerable, so it never hit the wire).
relaySummary = readRelayPublishSummary(publishResult?.legacy) || null;
} catch (err) {
devLogger.error("Failed to publish video:", err);
this.showError("Failed to share video. Please try again later.");
@@ -3088,7 +3152,15 @@ class Application {
);
}
this.showSuccess("Video shared successfully!");
// Tell the user how many relays actually accepted the video, so a partial
// (or relay-list-wide) failure isn't hidden behind a generic confirmation.
const outcome = describePublishOutcome(relaySummary || {});
if (outcome.tone === "warning") {
this.showSuccess(outcome.message);
this.showStatus(outcome.message, { autoHideMs: 8000, showSpinner: false });
} else {
this.showSuccess(outcome.message);
}
if (loadVideosError) {
this.showStatus(
@@ -3279,9 +3351,15 @@ class Application {
typeof sessionActor?.source === "string"
? sessionActor.source.trim()
: "";
// The anonymous view-event actor is created with source "session" (see
// SignerManager.ensureSessionActor). It's telemetry, NOT an alternate login
// identity, so it must not disqualify a real login — otherwise watching any
// video flips isUserLoggedIn() to false and breaks zaps/etc. Only a managed
// real source (e.g. "nsec") for a DIFFERENT pubkey disqualifies.
const isManagedSource = Boolean(declaredSource) && declaredSource !== "session";
const isPersisted = sessionActor?.persisted === true;
if (!hasEmbeddedPrivateKey || declaredSource || isPersisted) {
if (!hasEmbeddedPrivateKey || isManagedSource || isPersisted) {
return false;
}
}
@@ -4538,8 +4616,43 @@ class Application {
confirm: false,
});
// Best-effort: remove the underlying R2/S3 object(s) now that the note(s)
// are gone, so the file isn't left publicly downloadable. This never
// blocks the delete — failures are logged only.
let storageLockedRemnant = false;
try {
const cleanup = await r2Service.deleteVideoStorage({
videos: [targetVideo],
pubkey: this.pubkey,
});
if (cleanup?.deleted?.length) {
devLogger.log(
`[delete] Removed ${cleanup.deleted.length} storage object(s) for the deleted video.`
);
} else if (cleanup?.skipped) {
devLogger.log(`[delete] Storage cleanup skipped: ${cleanup.reason}.`);
// The note is gone but the hosted file is still publicly downloadable
// because storage is locked. Warn the user so they can unlock and
// delete again (or remove it from their bucket manually).
if (cleanup.reason === "storage-locked") {
storageLockedRemnant = true;
}
}
} catch (cleanupErr) {
devLogger.warn(
"[delete] Storage cleanup failed (note deletion still succeeded):",
cleanupErr
);
}
await this.loadVideos();
this.showSuccess("All versions deleted successfully!");
if (storageLockedRemnant) {
this.showStatus(
"Heads up: the hosted file is still in your storage bucket because storage is locked. Unlock storage and delete again, or remove it from your bucket manually.",
{ autoHideMs: 12000, showSpinner: false }
);
}
this.deleteModal.close();
this.forceRefreshAllProfiles();
} catch (err) {
+56 -7
View File
@@ -14,6 +14,44 @@ import { clearWatchHistoryConversationKeyCache } from "../nostr/watchHistory.js"
import { clearWatchHistoryDecryptedChunkCache } from "../nostr/watchHistoryDecryptCache.js";
import { FEED_TYPES } from "../constants.js";
/**
* Kick a background watch-history refresh after login, once the feed-driving
* lists have already finished decrypting.
*
* `loadLatest({ allowStale: true })` returns immediately and runs the
* chunk-capped refresh in the background, so this is strictly fire-and-forget:
* it must never block, await, or throw into the login path. The For You feed
* re-runs on the watch-history "fingerprint" event once the refresh resolves.
*
* @param {object} args
* @param {{ loadLatest?: Function }} [args.watchHistoryService]
* @param {string} [args.activePubkey]
* @param {{ warn?: Function }} [args.devLogger]
* @returns {boolean} Whether a preload was scheduled.
*/
export function schedulePostLoginWatchHistoryPreload({
watchHistoryService,
activePubkey,
devLogger,
} = {}) {
if (!activePubkey || typeof watchHistoryService?.loadLatest !== "function") {
return false;
}
Promise.resolve()
.then(() =>
watchHistoryService.loadLatest(activePubkey, { allowStale: true }),
)
.catch((error) => {
devLogger?.warn?.(
"[Application] Background watch-history preload after login failed:",
error,
);
});
return true;
}
/**
* @param {object} deps - Injected dependencies.
* @returns {object} Methods to be bound to the Application instance.
@@ -27,6 +65,7 @@ export function createAuthSessionCoordinator(deps) {
userBlocks,
subscriptions,
hashtagPreferences,
watchHistoryService,
storageService,
relayManager,
torrentClient,
@@ -492,13 +531,6 @@ export function createAuthSessionCoordinator(deps) {
const taskOutcomes = await Promise.all(parallelListTasks);
// NOTE: watch history is intentionally NOT eagerly preloaded on login.
// A cold watch history is ~150+ serialized nip-07 decrypts; preloading it
// here (even after the lists) saturated the extension and starved the
// profile modal's hashtag/subscription/DM loads. It now loads lazily when
// the For You feed or History view is opened. Re-enable an eager preload
// only once the cold-load decrypt cost is reduced (see task: ~180 calls).
const requiredTaskOutcomes = taskOutcomes.filter((outcome) =>
["blocks", "subscriptions", "hashtags"].includes(outcome.name),
);
@@ -615,6 +647,23 @@ export function createAuthSessionCoordinator(deps) {
pubkey: activePubkey,
authLoadingState: this.authLoadingState,
});
// Warm the watch history in the background now that the feed-driving
// lists (blocks/subscriptions/hashtags) have finished decrypting. This
// runs AFTER the required lists — never concurrently — so it does not
// re-introduce the signer starvation that previously made this an
// intentionally-deferred load. The cold load is now bounded to a few
// decrypts + deferred backfill (not the old ~150 serialized decrypts),
// so the For You feed's suppression and tag-affinity are ready by the
// time the user reaches it instead of waiting until the For You /
// History view is opened. Fire-and-forget: the feed re-runs on the
// watch-history "fingerprint" event.
schedulePostLoginWatchHistoryPreload({
watchHistoryService,
activePubkey,
devLogger,
});
return listSyncDetail;
});
+93 -4
View File
@@ -9,6 +9,28 @@
* Methods use `this` which is bound to the Application instance.
*/
/**
* Normalize a deep-link / caller "force this playback source" value to the
* canonical 'url' | 'torrent' the playback pipeline understands, or null when
* unset/unrecognized. Accepts a few friendly aliases (the in-modal toggle
* labels CDN/P2P, plus webtorrent/hosted) so shared links are forgiving.
* @param {unknown} value
* @returns {'url' | 'torrent' | null}
*/
export function normalizeForcedSource(value) {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
if (["torrent", "p2p", "webtorrent", "magnet"].includes(normalized)) {
return "torrent";
}
if (["url", "cdn", "hosted", "http", "https"].includes(normalized)) {
return "url";
}
return null;
}
/**
* @param {object} deps - Injected dependencies.
* @returns {object} Methods to be bound to the Application instance.
@@ -47,6 +69,14 @@ export function createPlaybackCoordinator(deps) {
const maybeNevent = urlParams.get("v");
if (!maybeNevent) return; // no link param
// Optional deep-link override that forces a playback source, mirroring the
// embed player's `playback` param. Lets you test a specific path
// consistently: `?v=<nevent>&playback=torrent` always streams via
// WebTorrent, `&playback=url` always uses the hosted CDN URL. If the
// requested source is missing for the video, playVideoByEventId surfaces
// a message instead of silently using the other source.
const forcedSource = normalizeForcedSource(urlParams.get("playback"));
try {
const decoded = window.NostrTools.nip19.decode(maybeNevent);
if (decoded.type === "nevent" && decoded.data.id) {
@@ -58,13 +88,13 @@ export function createPlaybackCoordinator(deps) {
// 1) check local map
let localMatch = this.videosMap.get(eventId);
if (localMatch) {
this.playVideoByEventId(eventId, { relay });
this.playVideoByEventId(eventId, { relay, forcedSource });
} else {
// 2) fallback => getOldEventById
this.getOldEventById(eventId)
.then((video) => {
if (video) {
this.playVideoByEventId(eventId, { relay });
this.playVideoByEventId(eventId, { relay, forcedSource });
} else {
this.showError("No matching video found for that link.");
}
@@ -332,7 +362,7 @@ export function createPlaybackCoordinator(deps) {
async playViaWebTorrent(
magnet,
{ fallbackMagnet = "", urlList = [] } = {}
{ fallbackMagnet = "", urlList = [], hostedUrlWebSeed = "" } = {}
) {
const sanitizedUrlList = Array.isArray(urlList)
? urlList
@@ -341,6 +371,11 @@ export function createPlaybackCoordinator(deps) {
)
.filter((entry) => /^https?:\/\//i.test(entry))
: [];
const sanitizedHostedWebSeed =
typeof hostedUrlWebSeed === "string" &&
/^https?:\/\//i.test(hostedUrlWebSeed.trim())
? hostedUrlWebSeed.trim()
: "";
const attemptStream = async (candidate) => {
const trimmedCandidate =
@@ -386,7 +421,38 @@ export function createPlaybackCoordinator(deps) {
const torrentInstance = await torrentClient.streamVideo(
cacheBustedMagnet,
this.modalVideo,
{ urlList: sanitizedUrlList }
{
urlList: sanitizedUrlList,
hostedUrlWebSeed: sanitizedHostedWebSeed,
hooks: {
// Surface a mid-stream stall instead of silently freezing on a
// buffering frame: tell the user whether the swarm is just slow
// or has no seeders at all.
onStall: ({ peers } = {}) => {
if (!this.videoModal) {
return;
}
this.videoModal.updateStatus(
peers > 0
? "Buffering — the swarm is slow right now."
: "Waiting for peers — this video may have no active seeders."
);
},
// A torrent error after playback started can't reject the (already
// settled) start promise, so report it here.
onPlaybackError: (error) => {
this.log(
"[playViaWebTorrent] Torrent error during playback:",
error
);
if (this.videoModal) {
this.videoModal.updateStatus(
"WebTorrent playback hit an error — the swarm may have dropped."
);
}
},
},
}
);
if (torrentClient.isServiceWorkerUnavailable()) {
@@ -801,6 +867,29 @@ export function createPlaybackCoordinator(deps) {
playbackOptions.trigger = this.lastModalTrigger;
}
// Honor a forced-source deep link (e.g. ?playback=torrent). If the video
// actually has that source, force it so the test path is deterministic.
// If it does not, tell the user clearly and fall back to normal selection
// rather than opening a dead player.
const requestedSource = normalizeForcedSource(hint.forcedSource);
if (requestedSource === "torrent") {
if (sanitizedMagnet) {
playbackOptions.forcedSource = "torrent";
} else {
this.showError(
"This video has no WebTorrent source — nothing to stream over P2P.",
);
}
} else if (requestedSource === "url") {
if (trimmedUrl) {
playbackOptions.forcedSource = "url";
} else {
this.showError(
"This video has no hosted CDN URL — only a WebTorrent source is available.",
);
}
}
let playbackPromise = null;
if (this.shouldDeferModeratedPlayback(this.currentVideo)) {
const pendingVideoId =
-1
View File
@@ -521,5 +521,4 @@ export const FEED_TYPES = Object.freeze({
CHANNEL: "channel-profile",
DOCS: "docs",
SEARCH: "search",
DEV_DASHBOARD: "dev/agent-dashboard",
});
+11 -3
View File
@@ -2,6 +2,7 @@
import { pointerKey, normalizePointerInput } from "../nostr/watchHistory.js";
import { convertEventToVideo } from "../nostr/index.js";
import { DEFAULT_VIDEO_KIND } from "../utils/videoPointer.js";
import watchHistoryService from "../watchHistoryService.js";
import nostrService from "../services/nostrService.js";
import { nostrClient } from "../nostrClientFacade.js";
@@ -237,9 +238,16 @@ function resolveEventAddress(event) {
if (!event || typeof event !== "object") {
return "";
}
const kind = Number(event.kind);
// Converted video objects (convertEventToVideo) carry tags/pubkey but NOT a
// `kind` field — only raw relay events do. Watch-history pointers are always
// video-kind addresses, so default to the video kind when it's absent.
// Without this, the cache-by-address scan produced "" for every cached video
// and never matched, forcing a relay round-trip that left freshly-watched
// videos (already sitting in allEvents) showing "Unknown".
const rawKind = Number(event.kind);
const kind = Number.isFinite(rawKind) ? rawKind : DEFAULT_VIDEO_KIND;
const pubkey = typeof event.pubkey === "string" ? event.pubkey.toLowerCase() : "";
if (!Number.isFinite(kind) || !pubkey) {
if (!pubkey) {
return "";
}
const dTag = event.tags?.find((t) => t[0] === "d" && t[1]);
@@ -266,7 +274,7 @@ function checkLocalCacheForVideo(pointer) {
return null;
}
function createWatchHistoryHydrationStage() {
export function createWatchHistoryHydrationStage() {
return async function watchHistoryHydrationStage(items = [], context = {}) {
debugInfo(`Starting hydration stage for ${items.length} items.`);
const missing = [];
+6 -1
View File
@@ -230,7 +230,12 @@ function queueProbe(magnet, cacheKey, priority = 0, webSeeds = []) {
const PROBE_CACHE_TTL_MS = FIVE_MINUTES_MS;
const PROBE_TIMEOUT_MS = 20000;
const PROBE_CONCURRENCY = 96;
// Each probe is a real WebTorrent swarm join (tracker announces + peer
// connections), NOT a cheap ping. At 96 concurrent, loading/scrolling the feed
// opened hundreds-to-thousands of simultaneous WebSocket/WebRTC connections,
// pegging the main thread and exhausting memory (freeze + tab crash). Keep this
// small — badges still populate, just a few at a time.
const PROBE_CONCURRENCY = 4;
const PROBE_POLL_COUNT = 3;
class ProbeQueue {
+30 -2
View File
@@ -16,6 +16,7 @@ import {
} from "./config.js";
import { getApplication } from "./applicationContext.js";
import { devLogger, userLogger } from "./utils/logger.js";
import { logWatchHistoryDebug } from "./watchHistoryDebug.js";
import {
normalizeVideoModerationContext,
applyModerationContextDatasets,
@@ -326,18 +327,38 @@ async function defaultRemoveHandler({
})
.filter(Boolean)
: [];
logWatchHistoryDebug("watch-history:remove", "info", "defaultRemoveHandler", {
actor,
sanitizedCount: sanitized.length,
values: sanitized.map((p) => p?.value).slice(0, 20),
hasSnapshot: typeof snapshot === "function",
});
if (typeof snapshot === "function") {
await snapshot(sanitized, { actor, reason });
// Removal replaces the list — without this the snapshot would merge the
// removed item back from cache and the deletion would have no effect.
const snapResult = await snapshot(sanitized, { actor, reason, replace: true });
logWatchHistoryDebug("watch-history:remove", "info", "defaultRemoveHandler snapshot", {
ok: snapResult?.ok,
empty: snapResult?.empty,
skipped: snapResult?.skipped,
});
}
try {
if (typeof updateWatchHistoryList === "function") {
await updateWatchHistoryList(sanitized, {
const updateResult = await updateWatchHistoryList(sanitized, {
actorPubkey: actor,
replace: true,
source: reason
});
logWatchHistoryDebug("watch-history:remove", "info", "defaultRemoveHandler list update", {
ok: updateResult?.ok,
finalItemCount: updateResult?.items?.length,
});
}
} catch (error) {
logWatchHistoryDebug("watch-history:remove", "warn", "defaultRemoveHandler failed", {
error: error?.message || String(error),
});
if (isDevEnv) {
userLogger.warn("[historyView] Failed to update watch history list:", error);
}
@@ -2319,6 +2340,13 @@ export function createWatchHistoryRenderer(config = {}) {
});
}
const feedItems = Array.isArray(result?.items) ? result.items : [];
logWatchHistoryDebug("watch-history:read", "info", "loadHistory result", {
force: force === true,
itemCount: feedItems.length,
values: feedItems
.map((entry) => entry?.pointer?.value || entry?.value)
.slice(0, 20),
});
const normalized = [];
for (const entry of feedItems) {
const pointer = normalizePointerInput(entry?.pointer || entry);
+101
View File
@@ -421,3 +421,104 @@ export function extractMagnetHints(rawValue) {
}
return hints;
}
/**
* Extract every web seed (`ws=`) URL from a magnet, in order, deduped
* case-insensitively. Unlike {@link extractMagnetHints} (which returns only the
* first `ws`), this surfaces the full set so callers can round-trip a magnet
* that carries multiple independent webseeds without silently dropping the
* extras.
*
* @param {string} rawValue
* @returns {string[]} Deduped web seed URLs in magnet order.
*/
export function extractAllWebSeeds(rawValue) {
const { params } = normalizeMagnetInput(rawValue);
const seeds = [];
const seen = new Set();
for (const param of params) {
if (param.lowerKey !== "ws") {
continue;
}
const value =
typeof (param.decoded || param.value) === "string"
? (param.decoded || param.value).trim()
: "";
if (!value) {
continue;
}
const key = value.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
seeds.push(value);
}
return seeds;
}
/**
* Normalize a web seed field into an ordered, deduped list of trimmed URLs.
* Accepts an array (e.g. [primaryUrl, textareaValue]) or a string, and splits
* every entry on newlines/commas so a single multi-line textarea expands into
* the full set. Lets a video declare multiple independent webseeds instead of
* collapsing to one.
*
* @param {string|string[]} value
* @returns {string[]}
*/
export function normalizeWebSeedList(value) {
const out = [];
const seen = new Set();
const push = (entry) => {
if (typeof entry !== "string") {
return;
}
for (const piece of entry.split(/[\r\n,]+/)) {
const trimmed = piece.trim();
if (!trimmed) {
continue;
}
const key = trimmed.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
out.push(trimmed);
}
};
if (Array.isArray(value)) {
value.forEach(push);
} else if (typeof value === "string") {
push(value);
}
return out;
}
/**
* Replace the complete set of web seeds (`ws=`) on a magnet with exactly the
* provided list. Unlike {@link ensureWebSeeds} (append-only), this first drops
* every existing `ws=` param, giving the edit form true add/remove semantics —
* a seed the user deleted from the list is actually removed from the magnet
* instead of lingering.
*
* @param {string} rawValue
* @param {string|string[]} seeds
* @param {{ allowHttp?: boolean, logger?: Function }} [options]
* @returns {string} The rebuilt magnet (or the canonical input when not a magnet).
*/
export function setMagnetWebSeeds(rawValue, seeds, { allowHttp = false, logger } = {}) {
const { initial, canonicalValue, isMagnet, normalizedScheme, fragment, params } =
normalizeMagnetInput(rawValue);
if (!initial) {
return "";
}
if (!isMagnet) {
return canonicalValue;
}
const kept = params.filter((param) => param.lowerKey !== "ws");
params.length = 0;
params.push(...kept);
ensureWebSeeds(params, normalizeWebSeedList(seeds), { allowHttp, logger });
return buildMagnetUri(normalizedScheme, params, fragment);
}
+233 -12
View File
@@ -61,6 +61,7 @@ import {
removeWatchHistoryItem as removeWatchHistoryItemWithManager,
fetchWatchHistory as fetchWatchHistoryWithManager,
resolveWatchHistory as resolveWatchHistoryWithManager,
getWatchHistoryDeferredChunkCount as getWatchHistoryDeferredChunkCountFromManager,
} from "./watchHistory.js";
import {
buildVideoPostEvent,
@@ -106,6 +107,7 @@ import {
publishEventToRelay,
publishEventToRelays,
assertAnyRelayAccepted,
attachRelayPublishSummary,
} from "../nostrPublish.js";
import {
signEventWithPrivateKey,
@@ -120,6 +122,7 @@ import {
DEFAULT_RELAY_URLS,
RELAY_URLS,
capReadRelays,
buildFeedRelaySet,
ensureNostrTools,
getCachedNostrTools,
nostrToolsBootstrapFailure,
@@ -978,6 +981,12 @@ export class NostrClient {
options,
);
}
getWatchHistoryDeferredChunkCount(actorInput) {
return getWatchHistoryDeferredChunkCountFromManager(
this.watchHistory,
actorInput,
);
}
async listVideoViewEvents(pointer, options = {}) {
return listVideoViewEventsForClient(this, pointer, options);
}
@@ -1158,6 +1167,26 @@ export class NostrClient {
return this.connectionManager.getHealthyRelays(candidates);
}
/**
* Resolve the relay set for deletion/tombstone events.
*
* A delete MUST reach every relay the video could have been published to,
* otherwise relays outside the set keep serving the original event and the
* "deleted" video resurrects on the next load. Normal publishes use the
* (uncapped) write set, but the delete paths historically used `this.relays`
* — the CAPPED read set (≤8) — so a video published broadly was only
* tombstoned on a subset. Mirror publishVideo: prefer the write set, fall
* back to the read set, then the bundled defaults.
*/
getDeletePublishRelays() {
const write = sanitizeRelayList(
Array.isArray(this.writeRelays) && this.writeRelays.length
? this.writeRelays
: this.relays,
);
return write.length ? write : Array.from(RELAY_URLS);
}
getDmDecryptCacheLimit() {
return DM_DECRYPT_CACHE_LIMIT;
}
@@ -2501,21 +2530,192 @@ export class NostrClient {
* @param {string} [opts.pubkey] - Optional hex public key.
* @returns {object} The created signer adapter.
*/
async registerPrivateKeySigner(opts) {
async registerPrivateKeySigner(opts = {}) {
const adapter = await createNsecAdapter(opts);
// Enforce access control (blacklist / whitelist) BEFORE activating the
// signer. The validator was previously accepted but ignored, so nsec login
// bypassed the access gate that NIP-07 and NIP-46 logins enforce.
if (typeof opts.validator === "function") {
try {
opts.validator(adapter.pubkey);
} catch (error) {
// Don't leave a since-blocked key persisted on this device.
try {
clearStoredSessionActorEntry();
} catch (cleanupError) {
devLogger.warn(
"[nostr] Failed to clear stored session after access denial:",
cleanupError,
);
}
throw error;
}
}
this.signerManager.setActiveSigner(adapter);
if (opts.privateKey && typeof opts.privateKey === "string") {
const normalizedPrivateKey = opts.privateKey.trim().toLowerCase();
this.sessionActor = {
pubkey: adapter.pubkey,
privateKey: opts.privateKey.trim().toLowerCase(),
privateKey: normalizedPrivateKey,
source: "nsec",
};
// Persist the key, encrypted with the user's passphrase, when they opted
// to remember it. Uses the canonical { privateKeyEncrypted, encryption }
// shape that readStoredSessionActorEntry / decryptSessionPrivateKey
// expect — the previous shape was silently rejected, so "remember this
// key" never actually stored anything.
if (
opts.persist === true &&
typeof opts.passphrase === "string" &&
opts.passphrase.trim()
) {
try {
const encrypted = await encryptSessionPrivateKey(
normalizedPrivateKey,
opts.passphrase,
);
persistSessionActorEntry({
pubkey: adapter.pubkey,
privateKeyEncrypted: encrypted.ciphertext,
encryption: {
salt: encrypted.salt,
iv: encrypted.iv,
iterations: encrypted.iterations,
hash: encrypted.hash,
algorithm: encrypted.algorithm,
version: encrypted.version,
},
});
this.sessionActor.persisted = true;
} catch (error) {
devLogger.warn("[nostr] Failed to persist private key signer:", error);
}
}
}
return adapter;
}
// Metadata about a stored, passphrase-encrypted nsec session (no secrets).
// The login modal reads `hasEncryptedKey` to offer the "unlock saved key"
// flow; without this the unlock UI never appeared.
getStoredSessionActorMetadata() {
let entry = null;
try {
entry = readStoredSessionActorEntry();
} catch (error) {
devLogger.warn(
"[nostr] Failed to read stored session actor metadata:",
error,
);
return null;
}
if (!entry || !entry.privateKeyEncrypted || !entry.encryption) {
return null;
}
return {
hasEncryptedKey: true,
pubkey: typeof entry.pubkey === "string" ? entry.pubkey : "",
source: "nsec",
createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : null,
};
}
// Restore a persisted nsec session by decrypting it with the user's
// passphrase and re-activating the signer via the same path as fresh login.
// The access-control validator is enforced before activation, and a
// now-blocked key is forgotten.
async unlockStoredSessionActor(passphrase, { validator } = {}) {
if (typeof passphrase !== "string" || !passphrase.trim()) {
const error = new Error("A passphrase is required to unlock the saved key.");
error.code = "passphrase-required";
throw error;
}
const entry = readStoredSessionActorEntry();
if (!entry || !entry.privateKeyEncrypted || !entry.encryption) {
const error = new Error("There is no saved key to unlock.");
error.code = "no-stored-session";
throw error;
}
const storedPubkey =
typeof entry.pubkey === "string" && HEX64_REGEX.test(entry.pubkey.toLowerCase())
? entry.pubkey.toLowerCase()
: "";
if (storedPubkey && typeof validator === "function") {
try {
validator(storedPubkey);
} catch (error) {
try {
clearStoredSessionActorEntry();
} catch (cleanupError) {
devLogger.warn(
"[nostr] Failed to clear stored session after access denial:",
cleanupError,
);
}
throw error;
}
}
let privateKeyHex = "";
try {
privateKeyHex = await decryptSessionPrivateKey(
{
privateKeyEncrypted: entry.privateKeyEncrypted,
encryption: entry.encryption,
},
passphrase,
);
} catch (error) {
const failure = new Error(
"Failed to unlock the saved key. Check your passphrase and try again.",
);
failure.code =
error?.code === "decrypt-failed" ? "decrypt-failed" : "unlock-failed";
failure.cause = error;
throw failure;
}
const normalizedPrivateKey =
typeof privateKeyHex === "string" ? privateKeyHex.trim().toLowerCase() : "";
if (!HEX64_REGEX.test(normalizedPrivateKey)) {
const error = new Error("The saved key is corrupt and cannot be used.");
error.code = "stored-key-invalid";
throw error;
}
let pubkey = storedPubkey;
if (!pubkey) {
const tools = (await ensureNostrTools()) || getCachedNostrTools();
if (tools && typeof tools.getPublicKey === "function") {
pubkey = tools.getPublicKey(normalizedPrivateKey);
}
}
const adapter = await this.registerPrivateKeySigner({
privateKey: normalizedPrivateKey,
pubkey,
persist: false,
validator,
});
if (this.sessionActor && typeof this.sessionActor === "object") {
this.sessionActor.persisted = true;
}
const resolvedPubkey =
(adapter && typeof adapter.pubkey === "string" && adapter.pubkey) ||
pubkey ||
"";
return { pubkey: resolvedPubkey };
}
/**
* Publishes a fresh Video Series (Kind 30078) to the network.
* This is the entry point for creating new content.
@@ -2546,12 +2746,20 @@ export class NostrClient {
try {
// 1. Publish the primary Video Note (Kind 30078)
const { signedEvent } = await this.signAndPublishEvent(context.event, {
context: "video note",
logName: "Video note",
devLogLabel: "video note",
resolveActiveSigner: (p) => this.signerManager.resolveActiveSigner(p),
});
const { signedEvent, summary } = await this.signAndPublishEvent(
context.event,
{
context: "video note",
logName: "Video note",
devLogLabel: "video note",
resolveActiveSigner: (p) => this.signerManager.resolveActiveSigner(p),
},
);
// Stash a compact relay tally on the returned event (non-enumerable so it
// never leaks into serialization/relay logic) so the UI can report how
// many relays actually accepted the video.
attachRelayPublishSummary(signedEvent, summary);
// 2. Publish NIP-94 Mirror (Kind 1063) if a hosted URL is present.
await handlePublishNip94(this, signedEvent, context);
@@ -2694,6 +2902,10 @@ export class NostrClient {
return this.signerManager.installNip46Client(client, options);
}
async prepareRemoteSignerHandshake(params) {
return this.signerManager.prepareRemoteSignerHandshake(params);
}
async connectRemoteSigner(params) {
return this.signerManager.connectRemoteSigner(params);
}
@@ -2782,6 +2994,9 @@ export class NostrClient {
baseEvent = {
id: fetched.id,
pubkey: fetched.pubkey,
// Carry created_at so the tombstone can be made strictly newer than the
// version it replaces (see buildRevertVideoPayload monotonicity guard).
created_at: fetched.created_at,
content: JSON.stringify({
version: fetched.version,
deleted: fetched.deleted,
@@ -2843,7 +3058,7 @@ export class NostrClient {
const signedEvent = await queueSignEvent(signer, event);
const publishResults = await publishEventToRelays(
this.pool,
this.relays,
this.getDeletePublishRelays(),
signedEvent
);
@@ -3006,6 +3221,9 @@ export class NostrClient {
{
id: vid.id,
pubkey: vid.pubkey,
// Preserve the original timestamp so the tombstone is published with
// a strictly-newer created_at and actually replaces the live version.
created_at: vid.created_at,
content: JSON.stringify(contentPayload),
tags: Array.isArray(vid.tags) ? vid.tags : [],
},
@@ -3168,7 +3386,7 @@ export class NostrClient {
const signedDelete = await queueSignEvent(signer, deleteEvent);
const publishResults = await publishEventToRelays(
this.pool,
this.relays,
this.getDeletePublishRelays(),
signedDelete,
{ waitForAll: true }
);
@@ -3386,7 +3604,11 @@ export class NostrClient {
getPool: () => this.pool,
getDefaultRelays: () => {
const healthy = sanitizeRelayList(this.getHealthyRelays(this.relays));
return healthy.length ? healthy : Array.from(DEFAULT_RELAY_URLS);
// Guarantee reliable default aggregators are always in the feed set,
// even when the user's own relays survive the health filter but are
// actually dead — otherwise the feed subscribes to a fully-broken
// list and hangs forever on "Fetching…".
return buildFeedRelaySet(healthy, this.relays);
},
// Own registry, isolated from the global singleton (which other
// subsystems use for their own keys) and from other client instances.
@@ -4106,4 +4328,3 @@ export {
resolveActiveSigner,
shouldRequestExtensionPermissions,
};
const a = 1
+335
View File
@@ -0,0 +1,335 @@
// Opt-in encrypted cross-login sync of bitvid settings (storage credentials, NWC
// wallet) published as NIP-78 app-data (kind 30078) *replaceable* events,
// encrypted to the user's own key.
//
// Security model (see todo item #15 — keep this in sync with the opt-in UX copy):
// - Content is NIP-44-encrypted to the user's OWN pubkey (NIP-04 fallback only if
// the signer lacks NIP-44, e.g. some NIP-46 remotes). The blob lives on PUBLIC
// relays: encryption hides the contents, NOT the fact that this pubkey stores
// bitvid settings.
// - Anyone who controls the user's Nostr key can decrypt these — the same trust
// root as everything else they sign. The opt-in copy must say so.
// - NWC URIs are bearer SPENDING secrets; callers must gate that sync behind an
// explicit warning.
// - Never log decrypted payloads or ciphertext.
// - Publish to WRITE relays so the update reaches everywhere; reads take the
// NEWEST event per d-tag so a stale device can't clobber newer settings.
// - created_at is forced strictly-newer than any known prior event so a replace
// actually wins.
//
// This module is pure / dependency-injected so it can be unit-tested without a
// live signer or relay pool.
export const APP_DATA_KIND = 30078;
export const ENCRYPTED_SYNC_VERSION = 1;
function nowSeconds(now) {
const ms = typeof now === "function" ? now() : Date.now();
return Math.floor(ms / 1000);
}
function normalizeDTag(value) {
return typeof value === "string" ? value.trim() : "";
}
function normalizePubkey(value) {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
async function encryptToSelf(signer, pubkey, plaintext) {
const caps = signer.capabilities || { nip04: true, nip44: true };
if (caps.nip44 && typeof signer.nip44Encrypt === "function") {
try {
const ciphertext = await signer.nip44Encrypt(pubkey, plaintext);
return { alg: "nip44", ciphertext };
} catch (err) {
// Fall through to NIP-04 for signers whose NIP-44 path is unavailable.
}
}
if (caps.nip04 && typeof signer.nip04Encrypt === "function") {
const ciphertext = await signer.nip04Encrypt(pubkey, plaintext);
return { alg: "nip04", ciphertext };
}
throw new Error("Signer does not support encryption (NIP-04 or NIP-44).");
}
async function decryptFromSelf(signer, pubkey, alg, ciphertext) {
const caps = signer.capabilities || { nip04: true, nip44: true };
if (alg === "nip44" && caps.nip44 && typeof signer.nip44Decrypt === "function") {
return signer.nip44Decrypt(pubkey, ciphertext);
}
if ((alg === "nip04" || !alg) && caps.nip04 && typeof signer.nip04Decrypt === "function") {
return signer.nip04Decrypt(pubkey, ciphertext);
}
throw new Error(`Signer cannot decrypt ${alg || "nip04"} content.`);
}
// Replaceable semantics: of all events a relay returns for a (kind, author, d),
// only the newest created_at is authoritative.
function selectNewest(events) {
let newest = null;
for (const ev of Array.isArray(events) ? events : []) {
if (!ev || typeof ev !== "object") {
continue;
}
const createdAt = Number(ev.created_at);
if (!Number.isFinite(createdAt)) {
continue;
}
if (!newest || createdAt > Number(newest.created_at)) {
newest = ev;
}
}
return newest;
}
export function createEncryptedSyncManager(deps = {}) {
const {
getActivePubkey,
getSigner,
getWriteRelays,
getReadRelays,
getPool,
listEvents,
publishEventToRelays,
summarizePublishResults,
signEvent,
now,
} = deps;
function resolvePubkey() {
return normalizePubkey(
typeof getActivePubkey === "function" ? getActivePubkey() : "",
);
}
function resolveSigner() {
return typeof getSigner === "function" ? getSigner() : null;
}
function isAvailable() {
const pubkey = resolvePubkey();
const signer = resolveSigner();
if (!pubkey || !signer) {
return false;
}
const caps = signer.capabilities || { nip04: true, nip44: true };
return Boolean(
(caps.nip44 && typeof signer.nip44Encrypt === "function") ||
(caps.nip04 && typeof signer.nip04Encrypt === "function"),
);
}
function resolveCreatedAt(options) {
return Math.max(
nowSeconds(now),
(Number(options?.afterCreatedAt) || 0) + 1,
);
}
async function publishTemplate(template) {
let signed;
try {
signed = await signEvent(template);
} catch (err) {
return { ok: false, error: "sign-failed", cause: err };
}
const relays =
(typeof getWriteRelays === "function" ? getWriteRelays() : []) || [];
const pool = typeof getPool === "function" ? getPool() : null;
if (!pool || !relays.length) {
return { ok: false, error: "no-relays" };
}
const results = await publishEventToRelays(pool, relays, signed);
const { accepted, failed } = summarizePublishResults(results);
return {
ok: accepted.length > 0,
accepted: accepted.length,
total: accepted.length + failed.length,
event: signed,
createdAt: template.created_at,
};
}
async function push(dTag, payload, options = {}) {
const tag = normalizeDTag(dTag);
if (!tag) {
return { ok: false, error: "missing-d-tag" };
}
const pubkey = resolvePubkey();
const signer = resolveSigner();
if (!pubkey || !signer) {
return { ok: false, error: "not-authenticated" };
}
let plaintext;
try {
plaintext = JSON.stringify(payload ?? null);
} catch (err) {
return { ok: false, error: "payload-not-serializable" };
}
let envelope;
try {
envelope = await encryptToSelf(signer, pubkey, plaintext);
} catch (err) {
return { ok: false, error: "encrypt-failed", cause: err };
}
const template = {
kind: APP_DATA_KIND,
pubkey,
created_at: resolveCreatedAt(options),
tags: [
["d", tag],
["client", "bitvid"],
],
content: JSON.stringify({
v: ENCRYPTED_SYNC_VERSION,
alg: envelope.alg,
data: envelope.ciphertext,
}),
};
return publishTemplate(template);
}
async function pull(dTag) {
const tag = normalizeDTag(dTag);
if (!tag) {
return { found: false, error: "missing-d-tag" };
}
const pubkey = resolvePubkey();
const signer = resolveSigner();
if (!pubkey || !signer) {
return { found: false, error: "not-authenticated" };
}
const relays =
(typeof getReadRelays === "function" ? getReadRelays() : []) || [];
if (typeof listEvents !== "function" || !relays.length) {
return { found: false, error: "no-relays" };
}
// Reads go through the injected listEvents (subscription manager in prod) —
// never pool.list directly (lint:pool-access).
let events = [];
try {
events = await listEvents(relays, [
{ kinds: [APP_DATA_KIND], authors: [pubkey], "#d": [tag], limit: 20 },
]);
} catch (err) {
return { found: false, error: "fetch-failed", cause: err };
}
const newest = selectNewest(Array.isArray(events) ? events.flat() : []);
if (!newest) {
return { found: false };
}
const rawContent =
typeof newest.content === "string" ? newest.content.trim() : "";
if (!rawContent) {
return { found: false, cleared: true, createdAt: newest.created_at };
}
let parsed;
try {
parsed = JSON.parse(rawContent);
} catch (err) {
return { found: false, error: "corrupt-content", createdAt: newest.created_at };
}
if (parsed && parsed.cleared === true) {
return { found: false, cleared: true, createdAt: newest.created_at };
}
let plaintext;
try {
plaintext = await decryptFromSelf(signer, pubkey, parsed?.alg, parsed?.data);
} catch (err) {
return { found: false, error: "decrypt-failed", cause: err };
}
let payload;
try {
payload = JSON.parse(plaintext);
} catch (err) {
return { found: false, error: "corrupt-payload" };
}
return { found: true, payload, createdAt: newest.created_at, event: newest };
}
// Cheap existence peek for the login-restore prompt: list-only, NO decrypt
// (does not spend the signer's decrypt budget). Returns whether a non-cleared
// note is present for the d-tag.
async function exists(dTag) {
const tag = normalizeDTag(dTag);
const pubkey = resolvePubkey();
if (!tag || !pubkey) {
return { exists: false };
}
const relays =
(typeof getReadRelays === "function" ? getReadRelays() : []) || [];
if (typeof listEvents !== "function" || !relays.length) {
return { exists: false };
}
let events = [];
try {
events = await listEvents(relays, [
{ kinds: [APP_DATA_KIND], authors: [pubkey], "#d": [tag], limit: 20 },
]);
} catch (err) {
return { exists: false, error: "fetch-failed" };
}
const newest = selectNewest(Array.isArray(events) ? events.flat() : []);
if (!newest) {
return { exists: false };
}
const rawContent =
typeof newest.content === "string" ? newest.content.trim() : "";
if (!rawContent) {
return { exists: false, cleared: true };
}
try {
const parsed = JSON.parse(rawContent);
if (parsed && parsed.cleared === true) {
return { exists: false, cleared: true };
}
} catch (err) {
// Unparseable content still counts as "something is there".
}
return { exists: true, createdAt: newest.created_at };
}
async function clear(dTag, options = {}) {
const tag = normalizeDTag(dTag);
if (!tag) {
return { ok: false, error: "missing-d-tag" };
}
const pubkey = resolvePubkey();
const signer = resolveSigner();
if (!pubkey || !signer) {
return { ok: false, error: "not-authenticated" };
}
// Publish a cleared marker (NOT empty content — some relays reject empty) so
// the cleared state propagates and a stale older event can't win on read.
const template = {
kind: APP_DATA_KIND,
pubkey,
created_at: resolveCreatedAt(options),
tags: [
["d", tag],
["client", "bitvid"],
],
content: JSON.stringify({ v: ENCRYPTED_SYNC_VERSION, cleared: true }),
};
return publishTemplate(template);
}
return { isAvailable, push, pull, exists, clear, APP_DATA_KIND };
}
export default createEncryptedSyncManager;
+57
View File
@@ -0,0 +1,57 @@
// Default encrypted-sync instance wired to the live Nostr client + active signer.
// The pure core lives in ./encryptedSync.js; this file is the only place that
// reaches into the singletons, so the core stays unit-testable.
import { nostrClient } from "../nostrClientFacade.js";
import { getActiveSigner } from "./index.js";
import {
publishEventToRelays,
summarizePublishResults,
} from "../nostrPublish.js";
import { createEncryptedSyncManager } from "./encryptedSync.js";
function resolveReadRelays() {
const read = Array.isArray(nostrClient?.readRelays) ? nostrClient.readRelays : [];
if (read.length) {
return read;
}
return Array.isArray(nostrClient?.relays) ? nostrClient.relays : [];
}
export const encryptedSync = createEncryptedSyncManager({
getActivePubkey: () => nostrClient?.pubkey || "",
getSigner: () => getActiveSigner(),
// Write to the full write set (not the capped read set) so the update reaches
// everywhere — mirrors getDeletePublishRelays from the watch-history work.
getWriteRelays: () =>
typeof nostrClient?.getDeletePublishRelays === "function"
? nostrClient.getDeletePublishRelays()
: [],
getReadRelays: resolveReadRelays,
getPool: () => nostrClient?.pool || null,
// Reads are routed through the subscription manager (deduped + health-gated),
// never pool.list directly (lint:pool-access).
listEvents: (relays, filters) => {
const manager =
typeof nostrClient?.getSubscriptionManager === "function"
? nostrClient.getSubscriptionManager()
: null;
if (!manager || typeof manager.list !== "function") {
return Promise.resolve([]);
}
return manager.list({ filters, relays });
},
publishEventToRelays,
summarizePublishResults,
signEvent: async (template) => {
const signer = getActiveSigner();
if (!signer || typeof signer.signEvent !== "function") {
const err = new Error("Active signer missing signEvent support.");
err.code = "sign-event-missing";
throw err;
}
return signer.signEvent(template);
},
});
export default encryptedSync;
+236 -211
View File
@@ -24,8 +24,22 @@ import {
getActiveSigner as getActiveSignerFromRegistry,
clearActiveSigner as clearActiveSignerInRegistry,
logoutSigner as logoutSignerFromRegistry,
resolveActiveSigner as resolveActiveSignerFromRegistry,
} from "../../nostrClientRegistry.js";
import {
resolveSignerCapabilities,
hydrateExtensionSignerCapabilities,
attachNipMethodAliases,
resolveActiveSigner,
buildExtensionSignerAdapter,
} from "../signerCapabilities.js";
// Re-exported for existing importers (e.g. js/nostr/client.js) that historically
// imported these from SignerManager.
export {
resolveSignerCapabilities,
hydrateExtensionSignerCapabilities,
attachNipMethodAliases,
};
import {
readStoredNip46Session,
@@ -39,6 +53,10 @@ import {
resolveNip46Relays,
encodeHexToNpub,
generateNip46Secret,
NIP46_RPC_KIND,
NIP46_HANDSHAKE_TIMEOUT_MS,
attemptDecryptNip46HandshakePayload,
normalizeNostrPubkey,
} from "../nip46Client.js";
import {
@@ -54,206 +72,8 @@ import { createPrivateKeyCipherClosures } from "../signerHelpers.js";
import { queueSignEvent } from "../signRequestQueue.js";
import { signEventWithPrivateKey } from "../publishHelpers.js";
import { ensureNostrTools, getCachedNostrTools } from "../toolkit.js";
export function resolveSignerCapabilities(signer) {
const fallback = {
sign: false,
nip44: false,
nip04: false,
};
if (!signer || typeof signer !== "object") {
return fallback;
}
const capabilities =
signer.capabilities && typeof signer.capabilities === "object"
? signer.capabilities
: {};
return {
sign:
(typeof capabilities.sign === "boolean" && capabilities.sign) ||
typeof signer.signEvent === "function",
nip44:
(typeof capabilities.nip44 === "boolean" && capabilities.nip44) ||
typeof signer.nip44Encrypt === "function" ||
typeof signer.nip44Decrypt === "function",
nip04:
(typeof capabilities.nip04 === "boolean" && capabilities.nip04) ||
typeof signer.nip04Encrypt === "function" ||
typeof signer.nip04Decrypt === "function",
};
}
export function hydrateExtensionSignerCapabilities(signer) {
if (!signer || typeof signer !== "object") {
return;
}
const signerType = typeof signer.type === "string" ? signer.type : "";
if (signerType !== "extension" && signerType !== "nip07") {
return;
}
const extension =
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
if (!extension) {
return;
}
if (typeof signer.signEvent !== "function" && extension.signEvent) {
if (typeof extension.signEvent === "function") {
signer.signEvent = extension.signEvent.bind(extension);
}
}
if (!signer.nip04 && extension.nip04) {
signer.nip04 = extension.nip04;
}
if (!signer.nip44 && extension.nip44) {
signer.nip44 = extension.nip44;
}
}
export function attachNipMethodAliases(signer) {
if (!signer || typeof signer !== "object") {
return;
}
const nip04 =
signer && typeof signer.nip04 === "object" && signer.nip04 !== null
? signer.nip04
: null;
if (nip04) {
const encrypt =
typeof nip04.encrypt === "function" ? nip04.encrypt.bind(nip04) : null;
const decrypt =
typeof nip04.decrypt === "function" ? nip04.decrypt.bind(nip04) : null;
if (encrypt && typeof signer.nip04Encrypt !== "function") {
signer.nip04Encrypt = (targetPubkey, plaintext) =>
encrypt(targetPubkey, plaintext);
}
if (decrypt && typeof signer.nip04Decrypt !== "function") {
signer.nip04Decrypt = (actorPubkey, ciphertext) =>
decrypt(actorPubkey, ciphertext);
}
}
const nip44 =
signer && typeof signer.nip44 === "object" && signer.nip44 !== null
? signer.nip44
: null;
if (nip44) {
const v2 =
typeof nip44.v2 === "object" && nip44.v2 !== null ? nip44.v2 : null;
const encrypt = (() => {
if (typeof signer.nip44Encrypt === "function") {
return null;
}
if (typeof v2?.encrypt === "function") {
return v2.encrypt.bind(v2);
}
if (typeof nip44.encrypt === "function") {
return nip44.encrypt.bind(nip44);
}
return null;
})();
const decrypt = (() => {
if (typeof signer.nip44Decrypt === "function") {
return null;
}
if (typeof v2?.decrypt === "function") {
return v2.decrypt.bind(v2);
}
if (typeof nip44.decrypt === "function") {
return nip44.decrypt.bind(nip44);
}
return null;
})();
if (encrypt) {
signer.nip44Encrypt = (targetPubkey, plaintext) =>
encrypt(targetPubkey, plaintext);
}
if (decrypt) {
signer.nip44Decrypt = (actorPubkey, ciphertext) =>
decrypt(actorPubkey, ciphertext);
}
}
}
function resolveActiveSigner(pubkey) {
const signer = resolveActiveSignerFromRegistry(pubkey);
hydrateExtensionSignerCapabilities(signer);
attachNipMethodAliases(signer);
if (signer && typeof signer === "object") {
const capsDescriptor = Object.getOwnPropertyDescriptor(
signer,
"capabilities",
);
const isGetter = capsDescriptor && typeof capsDescriptor.get === "function";
if (!isGetter) {
signer.capabilities = resolveSignerCapabilities(signer);
}
}
return signer;
}
const PERMISSION_STATUS_AUTO_HIDE_MS = 12_000;
const ENCRYPTION_METHOD_PREFIXES = ["nip04.", "nip44."];
function hasEncryptionPermissionMethods(methods) {
if (!Array.isArray(methods)) {
return false;
}
return methods.some((method) =>
ENCRYPTION_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix)),
);
}
function hasSigningPermissionMethods(methods) {
if (!Array.isArray(methods)) {
return false;
}
return methods.some(
(method) => method === "sign_event" || method === "get_public_key",
);
}
function resolvePermissionStatusMessage(methods, context) {
const normalizedContext =
typeof context === "string" ? context.trim().toLowerCase() : "";
if (normalizedContext === "dm") {
return "Approve the extension prompt to enable encrypted direct messages.";
}
if (normalizedContext === "lists") {
return "Approve the extension prompt to access your encrypted lists.";
}
const includesEncryption = hasEncryptionPermissionMethods(methods);
const includesSigning = hasSigningPermissionMethods(methods);
if (includesEncryption && includesSigning) {
return "Approve the extension prompt to enable signing and encrypted features (DMs, subscriptions, block lists).";
}
if (includesEncryption) {
return "Approve the extension prompt to enable encrypted features like DMs and private lists.";
}
if (includesSigning) {
return "Approve the extension prompt to enable signing.";
}
return "Approve the extension prompt to continue.";
}
import { accessControl } from "../../accessControl.js";
import { waitForRemoteSignerHandshake } from "../signerRemoteHandshake.js";
export class SignerManager {
constructor(client) {
@@ -436,6 +256,25 @@ export class SignerManager {
return raceWinner;
}
// Registry empty (e.g. page refresh restored the pubkey/UI but no fresh
// loginWithExtension ran): build + register the adapter from the live
// extension so getActiveSigner()/decrypt/sign work app-wide (storage unlock)
// rather than relying on per-call fallbacks. Guarded on a matching pubkey so
// we never adopt a different account.
const resolvedPubkey = extensionPubkey || normalizedPubkey;
if (
resolvedPubkey &&
(!normalizedPubkey || resolvedPubkey === normalizedPubkey) &&
typeof extension.signEvent === "function"
) {
const adapter = buildExtensionSignerAdapter(extension, resolvedPubkey);
this.pubkey = resolvedPubkey;
this.setActiveSigner(adapter);
// Return through resolveActiveSigner so capability aliases
// (nip04Decrypt/nip44Decrypt) are hydrated onto the adapter.
return this.resolveActiveSigner(resolvedPubkey) || adapter;
}
return null;
}
@@ -615,13 +454,7 @@ export class SignerManager {
}
this.pubkey = normalized;
const adapter = {
type: "extension",
pubkey: normalized,
signEvent: typeof extension.signEvent === "function" ? extension.signEvent.bind(extension) : undefined,
nip04: extension.nip04,
nip44: extension.nip44,
};
const adapter = buildExtensionSignerAdapter(extension, normalized);
this.setActiveSigner(adapter);
return { pubkey: normalized, signer: adapter };
@@ -643,6 +476,116 @@ export class SignerManager {
});
}
/**
* Prepares a NIP-46 `nostrconnect://` handshake: generates an ephemeral client
* keypair and builds the connection URI the user shares with their remote
* signer (link/QR). The returned handshake is then handed to
* connectRemoteSigner(). This is the "generate connect link" login path; it was
* previously only implemented on the unused Nip46Connector, so the client's
* `prepareRemoteSignerHandshake` proxy had nothing to call.
*
* @param {object} [params]
* @param {object} [params.metadata]
* @param {string[]} [params.relays]
* @param {string} [params.secret]
* @param {string} [params.permissions]
* @returns {Promise<object>} handshake { connectionString, clientPrivateKey, clientPublicKey, relays, secret, permissions, metadata }
*/
async prepareRemoteSignerHandshake({ metadata, relays, secret, permissions } = {}) {
const tools = await ensureNostrTools();
if (
!tools ||
typeof tools.generateSecretKey !== "function" ||
typeof tools.getPublicKey !== "function"
) {
throw new Error(
"Nostr tools are unavailable; cannot prepare a remote signer handshake.",
);
}
const secretKey = tools.generateSecretKey();
const clientPrivateKey = Array.from(secretKey)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const clientPublicKey = tools.getPublicKey(secretKey);
const sanitizedMetadata = sanitizeNip46Metadata(metadata);
const requestedPermissions =
typeof permissions === "string" && permissions.trim()
? permissions.trim()
: "";
const resolvedRelays = resolveNip46Relays(relays, this.client?.relays);
const handshakeSecret =
typeof secret === "string" && secret.trim()
? secret.trim()
: generateNip46Secret();
const params = [];
for (const relay of resolvedRelays) {
params.push(`relay=${encodeURIComponent(relay)}`);
}
if (handshakeSecret) {
params.push(`secret=${encodeURIComponent(handshakeSecret)}`);
}
if (requestedPermissions) {
params.push(`perms=${encodeURIComponent(requestedPermissions)}`);
}
for (const [key, value] of Object.entries(sanitizedMetadata)) {
params.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
const query = params.length ? `?${params.join("&")}` : "";
const uri = `nostrconnect://${clientPublicKey}${query}`;
devLogger.debug("[nostr] Prepared remote signer handshake", {
clientPublicKey: summarizeHexForLog(clientPublicKey),
relays: resolvedRelays,
permissions: requestedPermissions || null,
});
return {
type: "client",
connectionString: uri,
uri,
clientPrivateKey,
clientPublicKey,
relays: resolvedRelays,
secret: handshakeSecret,
permissions: requestedPermissions,
metadata: sanitizedMetadata,
};
}
/**
* Runs the access-control validator against a remote signer's resolved user
* pubkey. The validator (from the auth provider) throws on denial (blocked /
* not permitted) or returns false; either rejects the login. No-op when no
* validator is supplied.
*/
async _enforceRemoteSignerValidator(validator, userPubkey) {
if (typeof validator !== "function") {
return;
}
const isValid = await validator(userPubkey);
if (isValid === false) {
const error = new Error("Access denied.");
error.code = "remote-signer-access-denied";
throw error;
}
}
/**
* Waits for a remote signer to acknowledge a `nostrconnect://` handshake.
* Subscribes to NIP-46 RPC events addressed to the client pubkey; the first
* acknowledgement (matching the handshake secret, or a generic ack) reveals
* the remote signer's pubkey, which is needed before the RPC client can be
* constructed. Ported from the (unused) Nip46Connector.
*
* @returns {Promise<{remotePubkey:string, eventPubkey:string, response:object, algorithm:string}>}
*/
async _waitForRemoteSignerHandshake(options = {}) {
return waitForRemoteSignerHandshake(this, options);
}
async connectRemoteSigner({
connectionString,
remember = true,
@@ -656,6 +599,7 @@ export class SignerManager {
onStatus,
handshakeTimeoutMs,
passphrase,
validator,
} = {}) {
const parsed = parseNip46ConnectionString(connectionString);
if (!parsed) {
@@ -735,11 +679,39 @@ export class SignerManager {
await this.client.ensurePool();
// A nostrconnect:// link is client-initiated: the remote signer's pubkey is
// unknown until the user's signer scans the QR / opens the link and
// acknowledges. Wait for that ACK to learn the remote pubkey before building
// the RPC client (which requires it). bunker:// URIs already carry it.
let remotePubkey = parsed.remotePubkey;
if (!remotePubkey && parsed.type === "client") {
const clientPublicKey =
providedClientPublicKey || parsed.clientPublicKey || "";
handleStatus({
phase: "handshake",
state: "waiting",
message: "Waiting for your signer to approve the connection…",
});
const ack = await this._waitForRemoteSignerHandshake({
clientPrivateKey,
clientPublicKey,
relays: resolveNip46Relays(parsed.relays, providedRelays),
secret: providedSecret || parsed.secret,
onAuthUrl: handleAuthChallenge,
onStatus: handleStatus,
timeoutMs: handshakeTimeoutMs,
});
remotePubkey = ack.remotePubkey;
if (!remotePubkey) {
throw new Error("Remote signer did not provide a usable pubkey.");
}
}
this.nip46Client = new Nip46RpcClient({
nostrClient: this.client,
relays: resolveNip46Relays(parsed.relays, providedRelays),
clientPrivateKey: clientPrivateKey,
remotePubkey: parsed.remotePubkey,
remotePubkey,
secret: providedSecret || parsed.secret,
signEvent: signEventWithPrivateKey,
});
@@ -776,6 +748,11 @@ export class SignerManager {
}
const userPubkey = await this.nip46Client.getUserPubkey();
// Enforce access control BEFORE activating the signer, so a blocked /
// non-permitted pubkey can't establish a session via a remote signer.
await this._enforceRemoteSignerValidator(validator, userPubkey);
this.pubkey = userPubkey;
this.setActiveSigner(this.nip46Client.getActiveSigner());
@@ -797,6 +774,10 @@ export class SignerManager {
typeof normalizedOptions.passphrase === "string"
? normalizedOptions.passphrase
: null;
const validator =
typeof normalizedOptions.validator === "function"
? normalizedOptions.validator
: null;
let stored = readStoredNip46Session();
if (!stored) {
@@ -855,6 +836,11 @@ export class SignerManager {
// Just verify we can ping or get pubkey
await this.nip46Client.ensureSubscription();
const pubkey = await this.nip46Client.getUserPubkey();
// Re-check access control when restoring a stored session, so a pubkey
// that has since been blocked can't silently reconnect.
await this._enforceRemoteSignerValidator(validator, pubkey);
this.pubkey = pubkey;
this.setActiveSigner(this.nip46Client.getActiveSigner());
@@ -863,13 +849,38 @@ export class SignerManager {
} catch (err) {
this.nip46Client = null;
this.emitRemoteSignerChange({ state: "error", error: err });
if (forgetOnError) {
// Always forget a stored session that was rejected by access control —
// no point keeping a blocked pubkey's session to retry.
if (forgetOnError || err?.code === "remote-signer-access-denied") {
clearStoredNip46Session();
}
throw err;
}
}
// Access-control gate for restoring a stored signer. Waits for the admin
// lists to load (so the decision is accurate on a cold start) and throws if
// the pubkey is no longer allowed, matching the fresh-login/connect paths.
// useStoredRemoteSigner clears the stored session when this rejects.
buildAccessControlValidator() {
return async (pubkey) => {
try {
if (typeof accessControl.waitForReady === "function") {
await accessControl.waitForReady();
}
} catch (error) {
// If readiness can't be confirmed, fall through to the live check.
}
if (!accessControl.canAccess(pubkey)) {
if (accessControl.isBlacklisted(pubkey)) {
throw new Error("Your account has been blocked on this platform.");
}
throw new Error("Access restricted to admins and moderators only.");
}
return true;
};
}
async scheduleStoredRemoteSignerRestore() {
const stored = readStoredNip46Session();
if (!stored) {
@@ -878,7 +889,13 @@ export class SignerManager {
const decryptAndConnect = async () => {
try {
await this.useStoredRemoteSigner({ silent: true });
// Enforce access control on the silent startup restore too — without a
// validator a since-blocked pubkey's stored session would reconnect
// unchecked.
await this.useStoredRemoteSigner({
silent: true,
validator: this.buildAccessControlValidator(),
});
} catch (err) {
devLogger.warn("Failed to restore remote signer", err);
}
@@ -888,6 +905,14 @@ export class SignerManager {
}
async disconnectRemoteSigner({ keepStored = false } = {}) {
// Abort an in-flight nostrconnect:// handshake wait, if any.
if (typeof this.pendingHandshakeCancel === "function") {
try {
this.pendingHandshakeCancel();
} catch (error) {
// ignore cancel errors
}
}
if (this.nip46Client) {
if (typeof this.nip46Client.destroy === 'function') {
this.nip46Client.destroy();
+10 -1
View File
@@ -1402,9 +1402,18 @@ export function buildRevertVideoPayload({
});
const additionalTags = storagePointer ? [["s", storagePointer]] : [];
// The deletion tombstone must REPLACE the version it deletes, so its
// created_at has to be strictly greater (NIP-01 keeps the highest created_at).
// With a behind clock this would otherwise be older than the original and the
// relay would keep serving the live video to everyone else. Guarantee it.
const baseCreatedAt = Number.isFinite(baseEvent?.created_at)
? Math.floor(baseEvent.created_at)
: 0;
const createdAt = Math.max(Math.floor(Date.now() / 1000), baseCreatedAt + 1);
return buildVideoPostEvent({
pubkey,
created_at: Math.floor(Date.now() / 1000),
created_at: createdAt,
dTagValue: stableDTag,
content: contentObject,
additionalTags,
+224
View File
@@ -0,0 +1,224 @@
// js/nostr/signerCapabilities.js
//
// Signer capability/permission helpers extracted from managers/SignerManager.js
// to keep that module under the file-size budget. These operate on a signer
// object (or plain method lists) passed in — no SignerManager instance state —
// so they move cleanly. No behavior change.
import { resolveActiveSigner as resolveActiveSignerFromRegistry } from "../nostrClientRegistry.js";
export function resolveSignerCapabilities(signer) {
const fallback = {
sign: false,
nip44: false,
nip04: false,
};
if (!signer || typeof signer !== "object") {
return fallback;
}
const capabilities =
signer.capabilities && typeof signer.capabilities === "object"
? signer.capabilities
: {};
return {
sign:
(typeof capabilities.sign === "boolean" && capabilities.sign) ||
typeof signer.signEvent === "function",
nip44:
(typeof capabilities.nip44 === "boolean" && capabilities.nip44) ||
typeof signer.nip44Encrypt === "function" ||
typeof signer.nip44Decrypt === "function",
nip04:
(typeof capabilities.nip04 === "boolean" && capabilities.nip04) ||
typeof signer.nip04Encrypt === "function" ||
typeof signer.nip04Decrypt === "function",
};
}
export function hydrateExtensionSignerCapabilities(signer) {
if (!signer || typeof signer !== "object") {
return;
}
const signerType = typeof signer.type === "string" ? signer.type : "";
if (signerType !== "extension" && signerType !== "nip07") {
return;
}
const extension =
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
if (!extension) {
return;
}
if (typeof signer.signEvent !== "function" && extension.signEvent) {
if (typeof extension.signEvent === "function") {
signer.signEvent = extension.signEvent.bind(extension);
}
}
if (!signer.nip04 && extension.nip04) {
signer.nip04 = extension.nip04;
}
if (!signer.nip44 && extension.nip44) {
signer.nip44 = extension.nip44;
}
}
export function attachNipMethodAliases(signer) {
if (!signer || typeof signer !== "object") {
return;
}
const nip04 =
signer && typeof signer.nip04 === "object" && signer.nip04 !== null
? signer.nip04
: null;
if (nip04) {
const encrypt =
typeof nip04.encrypt === "function" ? nip04.encrypt.bind(nip04) : null;
const decrypt =
typeof nip04.decrypt === "function" ? nip04.decrypt.bind(nip04) : null;
if (encrypt && typeof signer.nip04Encrypt !== "function") {
signer.nip04Encrypt = (targetPubkey, plaintext) =>
encrypt(targetPubkey, plaintext);
}
if (decrypt && typeof signer.nip04Decrypt !== "function") {
signer.nip04Decrypt = (actorPubkey, ciphertext) =>
decrypt(actorPubkey, ciphertext);
}
}
const nip44 =
signer && typeof signer.nip44 === "object" && signer.nip44 !== null
? signer.nip44
: null;
if (nip44) {
const v2 =
typeof nip44.v2 === "object" && nip44.v2 !== null ? nip44.v2 : null;
const encrypt = (() => {
if (typeof signer.nip44Encrypt === "function") {
return null;
}
if (typeof v2?.encrypt === "function") {
return v2.encrypt.bind(v2);
}
if (typeof nip44.encrypt === "function") {
return nip44.encrypt.bind(nip44);
}
return null;
})();
const decrypt = (() => {
if (typeof signer.nip44Decrypt === "function") {
return null;
}
if (typeof v2?.decrypt === "function") {
return v2.decrypt.bind(v2);
}
if (typeof nip44.decrypt === "function") {
return nip44.decrypt.bind(nip44);
}
return null;
})();
if (encrypt) {
signer.nip44Encrypt = (targetPubkey, plaintext) =>
encrypt(targetPubkey, plaintext);
}
if (decrypt) {
signer.nip44Decrypt = (actorPubkey, ciphertext) =>
decrypt(actorPubkey, ciphertext);
}
}
}
// Build the plain NIP-07 signer adapter from a live `window.nostr`-style
// extension. Shared by loginWithExtension and the refresh-restore path in
// ensureActiveSignerForPubkey so the adapter shape stays identical. Capability
// aliases (nip04Decrypt/nip44Decrypt) are hydrated later by resolveActiveSigner.
export function buildExtensionSignerAdapter(extension, pubkey) {
return {
type: "extension",
pubkey,
signEvent:
typeof extension?.signEvent === "function"
? extension.signEvent.bind(extension)
: undefined,
nip04: extension?.nip04,
nip44: extension?.nip44,
};
}
export function resolveActiveSigner(pubkey) {
const signer = resolveActiveSignerFromRegistry(pubkey);
hydrateExtensionSignerCapabilities(signer);
attachNipMethodAliases(signer);
if (signer && typeof signer === "object") {
const capsDescriptor = Object.getOwnPropertyDescriptor(
signer,
"capabilities",
);
const isGetter = capsDescriptor && typeof capsDescriptor.get === "function";
if (!isGetter) {
signer.capabilities = resolveSignerCapabilities(signer);
}
}
return signer;
}
const ENCRYPTION_METHOD_PREFIXES = ["nip04.", "nip44."];
export function hasEncryptionPermissionMethods(methods) {
if (!Array.isArray(methods)) {
return false;
}
return methods.some((method) =>
ENCRYPTION_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix)),
);
}
export function hasSigningPermissionMethods(methods) {
if (!Array.isArray(methods)) {
return false;
}
return methods.some(
(method) => method === "sign_event" || method === "get_public_key",
);
}
export function resolvePermissionStatusMessage(methods, context) {
const normalizedContext =
typeof context === "string" ? context.trim().toLowerCase() : "";
if (normalizedContext === "dm") {
return "Approve the extension prompt to enable encrypted direct messages.";
}
if (normalizedContext === "lists") {
return "Approve the extension prompt to access your encrypted lists.";
}
const includesEncryption = hasEncryptionPermissionMethods(methods);
const includesSigning = hasSigningPermissionMethods(methods);
if (includesEncryption && includesSigning) {
return "Approve the extension prompt to enable signing and encrypted features (DMs, subscriptions, block lists).";
}
if (includesEncryption) {
return "Approve the extension prompt to enable encrypted features like DMs and private lists.";
}
if (includesSigning) {
return "Approve the extension prompt to enable signing.";
}
return "Approve the extension prompt to continue.";
}
+208
View File
@@ -0,0 +1,208 @@
// js/nostr/signerRemoteHandshake.js
//
// NIP-46 remote-signer handshake wait, extracted from managers/SignerManager.js
// to keep that module under the file-size budget. Takes the SignerManager
// instance (`ctx`) for its client/pool and cancel hook, so behavior is identical
// to the former method; SignerManager keeps a thin delegator. No behavior change.
import {
normalizeNip46EncryptionAlgorithm,
resolveNip46Relays,
NIP46_RPC_KIND,
NIP46_HANDSHAKE_TIMEOUT_MS,
attemptDecryptNip46HandshakePayload,
normalizeNostrPubkey,
} from "./nip46Client.js";
import { devLogger } from "../utils/logger.js";
import { HEX64_REGEX } from "../utils/hex.js";
export async function waitForRemoteSignerHandshake(
ctx,
{
clientPrivateKey,
clientPublicKey,
relays,
secret,
onAuthUrl,
onStatus,
timeoutMs,
} = {},
) {
const normalizedClientPublicKey = normalizeNostrPubkey(clientPublicKey);
if (!normalizedClientPublicKey) {
throw new Error(
"A client public key is required to await the remote signer handshake.",
);
}
if (
!clientPrivateKey ||
typeof clientPrivateKey !== "string" ||
!HEX64_REGEX.test(clientPrivateKey)
) {
throw new Error(
"A client private key is required to await the remote signer handshake.",
);
}
const resolvedRelays = resolveNip46Relays(relays, ctx.client?.relays);
if (!resolvedRelays.length) {
throw new Error("No relays available to complete the remote signer handshake.");
}
const pool = await ctx.client.ensurePool();
const filters = [
{ kinds: [NIP46_RPC_KIND], "#p": [normalizedClientPublicKey] },
];
const waitTimeout =
Number.isFinite(timeoutMs) && timeoutMs > 0
? timeoutMs
: NIP46_HANDSHAKE_TIMEOUT_MS;
const coerceStructuredString = (value) => {
if (typeof value === "string") {
return value.trim();
}
if (Array.isArray(value)) {
for (const entry of value) {
const candidate = coerceStructuredString(entry);
if (candidate) return candidate;
}
return "";
}
if (value && typeof value === "object") {
for (const key of ["secret", "message", "status", "result", "url"]) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
const candidate = coerceStructuredString(value[key]);
if (candidate) return candidate;
}
}
}
return "";
};
return new Promise((resolve, reject) => {
let settled = false;
let subscription;
let timeoutId;
const cleanup = () => {
if (settled) return;
settled = true;
ctx.pendingHandshakeCancel = null;
try {
subscription?.unsub?.();
} catch (error) {
// ignore subscription cleanup failures
}
if (timeoutId) clearTimeout(timeoutId);
};
// Allow the UI (modal close / disconnect) to abort the wait.
ctx.pendingHandshakeCancel = () => {
cleanup();
const error = new Error("Remote signer connection cancelled.");
error.code = "login-cancelled";
reject(error);
};
timeoutId = setTimeout(() => {
cleanup();
const error = new Error(
"Timed out waiting for the remote signer to acknowledge the connection.",
);
error.code = "nip46-handshake-timeout";
reject(error);
}, waitTimeout);
try {
subscription = pool.sub(resolvedRelays, filters);
} catch (error) {
cleanup();
reject(error);
return;
}
subscription.on("event", (event) => {
if (settled || !event || event.kind !== NIP46_RPC_KIND) {
return;
}
const eventRemotePubkey = normalizeNostrPubkey(event.pubkey);
const candidateRemotePubkeys = eventRemotePubkey ? [eventRemotePubkey] : [];
const decryptHandshake =
ctx._decryptHandshakePayload || attemptDecryptNip46HandshakePayload;
Promise.resolve()
.then(() =>
decryptHandshake({
clientPrivateKey,
candidateRemotePubkeys,
ciphertext: event.content,
}),
)
.then((payloadResult) => {
let parsed;
try {
parsed = JSON.parse(payloadResult?.plaintext ?? "");
} catch (error) {
return;
}
const resultValue = coerceStructuredString(parsed?.result);
const errorValue = coerceStructuredString(parsed?.error);
// auth_url challenge: surface to the UI and keep waiting.
if (resultValue === "auth_url" && errorValue) {
if (typeof onAuthUrl === "function") {
try {
onAuthUrl(errorValue, {
phase: "handshake",
remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "",
requestId: typeof parsed?.id === "string" ? parsed.id : "",
});
} catch (callbackError) {
devLogger.warn("[nostr] Handshake auth_url callback threw:", callbackError);
}
}
return;
}
// Match the handshake secret (or accept a generic ack).
if (secret) {
const normalizedResult = resultValue ? resultValue.toLowerCase() : "";
if (resultValue !== secret && normalizedResult !== "ack") return;
} else if (resultValue) {
if (!["ack", "ok", "success"].includes(resultValue.toLowerCase())) return;
}
cleanup();
if (typeof onStatus === "function") {
try {
onStatus({
phase: "handshake",
state: "acknowledged",
message: "Remote signer acknowledged the connect request.",
remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "",
});
} catch (callbackError) {
devLogger.warn("[nostr] Handshake status callback threw:", callbackError);
}
}
resolve({
remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "",
eventPubkey: eventRemotePubkey || "",
response: parsed,
algorithm: normalizeNip46EncryptionAlgorithm(payloadResult?.algorithm),
});
})
.catch((error) => {
devLogger.warn(
"[nostr] Failed to decrypt remote signer handshake payload:",
error,
);
});
});
subscription.on("eose", () => {
// no-op: handshake responses are push-based
});
});
}
+22
View File
@@ -102,6 +102,28 @@ export function capReadRelays(urls) {
).slice(0, MAX_SUBSCRIBE_RELAYS);
}
// Build the relay set for the main video feed.
//
// The subtle failure this guards: a user whose own relays are all DEAD but not
// yet probed (so they still pass the liveness filter) would otherwise have the
// feed subscribe only to those dead relays and hang on "Fetching…" forever,
// because the known-good defaults were never injected. capReadRelays always
// reserves a couple of reliable aggregator slots, so routing the healthy set
// through it guarantees the feed can reach live relays even when the user's
// entire personal list is broken. Falls back to the full default set if nothing
// survives at all.
export function buildFeedRelaySet(healthyRelays, fallbackRelays = []) {
const healthy = Array.isArray(healthyRelays)
? healthyRelays.filter((u) => typeof u === "string" && u.trim())
: [];
const fallback = Array.isArray(fallbackRelays)
? fallbackRelays.filter((u) => typeof u === "string" && u.trim())
: [];
const base = healthy.length ? healthy : fallback;
const capped = capReadRelays(base);
return capped.length ? capped : Array.from(DEFAULT_RELAY_URLS);
}
const globalScope =
typeof window !== "undefined"
? window
+8 -1
View File
@@ -1,7 +1,14 @@
// js/nostr/utils.js
export function getActiveKey(video) {
if (video.videoRootId) {
// A "LEGACY:<pubkey>:<dTag>" videoRootId is synthesized by the deletion
// builder for legacy videos that have no real root. It must NOT be treated as
// a distinct root, otherwise a deletion tombstone keys as
// "ROOT:LEGACY:<pubkey>:<dTag>" while the original legacy event (which carries
// no videoRootId) keys as "<pubkey>:<dTag>", and the tombstone never matches
// the zombie it is meant to suppress. Fall through to the pubkey:dTag key so
// both map to the same identity.
if (video.videoRootId && !String(video.videoRootId).startsWith("LEGACY:")) {
return `ROOT:${video.videoRootId}`;
}
const dTag = video.tags?.find((t) => t[0] === "d");
+9 -1
View File
@@ -457,7 +457,15 @@ export function prepareVideoEditPayload(params) {
? [["s", storagePointer], ...nip71Tags]
: nip71Tags;
const createdAt = Math.floor(Date.now() / 1000);
// A replaceable event only wins if its created_at is strictly greater than
// the version it replaces (NIP-01 keeps the highest created_at; ties go to
// the lowest id). A device clock that's behind, or a second edit within the
// same second, would otherwise produce an edit that relays silently keep
// BEHIND the original. Guarantee monotonicity against the base event.
const baseCreatedAt = Number.isFinite(baseEvent?.created_at)
? Math.floor(baseEvent.created_at)
: 0;
const createdAt = Math.max(Math.floor(Date.now() / 1000), baseCreatedAt + 1);
const event = buildVideoPostEvent({
pubkey: userPubkeyLower,
+148 -98
View File
@@ -14,6 +14,13 @@ import {
NOTE_TYPES,
} from "../nostrEventSchemas.js";
import { CACHE_POLICIES } from "./cachePolicies.js";
import {
sanitizeWatchHistoryMetadata,
serializeWatchHistoryItems,
bytesToHex,
looksLikeJsonStructure,
hexToBytesCompat,
} from "./watchHistoryCodec.js";
import { publishEventToRelays } from "../nostrPublish.js";
import {
RELAY_URLS,
@@ -33,6 +40,7 @@ import {
} from "../utils/pointerNormalization.js";
import { pMap } from "../utils/asyncUtils.js";
import { selectNewestListEvent } from "./listEventOrdering.js";
import { dedupeNewestPerReplaceableAddress } from "./watchHistoryDedup.js";
import {
getCachedChunkPlaintext,
setCachedChunkPlaintext,
@@ -368,55 +376,6 @@ function canonicalizeWatchHistoryItems(rawItems, maxItems = WATCH_HISTORY_MAX_IT
return buckets;
}
function sanitizeWatchHistoryMetadata(metadata) {
return {};
}
function serializeWatchHistoryItems(items) {
if (!Array.isArray(items) || items.length === 0) {
return "[]";
}
const normalized = items
.map((item) => {
const type = item?.type === "a" ? "a" : "e";
const value = typeof item?.value === "string" ? item.value : "";
if (!type || !value) {
return null;
}
const relay =
typeof item?.relay === "string" && item.relay.trim()
? item.relay.trim()
: undefined;
const watchedAt = Number.isFinite(item?.watchedAt)
? Math.max(0, Math.floor(item.watchedAt))
: undefined;
const payload = { type, value };
if (relay) {
payload.relay = relay;
}
if (watchedAt !== undefined) {
payload.watchedAt = watchedAt;
}
const resumeAt = Number.isFinite(item?.resumeAt)
? Math.max(0, Math.floor(item.resumeAt))
: undefined;
if (resumeAt !== undefined) {
payload.resumeAt = resumeAt;
}
if (item?.completed === true) {
payload.completed = true;
}
return payload;
})
.filter(Boolean);
return JSON.stringify(normalized);
}
function bytesToHex(bytes) {
return Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
async function computeWatchHistoryFingerprintForItems(itemsOrBuckets) {
// Check if buckets
@@ -454,40 +413,6 @@ async function computeWatchHistoryFingerprintForItems(itemsOrBuckets) {
return `fallback:${serialized}`;
}
function looksLikeJsonStructure(content) {
if (typeof content !== "string") {
return false;
}
const trimmed = content.trim();
if (!trimmed) {
return false;
}
const first = trimmed[0];
return first === "{" || first === "[";
}
function hexToBytesCompat(hex, tools = null) {
if (typeof hex !== "string") {
throw new Error("Invalid hex input.");
}
const trimmed = hex.trim();
if (!trimmed || trimmed.length % 2 !== 0) {
throw new Error("Invalid hex input.");
}
if (tools?.utils && typeof tools.utils.hexToBytes === "function") {
return tools.utils.hexToBytes(trimmed);
}
const bytes = new Uint8Array(trimmed.length / 2);
for (let index = 0; index < trimmed.length; index += 2) {
const byte = Number.parseInt(trimmed.slice(index, index + 2), 16);
if (Number.isNaN(byte)) {
throw new Error("Invalid hex input.");
}
bytes[index / 2] = byte;
}
return bytes;
}
// Memoizes NIP-44 conversation keys across an entire load. Deriving a
// conversation key is an ECDH operation (~12ms each in-bundle); without this
// cache the watch-history decrypt path re-derived the *same* key once per
@@ -677,7 +602,39 @@ function determineWatchHistoryDecryptionOrder(
}
}
return prioritized.length ? prioritized : available;
const ordered = prioritized.length ? prioritized : available;
// Strictly format-route by the ciphertext shape, which is authoritative.
// NIP-04 ciphertext always carries a literal "?iv=" marker; NIP-44 is plain
// base64. The opposite family can NEVER decrypt a given ciphertext, so trying
// it is a guaranteed-wasted decrypt — and under a NIP-46 remote signer each
// wasted decrypt is a *published* relay RPC that burns the serial request
// queue and risks the relay rate limit ("noting too much"). Dropping the
// non-matching family halves cold-load NIP-46 decrypt RPCs for watch history,
// mirroring the list-decrypt routing in userBlocks/subscriptions/hashtags.
// Trust the ciphertext over a possibly-misleading "encrypted" tag.
const normalizedCiphertext =
typeof ciphertext === "string" ? ciphertext.trim() : "";
if (normalizedCiphertext) {
const formatFamily = normalizedCiphertext.includes("?iv=")
? "nip04"
: "nip44";
const matching = ordered.filter(
(scheme) => watchHistorySchemeFamily(scheme) === formatFamily,
);
// Safety: never strand the only available decryptor. If nothing matches the
// detected format (e.g. a contrived/legacy payload), fall back to the full
// ordered list rather than returning an empty attempt set.
if (matching.length) {
return matching;
}
}
return ordered;
}
function watchHistorySchemeFamily(scheme) {
return scheme === "nip04" ? "nip04" : "nip44";
}
async function resolveNostrToolkit(deps = {}, cacheRef = null) {
@@ -930,6 +887,10 @@ class WatchHistoryManager {
this.cacheTtlMs = 0;
this.fingerprints = new Map();
this.lastCreatedAt = 0;
// Per-actor count of older chunks left undecrypted by the last bounded
// fetch (see fetch()'s chunkDecryptLimit). Read by the service to decide
// whether to offer "load older".
this.deferredChunkCounts = new Map();
profileCache.subscribe((event, detail) => {
if (event === "profileChanged") {
@@ -1341,7 +1302,8 @@ class WatchHistoryManager {
}
async publishMonthRecord(monthIdentifier, items, options = {}) {
if (!items || !items.length) return { ok: true };
// Empty month = "clear it": still publish a newest EMPTY event for the d-tag (skipping it left removed items live on relays).
items = Array.isArray(items) ? items : [];
const pool = typeof this.deps.getPool === "function" ? this.deps.getPool() : null;
if (!pool) {
@@ -1610,13 +1572,13 @@ class WatchHistoryManager {
}
}
this.lastCreatedAt = createdAtCursor;
const partialAcceptance = anyPartial;
const success = acceptedCount === relays.length && !anyRejected;
// Durable if ANY relay accepted (reads take newest per d-tag); all-relays was a false failure on large lists.
const success = acceptedCount > 0;
let errorCode = null;
if (!success) {
if (anyRejected) {
errorCode = "publish-rejected";
} else if (partialAcceptance) {
} else if (anyPartial) {
errorCode = "partial-relay-acceptance";
}
}
@@ -1635,7 +1597,7 @@ class WatchHistoryManager {
},
skippedCount: skipped.length,
source: options.source || "manual",
partial: partialAcceptance,
partial: anyPartial,
};
if (!success && errorCode) {
result.error = errorCode;
@@ -1645,7 +1607,7 @@ class WatchHistoryManager {
actor: actorKey,
monthIdentifier,
success,
partialAcceptance,
partialAcceptance: anyPartial,
error: result.error || null,
acceptedCount,
});
@@ -1806,6 +1768,19 @@ class WatchHistoryManager {
}
async fetch(actorInput, options = {}) {
// Bound how many *uncached* watch-history chunks we decrypt up front. Each
// uncached chunk decrypt is a signer round-trip — and under a NIP-46 remote
// signer it is a published relay RPC routed through the serial 250ms queue,
// so a deep history (many monthly chunks) blocks the first render and risks
// the relay rate limit. Default is unlimited (no behavior change); callers
// opt into a finite limit and decrypt older chunks newest-first on demand.
// Already-cached and plaintext chunks are always decrypted (they cost no
// RPC) and never count against the limit.
const chunkDecryptLimitRaw = Number(options?.chunkDecryptLimit);
const chunkDecryptLimit =
Number.isFinite(chunkDecryptLimitRaw) && chunkDecryptLimitRaw >= 0
? Math.floor(chunkDecryptLimitRaw)
: Infinity;
const actorCandidates = [actorInput];
if (typeof this.deps.getActivePubkey === "function") {
actorCandidates.push(this.deps.getActivePubkey());
@@ -2037,6 +2012,7 @@ class WatchHistoryManager {
const toolkitCacheRef = { current: null };
let decryptedItems = [];
let deferredChunkCount = 0;
if (chunkIdentifiers.length && canAttemptDecrypt) {
try {
const results = await listEvents(readRelays, [
@@ -2046,9 +2022,41 @@ class WatchHistoryManager {
"#d": chunkIdentifiers,
},
]);
const chunkEvents = Array.isArray(results)
const allChunkEvents = Array.isArray(results)
? results.flat().filter((event) => event && typeof event === "object")
: [];
// Decrypt newest chunks first so the most recent history is the part we
// spend the (bounded) decrypt budget on. A chunk is "free" when its
// content is already plaintext or its plaintext is cached — those never
// count against the limit. Only uncached, genuinely-encrypted chunks
// consume the budget; any beyond it are deferred (the caller can request
// older chunks later, which re-uses the chunk cache for the rest).
// Keep only the NEWEST event per month (d-tag) so a lagging relay's
// stale copy can't resurrect removed items in the union. Newest-first,
// so the decrypt-budget ordering below is preserved.
const sortedChunkEvents = dedupeNewestPerReplaceableAddress(allChunkEvents);
const chunkEvents = [];
let uncachedBudget = chunkDecryptLimit;
for (const event of sortedChunkEvents) {
const ciphertext =
typeof event.content === "string" ? event.content : "";
const isFree =
!ciphertext ||
looksLikeJsonStructure(ciphertext) ||
Boolean(getCachedChunkPlaintext(event.id));
if (isFree) {
chunkEvents.push(event);
continue;
}
if (uncachedBudget > 0) {
uncachedBudget -= 1;
chunkEvents.push(event);
} else {
deferredChunkCount += 1;
}
}
const chunkResults = await pMap(
chunkEvents,
async (event) => {
@@ -2100,13 +2108,12 @@ class WatchHistoryManager {
const eventsToProcess = Array.isArray(eventToProcess) ? eventToProcess : [eventToProcess];
const collectedItems = [];
// Deduplicate events by ID
const uniqueEvents = new Map();
for (const ev of eventsToProcess) {
if (ev && ev.id) uniqueEvents.set(ev.id, ev);
}
// Deduplicate by replaceable address (newest per d-tag), NOT by id — a stale
// version of the same month from a lagging relay must not be unioned in
// alongside the fresh one (that resurrects removed items).
const uniqueEvents = dedupeNewestPerReplaceableAddress(eventsToProcess);
for (const event of uniqueEvents.values()) {
for (const event of uniqueEvents) {
const fallbackPointers = extractPointerItemsFromEvent(event);
const ciphertext = typeof event.content === "string" ? event.content : "";
@@ -2189,7 +2196,17 @@ class WatchHistoryManager {
};
this.cache.set(actorKey, entry);
this.persistEntry(actorKey, entry);
return { pointerEvent: latestEvent, items: flatItems, snapshotId: "" };
return {
pointerEvent: latestEvent,
items: flatItems,
snapshotId: "",
// Number of older encrypted chunks left undecrypted because the caller
// capped the per-fetch decrypt budget. >0 means "more history available
// on demand"; the caller can re-fetch with a larger chunkDecryptLimit
// (already-decrypted chunks are served from cache, so only the newly
// requested older chunks cost RPCs).
deferredChunkCount,
};
}
async resolve(actorInput, options = {}) {
@@ -2227,7 +2244,18 @@ class WatchHistoryManager {
const fetchResult = await this.fetch(resolvedActor, {
forceRefresh: options.forceRefresh || false,
...(options.chunkDecryptLimit !== undefined
? { chunkDecryptLimit: options.chunkDecryptLimit }
: {}),
});
// Surface how many older chunks were left undecrypted so the service can
// decide whether to offer "load older". Stored per-actor side-channel so
// resolve() can keep returning a plain items array (its sole caller relies
// on that contract).
this.setDeferredChunkCount(
actorKey,
Number(fetchResult?.deferredChunkCount) || 0,
);
const merged = mergeWatchHistoryItemsWithFallback(
{
version: 2,
@@ -2299,9 +2327,27 @@ class WatchHistoryManager {
this.cache.clear();
this.fingerprints.clear();
this.refreshPromises.clear();
this.deferredChunkCounts.clear();
this.lastCreatedAt = 0;
this.storage = null;
}
setDeferredChunkCount(actorInput, count) {
const actorKey = normalizeActorKey(actorInput);
if (!actorKey) {
return;
}
const normalized = Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
this.deferredChunkCounts.set(actorKey, normalized);
}
getDeferredChunkCount(actorInput) {
const actorKey = normalizeActorKey(actorInput);
if (!actorKey) {
return 0;
}
return this.deferredChunkCounts.get(actorKey) || 0;
}
}
export function getWatchHistoryCacheTtlMs(manager) {
@@ -2361,6 +2407,10 @@ export function resolveWatchHistory(manager, actorInput, options = {}) {
return manager.resolve(actorInput, options);
}
export function getWatchHistoryDeferredChunkCount(manager, actorInput) {
return manager.getDeferredChunkCount(actorInput);
}
/**
* Create a watch-history manager. Callers supply the nostr client hooks needed
* to sign, encrypt, fetch, and publish events. All dependencies are optional;
+88
View File
@@ -0,0 +1,88 @@
// js/nostr/watchHistoryCodec.js
//
// Pure, dependency-free encode/decode helpers extracted from watchHistory.js to
// keep that module under the file-size budget. No behavior change.
export function sanitizeWatchHistoryMetadata(metadata) {
return {};
}
export function serializeWatchHistoryItems(items) {
if (!Array.isArray(items) || items.length === 0) {
return "[]";
}
const normalized = items
.map((item) => {
const type = item?.type === "a" ? "a" : "e";
const value = typeof item?.value === "string" ? item.value : "";
if (!type || !value) {
return null;
}
const relay =
typeof item?.relay === "string" && item.relay.trim()
? item.relay.trim()
: undefined;
const watchedAt = Number.isFinite(item?.watchedAt)
? Math.max(0, Math.floor(item.watchedAt))
: undefined;
const payload = { type, value };
if (relay) {
payload.relay = relay;
}
if (watchedAt !== undefined) {
payload.watchedAt = watchedAt;
}
const resumeAt = Number.isFinite(item?.resumeAt)
? Math.max(0, Math.floor(item.resumeAt))
: undefined;
if (resumeAt !== undefined) {
payload.resumeAt = resumeAt;
}
if (item?.completed === true) {
payload.completed = true;
}
return payload;
})
.filter(Boolean);
return JSON.stringify(normalized);
}
export function bytesToHex(bytes) {
return Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
export function looksLikeJsonStructure(content) {
if (typeof content !== "string") {
return false;
}
const trimmed = content.trim();
if (!trimmed) {
return false;
}
const first = trimmed[0];
return first === "{" || first === "[";
}
export function hexToBytesCompat(hex, tools = null) {
if (typeof hex !== "string") {
throw new Error("Invalid hex input.");
}
const trimmed = hex.trim();
if (!trimmed || trimmed.length % 2 !== 0) {
throw new Error("Invalid hex input.");
}
if (tools?.utils && typeof tools.utils.hexToBytes === "function") {
return tools.utils.hexToBytes(trimmed);
}
const bytes = new Uint8Array(trimmed.length / 2);
for (let index = 0; index < trimmed.length; index += 2) {
const byte = Number.parseInt(trimmed.slice(index, index + 2), 16);
if (Number.isNaN(byte)) {
throw new Error("Invalid hex input.");
}
bytes[index / 2] = byte;
}
return bytes;
}
+39
View File
@@ -0,0 +1,39 @@
// Watch-history events are PARAMETERIZED REPLACEABLE (kind + pubkey + d-tag,
// where the d-tag is the month). When reading across multiple relays we can
// receive several versions of the same month — e.g. a relay that missed the
// latest removal/clear publish still serves the old, larger copy. The reader
// unions items across events, so unless we keep only the NEWEST version per
// address, a lagging relay resurrects items the user just removed.
//
// This collapses a set of events to the newest per replaceable address (by
// created_at), so the union downstream reflects the user's latest intent.
/**
* @param {Array<object>} events raw nostr events
* @returns {Array<object>} newest event per d-tag (events without a d-tag are
* kept individually by id), sorted newest-first
*/
export function dedupeNewestPerReplaceableAddress(events) {
const sorted = (Array.isArray(events) ? events : [])
.filter((event) => event && typeof event === "object")
.slice()
.sort((a, b) => (Number(b?.created_at) || 0) - (Number(a?.created_at) || 0));
const seen = new Set();
const out = [];
for (const event of sorted) {
const dTag = Array.isArray(event.tags)
? event.tags.find((tag) => Array.isArray(tag) && tag[0] === "d")?.[1] || ""
: "";
// Distinct months have distinct d-tags, so deduping by d-tag keeps one
// (newest) per month while preserving every month. Events with no d-tag
// can't be addressed, so they're kept individually by id.
const key = dTag ? `d:${dTag}` : `id:${event.id}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
out.push(event);
}
return out;
}
+111 -3
View File
@@ -68,12 +68,12 @@ export function publishEventToRelay(pool, url, event, options = {}) {
return new Promise((resolve) => {
let settled = false;
const finalize = (success, error = null) => {
const finalize = (success, error = null, optimistic = false) => {
if (settled) {
return;
}
settled = true;
resolve({ url, success, error });
resolve({ url, success, error, optimistic });
};
const timeoutId = setTimeout(() => {
@@ -131,6 +131,34 @@ export function publishEventToRelay(pool, url, event, options = {}) {
}
}
// nostr-tools SimplePool.publish(relays, event) returns an ARRAY of
// per-relay promises (one per url). Each MUST be consumed or a relay that
// rejects ("blocked", "auth-required", "publish timed out", websocket
// error, …) surfaces as an uncaught promise rejection. With a large/flaky
// relay list that floods the event loop and can freeze the tab. We pass a
// single [url], so the array has one promise; allSettled never rejects, so
// every rejection is consumed here.
if (Array.isArray(pub)) {
Promise.allSettled(pub).then((settled) => {
clearTimeout(timeoutId);
const anyOk = settled.some((entry) => entry.status === "fulfilled");
if (anyOk) {
finalize(true);
return;
}
const firstRejection = settled.find(
(entry) => entry.status === "rejected",
);
finalize(
false,
firstRejection?.reason instanceof Error
? firstRejection.reason
: new Error(String(firstRejection?.reason || "publish failed")),
);
});
return;
}
if (pub && typeof pub.then === "function") {
pub
.then(() => {
@@ -149,8 +177,16 @@ export function publishEventToRelay(pool, url, event, options = {}) {
return;
}
// No confirmation channel: the relay handle exposed neither an ok/failed
// event nor a thenable. We still optimistically report success for legacy
// "seen"-only relays, but mark the result `optimistic` and log it so a
// silently-unconfirmed publish is traceable rather than indistinguishable
// from a real relay ack (publish audit #3).
clearTimeout(timeoutId);
finalize(true);
devLogger.warn(
`[nostrPublish] Optimistic (unconfirmed) publish success for ${url}: relay gave no ok/failed/then signal.`,
);
finalize(true, null, true);
});
}
@@ -227,6 +263,78 @@ export function summarizePublishResults(results = []) {
return { accepted, failed };
}
// Non-enumerable key so the tally rides along with a signed event without ever
// polluting JSON.stringify / relay payloads.
export const RELAY_PUBLISH_SUMMARY_KEY = "__relayPublishSummary";
/**
* Reduce a {accepted, failed} publish summary to a compact {accepted, total}
* tally and stash it on the event (non-enumerable). Best-effort: a frozen or
* non-object event is left untouched.
*/
export function attachRelayPublishSummary(event, summary) {
if (!event || typeof event !== "object") {
return event;
}
const accepted = Array.isArray(summary?.accepted) ? summary.accepted.length : 0;
const failed = Array.isArray(summary?.failed) ? summary.failed.length : 0;
try {
Object.defineProperty(event, RELAY_PUBLISH_SUMMARY_KEY, {
value: { accepted, total: accepted + failed },
enumerable: false,
configurable: true,
writable: true,
});
} catch (_) {
// ignore — the tally is purely informational.
}
return event;
}
/** Read back the compact tally attached by attachRelayPublishSummary, if any. */
export function readRelayPublishSummary(event) {
if (!event || typeof event !== "object") {
return null;
}
const tally = event[RELAY_PUBLISH_SUMMARY_KEY];
return tally && typeof tally === "object" ? tally : null;
}
/**
* Turn a relay-publish tally into a user-facing message + tone, so the UI can
* tell the user how many relays actually accepted their video instead of an
* opaque "shared successfully" that hides partial/total failure.
*
* @param {{ accepted?: number, total?: number }} tally
* @returns {{ tone: "success"|"warning"|"error", message: string }}
*/
export function describePublishOutcome({ accepted = 0, total = 0 } = {}) {
const a = Number.isFinite(accepted) ? Math.max(0, Math.floor(accepted)) : 0;
const t = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0;
// No relay count available — fall back to the generic confirmation.
if (t <= 0) {
return { tone: "success", message: "Video shared successfully!" };
}
if (a <= 0) {
return {
tone: "error",
message:
"Video could not be published to any relay. Check your relay list and try again.",
};
}
if (a < t) {
return {
tone: "warning",
message: `Video published to ${a} of ${t} relays — some relays rejected it.`,
};
}
return {
tone: "success",
message: `Video published to ${a} ${a === 1 ? "relay" : "relays"}!`,
};
}
export function assertAnyRelayAccepted(results = [], options = {}) {
const summary = summarizePublishResults(results);
if (summary.accepted.length > 0) {
+41 -14
View File
@@ -1,7 +1,11 @@
// js/payments/zapReceiptValidator.js
import { nostrToolsReady } from "../nostrToolsBootstrap.js";
import { normalizeToolkitCandidate, readToolkitFromScope } from "../nostr/toolkit.js";
import {
normalizeToolkitCandidate,
readToolkitFromScope,
shimLegacySimplePoolMethods,
} from "../nostr/toolkit.js";
import { userLogger } from "../utils/logger.js";
import { bech32, bytesToHex, sha256 } from "../../vendor/crypto-helpers.bundle.min.js";
@@ -212,6 +216,37 @@ function buildValidationResult({
};
}
// Build the relay pool used to fetch the zap receipt. nostr-tools 2.x SimplePool
// no longer exposes .list(), so a freshly constructed pool can't query — which
// made receipt validation always fail with "Unable to initialize a relay pool"
// after an otherwise-successful zap. Apply the app's legacy .list shim (unless a
// listEvents override supplies its own fetch path).
export function resolveReceiptListPool(tools, overrides = {}) {
let pool = null;
try {
if (typeof overrides.createPool === "function") {
pool = overrides.createPool(tools);
} else if (tools && typeof tools.SimplePool === "function") {
pool = new tools.SimplePool();
}
} catch (error) {
userLogger.warn("[zapReceiptValidator] Failed to create SimplePool instance.", error);
return null;
}
if (
pool &&
typeof pool.list !== "function" &&
typeof overrides.listEvents !== "function"
) {
try {
shimLegacySimplePoolMethods(pool);
} catch (error) {
userLogger.warn("[zapReceiptValidator] Failed to shim SimplePool.list.", error);
}
}
return pool;
}
export async function validateZapReceipt(context = {}, overrides = {}) {
const {
zapRequest,
@@ -330,19 +365,11 @@ export async function validateZapReceipt(context = {}, overrides = {}) {
});
}
let pool;
try {
if (typeof overrides.createPool === "function") {
pool = overrides.createPool(tools);
} else if (typeof tools.SimplePool === "function") {
pool = new tools.SimplePool();
}
} catch (error) {
userLogger.warn("[zapReceiptValidator] Failed to create SimplePool instance.", error);
pool = null;
}
if (!pool || typeof pool.list !== "function") {
const pool = resolveReceiptListPool(tools, overrides);
const canListEvents =
typeof overrides.listEvents === "function" ||
(pool && typeof pool.list === "function");
if (!pool || !canListEvents) {
return buildValidationResult({
status: "failed",
reason: "Unable to initialize a relay pool for receipt validation.",
+76 -1
View File
@@ -1,4 +1,9 @@
import { userLogger } from "./utils/logger.js";
import { userLogger, devLogger } from "./utils/logger.js";
import {
computeSha256HexFromValue,
valueToUint8Array,
getSharedTextEncoder,
} from "./utils/cryptoUtils.js";
const DB_NAME = "bitvidSettings";
const DB_VERSION = 1;
const STORE_NAME = "kv";
@@ -242,4 +247,74 @@ export function buildPublicUrl(baseUrl, key) {
return `${sanitizedBase}/${encodedKey}`;
}
// Above this size we fingerprint metadata + sampled edges instead of hashing the
// whole file, so we never buffer a multi-GB video in memory (which would freeze
// the main thread). Only reached on the rare path where a torrent info-hash is
// unavailable.
const STORAGE_FULL_HASH_MAX_BYTES = 512 * 1024 * 1024; // 512 MB
const STORAGE_FINGERPRINT_SAMPLE_BYTES = 256 * 1024; // 256 KB head + tail
async function fingerprintLargeFile(file, size) {
const encoder = getSharedTextEncoder();
const metaString = `${file?.name || ""} ${size} ${
Number(file?.lastModified) || 0
}`;
const meta = encoder ? encoder.encode(metaString) : new Uint8Array();
const canSlice = typeof file?.slice === "function";
const headBlob = canSlice
? file.slice(0, Math.min(STORAGE_FINGERPRINT_SAMPLE_BYTES, size))
: null;
const tailBlob = canSlice
? file.slice(Math.max(0, size - STORAGE_FINGERPRINT_SAMPLE_BYTES))
: null;
const head = headBlob ? await valueToUint8Array(headBlob) : null;
const tail = tailBlob ? await valueToUint8Array(tailBlob) : null;
const parts = [meta, head, tail].filter(Boolean);
const total = parts.reduce((sum, part) => sum + part.length, 0);
const combined = new Uint8Array(total);
let offset = 0;
for (const part of parts) {
combined.set(part, offset);
offset += part.length;
}
return computeSha256HexFromValue(combined);
}
/**
* Derives a deterministic, content-based namespace component for a storage key
* when no torrent info-hash is available. Prevents distinct URL-first uploads
* that share a filename from overwriting each other - the `buildR2Key`
* `"uploads"` fallback was a silent data-loss path.
*
* Small/medium files use a full SHA-256 of the bytes (idempotent + collision
* safe). Files larger than `STORAGE_FULL_HASH_MAX_BYTES` use a SHA-256
* fingerprint over name/size/mtime + sampled head & tail bytes, so we never
* buffer a huge file in memory. Returns "" only if the file can't be read at
* all (callers must then supply their own uniqueness).
*
* @param {Blob|File} file
* @returns {Promise<string>}
*/
export async function computeStorageContentHash(file) {
if (!file) {
return "";
}
try {
const size = Number(file.size) || 0;
if (size > 0 && size <= STORAGE_FULL_HASH_MAX_BYTES) {
const hex = await computeSha256HexFromValue(file);
if (hex) {
return `sha256${hex}`;
}
}
const fingerprint = await fingerprintLargeFile(file, size);
if (fingerprint) {
return `fp${fingerprint}`;
}
} catch (error) {
devLogger.warn("[r2] Failed to derive content hash for storage key:", error);
}
return "";
}
export { sanitizeBaseDomain };
+17 -12
View File
@@ -3,7 +3,7 @@ import { accessControl } from "../../accessControl.js";
const PROVIDER_ID = "nsec";
const PROVIDER_LABEL = "nsec or seed (direct private key)";
const PROVIDER_DESCRIPTION =
"Paste an nsec, hex key, or BIP-39 seed. Keep it encrypted on this device if you choose.";
"Paste an nsec or hex private key. Keep it encrypted on this device if you choose.";
const PROVIDER_CAPABILITIES = Object.freeze([
Object.freeze({
id: "signing",
@@ -92,6 +92,18 @@ export default {
const normalized = normalizeOptions(options);
// Access gate enforced before any signer is activated, for both fresh
// logins and stored-key unlocks.
const validator = (pubkey) => {
if (!accessControl.canAccess(pubkey)) {
if (accessControl.isBlacklisted(pubkey)) {
throw new Error("Your account has been blocked on this platform.");
}
throw new Error("Access restricted to admins and moderators users only.");
}
return true;
};
if (normalized.unlockStored) {
if (typeof nostrClient.unlockStoredSessionActor !== "function") {
const error = new Error("Stored key unlock is not supported.");
@@ -106,7 +118,10 @@ export default {
throw error;
}
const unlockResult = await nostrClient.unlockStoredSessionActor(suppliedPassphrase);
const unlockResult = await nostrClient.unlockStoredSessionActor(
suppliedPassphrase,
{ validator },
);
const unlockedPubkey = normalizePubkey(unlockResult);
return {
@@ -133,16 +148,6 @@ export default {
throw error;
}
const validator = (pubkey) => {
if (!accessControl.canAccess(pubkey)) {
if (accessControl.isBlacklisted(pubkey)) {
throw new Error("Your account has been blocked on this platform.");
}
throw new Error("Access restricted to admins and moderators users only.");
}
return true;
};
const registrationResult = await nostrClient.registerPrivateKeySigner({
privateKey,
pubkey,
+256
View File
@@ -0,0 +1,256 @@
// js/services/commentThreadCache.js
//
// localStorage comment-cache subsystem extracted from commentThreadService.js to
// keep that service under the file-size budget. Each function takes the service
// instance (`ctx`) so behavior is identical to the former methods; the service
// keeps thin delegators so existing call sites are unchanged. No behavior change.
import {
FEATURE_IMPROVED_COMMENT_FETCHING,
FIVE_MINUTES_MS,
} from "../constants.js";
import { normalizeHexId } from "../utils/hex.js";
const COMMENT_CACHE_PREFIX = "bitvid:comments:";
const COMMENT_CACHE_TTL_MS = FIVE_MINUTES_MS;
const COMMENT_CACHE_VERSION = 2;
function logDev(loggerCandidate, ...args) {
if (loggerCandidate?.info) {
loggerCandidate.info(...args);
}
}
export function getCommentCacheKey(videoEventId) {
const normalized = normalizeHexId(videoEventId);
if (!normalized) {
return "";
}
return `${COMMENT_CACHE_PREFIX}${normalized.toLowerCase()}`;
}
export function handleCommentCacheError(ctx, context, videoEventId, error) {
ctx.commentCacheDiagnostics = {
...ctx.commentCacheDiagnostics,
storageUnavailable: true,
};
const message =
typeof videoEventId === "string" && videoEventId.trim()
? `[commentThread] Failed to ${context} comment cache for ${videoEventId}.`
: `[commentThread] Failed to ${context} comment cache.`;
if (ctx.logger?.user?.warn) {
ctx.logger.user.warn(message, error);
} else if (ctx.logger?.warn) {
ctx.logger.warn(message, error);
}
if (ctx.logger?.dev?.warn && ctx.logger.dev !== ctx.logger.user) {
ctx.logger.dev.warn(message, error);
}
}
export function getCachedComments(ctx, videoEventId) {
if (
!FEATURE_IMPROVED_COMMENT_FETCHING ||
typeof localStorage === "undefined"
) {
return null;
}
const cacheKey = ctx.getCommentCacheKey(videoEventId);
if (!cacheKey) {
logDev(
ctx.logger?.dev,
"[commentThread] Comment cache skipped: invalid video id.",
);
return null;
}
let raw = null;
try {
raw = localStorage.getItem(cacheKey);
} catch (error) {
ctx.handleCommentCacheError("read", videoEventId, error);
return null;
}
if (raw === null) {
logDev(
ctx.logger?.dev,
`[commentThread] Comment cache miss for ${videoEventId}: no entry present.`,
);
return null;
}
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") {
logDev(
ctx.logger?.dev,
`[commentThread] Comment cache rejected for ${videoEventId}: malformed payload.`,
);
ctx.removeCommentCache(cacheKey);
return null;
}
const cacheVersion = Number.isFinite(parsed.version)
? Number(parsed.version)
: null;
if (cacheVersion !== COMMENT_CACHE_VERSION) {
logDev(
ctx.logger?.dev,
`[commentThread] Comment cache rejected for ${videoEventId}: version ${cacheVersion} != ${COMMENT_CACHE_VERSION}.`,
);
ctx.removeCommentCache(cacheKey);
return null;
}
const comments = Array.isArray(parsed.comments)
? parsed.comments
: null;
const timestamp = Number(parsed.timestamp);
if (
Array.isArray(comments) &&
Number.isFinite(timestamp) &&
Date.now() - timestamp <= COMMENT_CACHE_TTL_MS
) {
logDev(
ctx.logger?.dev,
`[commentThread] Loaded ${comments.length} cached comments for ${videoEventId}.`,
);
return comments;
}
logDev(
ctx.logger?.dev,
`[commentThread] Comment cache rejected for ${videoEventId}: entry expired.`,
);
ctx.removeCommentCache(cacheKey);
} catch (error) {
if (ctx.logger?.warn) {
ctx.logger.warn(
`[commentThread] Failed to parse cached comments for ${videoEventId}:`,
error,
);
}
logDev(
ctx.logger?.dev,
`[commentThread] Comment cache rejected for ${videoEventId}: parse error.`,
);
ctx.removeCommentCache(cacheKey);
}
return null;
}
export function cacheComments(ctx, videoEventId, comments) {
if (
!FEATURE_IMPROVED_COMMENT_FETCHING ||
typeof localStorage === "undefined" ||
!Array.isArray(comments)
) {
return;
}
const cacheKey = ctx.getCommentCacheKey(videoEventId);
if (!cacheKey) {
return;
}
try {
localStorage.setItem(
cacheKey,
JSON.stringify({
version: COMMENT_CACHE_VERSION,
comments,
timestamp: Date.now(),
}),
);
logDev(
ctx.logger?.dev,
`[commentThread] Cached ${comments.length} comments for ${videoEventId}.`,
);
} catch (error) {
ctx.handleCommentCacheError("write", videoEventId, error);
}
}
export function removeCommentCache(ctx, cacheKey) {
if (!cacheKey || typeof localStorage === "undefined") {
return;
}
try {
localStorage.removeItem(cacheKey);
} catch (error) {
if (ctx.logger?.warn) {
ctx.logger.warn(
`[commentThread] Failed to clear cached comments for ${cacheKey}:`,
error,
);
}
}
}
export function clearCommentCache(ctx, videoEventId = null) {
if (!FEATURE_IMPROVED_COMMENT_FETCHING || typeof localStorage === "undefined") {
return;
}
if (videoEventId) {
ctx.removeCommentCache(ctx.getCommentCacheKey(videoEventId));
return;
}
try {
const keys = Object.keys(localStorage);
keys
.filter((key) => key.startsWith(COMMENT_CACHE_PREFIX))
.forEach((key) => ctx.removeCommentCache(key));
} catch (error) {
if (ctx.logger?.warn) {
ctx.logger.warn(
"[commentThread] Failed to clear comment cache:",
error,
);
}
}
}
export function persistCommentCache(ctx) {
if (!FEATURE_IMPROVED_COMMENT_FETCHING || !ctx.videoEventId) {
return;
}
const comments = ctx.serializeCommentsForCache();
ctx.cacheComments(ctx.videoEventId, comments);
}
export function serializeCommentsForCache(ctx) {
const events = Array.from(ctx.eventsById.values());
return events.sort((a, b) => {
const aTime = Number.isFinite(a?.created_at) ? a.created_at : 0;
const bTime = Number.isFinite(b?.created_at) ? b.created_at : 0;
if (aTime !== bTime) {
return aTime - bTime;
}
const aId = normalizeHexId(a?.id);
const bId = normalizeHexId(b?.id);
if (aId && bId) {
return aId.localeCompare(bId);
}
if (aId) {
return -1;
}
if (bId) {
return 1;
}
return 0;
});
}
+193 -214
View File
@@ -4,16 +4,23 @@ import logger, { devLogger, userLogger } from "../utils/logger.js";
import { normalizeHexId, normalizeHexPubkey } from "../utils/hex.js";
import { buildVideoAddressPointer } from "../utils/videoPointer.js";
import { COMMENT_EVENT_KIND } from "../nostr/commentEvents.js";
import { FEATURE_IMPROVED_COMMENT_FETCHING, FIVE_MINUTES_MS } from "../constants.js";
import { FEATURE_IMPROVED_COMMENT_FETCHING } from "../constants.js";
import {
getCommentCacheKey as cacheGetCommentCacheKey,
handleCommentCacheError as cacheHandleCommentCacheError,
getCachedComments as cacheGetCachedComments,
cacheComments as cacheCacheComments,
removeCommentCache as cacheRemoveCommentCache,
clearCommentCache as cacheClearCommentCache,
persistCommentCache as cachePersistCommentCache,
serializeCommentsForCache as cacheSerializeCommentsForCache,
} from "./commentThreadCache.js";
const ROOT_PARENT_KEY = "__root__";
const DEFAULT_INITIAL_LIMIT = 40;
const DEFAULT_HYDRATION_DEBOUNCE_MS = 25;
const PROFILE_FETCH_MAX_ATTEMPTS = 3;
const PROFILE_FETCH_BACKOFF_MS = 50;
const COMMENT_CACHE_PREFIX = "bitvid:comments:";
const COMMENT_CACHE_TTL_MS = FIVE_MINUTES_MS;
const COMMENT_CACHE_VERSION = 2;
function toPositiveInteger(value, fallback) {
const numeric = Number(value);
@@ -46,6 +53,13 @@ function normalizeKind(value) {
return "";
}
// NIP-09 deletion request.
const DELETION_EVENT_KIND = 5;
function isDeletionEvent(event) {
return normalizeKind(event?.kind) === String(DELETION_EVENT_KIND);
}
function toParentKey(parentId) {
return parentId ? parentId : ROOT_PARENT_KEY;
}
@@ -105,6 +119,7 @@ export default class CommentThreadService {
app = null,
fetchVideoComments = null,
subscribeVideoComments = null,
fetchDeletionEvents = null,
getProfileCacheEntry = null,
batchFetchProfiles = null,
limit = DEFAULT_INITIAL_LIMIT,
@@ -114,6 +129,10 @@ export default class CommentThreadService {
this.nostrClient = nostrClient;
this.fetchVideoComments = fetchVideoComments;
this.subscribeVideoComments = subscribeVideoComments;
this.fetchDeletionEvents =
typeof fetchDeletionEvents === "function"
? fetchDeletionEvents
: (ids, opts) => this._defaultFetchDeletions(ids, opts);
if (!this.fetchVideoComments && this.nostrClient) {
const clientFetcher = this.nostrClient.fetchVideoComments;
@@ -162,6 +181,9 @@ export default class CommentThreadService {
this.metaById = new Map();
this.childrenByParent = new Map();
this.childrenByParent.set(ROOT_PARENT_KEY, []);
// NIP-09: target comment id -> Set of pubkeys that requested its deletion.
// A comment is treated as deleted only when its own author appears here.
this.deletionsByTarget = new Map();
this.profileCache = new Map();
this.profileQueue = new Set();
this.profileHydrationTimer = null;
@@ -306,6 +328,11 @@ export default class CommentThreadService {
});
}
// Honor NIP-09 deletions: drop comments whose author has since requested
// their removal. Applied during the (silent) initial pass; the emit below
// renders the final, deletions-applied thread.
await this.fetchAndApplyDeletions();
this.emitThreadReady();
if (this.profileHydrationTimer) {
@@ -380,237 +407,35 @@ export default class CommentThreadService {
}
getCommentCacheKey(videoEventId) {
const normalized = normalizeHexId(videoEventId);
if (!normalized) {
return "";
}
return `${COMMENT_CACHE_PREFIX}${normalized.toLowerCase()}`;
return cacheGetCommentCacheKey(videoEventId);
}
handleCommentCacheError(context, videoEventId, error) {
this.commentCacheDiagnostics = {
...this.commentCacheDiagnostics,
storageUnavailable: true,
};
const message =
typeof videoEventId === "string" && videoEventId.trim()
? `[commentThread] Failed to ${context} comment cache for ${videoEventId}.`
: `[commentThread] Failed to ${context} comment cache.`;
if (this.logger?.user?.warn) {
this.logger.user.warn(message, error);
} else if (this.logger?.warn) {
this.logger.warn(message, error);
}
if (this.logger?.dev?.warn && this.logger.dev !== this.logger.user) {
this.logger.dev.warn(message, error);
}
return cacheHandleCommentCacheError(this, context, videoEventId, error);
}
getCachedComments(videoEventId) {
if (
!FEATURE_IMPROVED_COMMENT_FETCHING ||
typeof localStorage === "undefined"
) {
return null;
}
const cacheKey = this.getCommentCacheKey(videoEventId);
if (!cacheKey) {
logDev(
this.logger?.dev,
"[commentThread] Comment cache skipped: invalid video id.",
);
return null;
}
let raw = null;
try {
raw = localStorage.getItem(cacheKey);
} catch (error) {
this.handleCommentCacheError("read", videoEventId, error);
return null;
}
if (raw === null) {
logDev(
this.logger?.dev,
`[commentThread] Comment cache miss for ${videoEventId}: no entry present.`,
);
return null;
}
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") {
logDev(
this.logger?.dev,
`[commentThread] Comment cache rejected for ${videoEventId}: malformed payload.`,
);
this.removeCommentCache(cacheKey);
return null;
}
const cacheVersion = Number.isFinite(parsed.version)
? Number(parsed.version)
: null;
if (cacheVersion !== COMMENT_CACHE_VERSION) {
logDev(
this.logger?.dev,
`[commentThread] Comment cache rejected for ${videoEventId}: version ${cacheVersion} != ${COMMENT_CACHE_VERSION}.`,
);
this.removeCommentCache(cacheKey);
return null;
}
const comments = Array.isArray(parsed.comments)
? parsed.comments
: null;
const timestamp = Number(parsed.timestamp);
if (
Array.isArray(comments) &&
Number.isFinite(timestamp) &&
Date.now() - timestamp <= COMMENT_CACHE_TTL_MS
) {
logDev(
this.logger?.dev,
`[commentThread] Loaded ${comments.length} cached comments for ${videoEventId}.`,
);
return comments;
}
logDev(
this.logger?.dev,
`[commentThread] Comment cache rejected for ${videoEventId}: entry expired.`,
);
this.removeCommentCache(cacheKey);
} catch (error) {
if (this.logger?.warn) {
this.logger.warn(
`[commentThread] Failed to parse cached comments for ${videoEventId}:`,
error,
);
}
logDev(
this.logger?.dev,
`[commentThread] Comment cache rejected for ${videoEventId}: parse error.`,
);
this.removeCommentCache(cacheKey);
}
return null;
return cacheGetCachedComments(this, videoEventId);
}
cacheComments(videoEventId, comments) {
if (
!FEATURE_IMPROVED_COMMENT_FETCHING ||
typeof localStorage === "undefined" ||
!Array.isArray(comments)
) {
return;
}
const cacheKey = this.getCommentCacheKey(videoEventId);
if (!cacheKey) {
return;
}
try {
localStorage.setItem(
cacheKey,
JSON.stringify({
version: COMMENT_CACHE_VERSION,
comments,
timestamp: Date.now(),
}),
);
logDev(
this.logger?.dev,
`[commentThread] Cached ${comments.length} comments for ${videoEventId}.`,
);
} catch (error) {
this.handleCommentCacheError("write", videoEventId, error);
}
return cacheCacheComments(this, videoEventId, comments);
}
removeCommentCache(cacheKey) {
if (!cacheKey || typeof localStorage === "undefined") {
return;
}
try {
localStorage.removeItem(cacheKey);
} catch (error) {
if (this.logger?.warn) {
this.logger.warn(
`[commentThread] Failed to clear cached comments for ${cacheKey}:`,
error,
);
}
}
return cacheRemoveCommentCache(this, cacheKey);
}
clearCommentCache(videoEventId = null) {
if (!FEATURE_IMPROVED_COMMENT_FETCHING || typeof localStorage === "undefined") {
return;
}
if (videoEventId) {
this.removeCommentCache(this.getCommentCacheKey(videoEventId));
return;
}
try {
const keys = Object.keys(localStorage);
keys
.filter((key) => key.startsWith(COMMENT_CACHE_PREFIX))
.forEach((key) => this.removeCommentCache(key));
} catch (error) {
if (this.logger?.warn) {
this.logger.warn(
"[commentThread] Failed to clear comment cache:",
error,
);
}
}
return cacheClearCommentCache(this, videoEventId);
}
persistCommentCache() {
if (!FEATURE_IMPROVED_COMMENT_FETCHING || !this.videoEventId) {
return;
}
const comments = this.serializeCommentsForCache();
this.cacheComments(this.videoEventId, comments);
return cachePersistCommentCache(this);
}
serializeCommentsForCache() {
const events = Array.from(this.eventsById.values());
return events.sort((a, b) => {
const aTime = Number.isFinite(a?.created_at) ? a.created_at : 0;
const bTime = Number.isFinite(b?.created_at) ? b.created_at : 0;
if (aTime !== bTime) {
return aTime - bTime;
}
const aId = normalizeHexId(a?.id);
const bId = normalizeHexId(b?.id);
if (aId && bId) {
return aId.localeCompare(bId);
}
if (aId) {
return -1;
}
if (bId) {
return 1;
}
return 0;
});
return cacheSerializeCommentsForCache(this);
}
startSubscription(target, options = {}) {
@@ -656,6 +481,21 @@ export default class CommentThreadService {
return;
}
if (result.type === "skipped-deleted") {
return;
}
if (result.type === "deletion") {
// A removal can't be expressed as an append; re-render the thread when a
// comment actually disappeared (skip during the initial cache replay /
// bulk load, which emits once at the end).
if (!isInitial && result.removed) {
this.persistCommentCache();
this.emitThreadReady();
}
return;
}
if (!isInitial && result.type === "insert") {
this.emitAppend(result);
return;
@@ -679,6 +519,10 @@ export default class CommentThreadService {
return null;
}
if (isDeletionEvent(event)) {
return this.applyDeletion(event);
}
const parentId = this.extractParentId(event);
const parentKey = toParentKey(parentId);
const createdAt = Number.isFinite(event.created_at) ? event.created_at : 0;
@@ -702,6 +546,13 @@ export default class CommentThreadService {
}
const existingMeta = this.metaById.get(eventId);
// The comment may arrive after we already saw its author's deletion
// request (out-of-order relays). Honor the deletion and don't surface it.
if (!existingMeta && this.isCommentDeleted(eventId, pubkey)) {
return { type: "skipped-deleted", eventId };
}
this.eventsById.set(eventId, event);
this.metaById.set(eventId, { parentKey, createdAt });
@@ -759,6 +610,134 @@ export default class CommentThreadService {
return "";
}
// The comment ids a NIP-09 kind-5 request asks to delete (its `e` tags).
extractDeletionTargets(event) {
const tags = Array.isArray(event?.tags) ? event.tags : [];
const ids = [];
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2 || tag[0] !== "e") {
continue;
}
const id = normalizeHexId(tag[1]);
if (id && !ids.includes(id)) {
ids.push(id);
}
}
return ids;
}
isCommentDeleted(eventId, authorPubkey) {
if (!eventId || !authorPubkey) {
return false;
}
const deleters = this.deletionsByTarget.get(eventId);
return Boolean(deleters && deleters.has(authorPubkey));
}
// Apply a NIP-09 deletion request. A comment is only removed when the
// deletion is signed by the comment's own author (you can't delete someone
// else's comment). Deletions are also remembered so a comment that arrives
// afterwards (out-of-order relays) is suppressed on insert.
applyDeletion(event) {
const deleter = normalizePubkey(event.pubkey);
if (!deleter) {
return { type: "deletion", removed: false };
}
const targets = this.extractDeletionTargets(event);
let removed = false;
for (const targetId of targets) {
let deleters = this.deletionsByTarget.get(targetId);
if (!deleters) {
deleters = new Set();
this.deletionsByTarget.set(targetId, deleters);
}
deleters.add(deleter);
const existing = this.eventsById.get(targetId);
if (existing && normalizePubkey(existing.pubkey) === deleter) {
this.removeComment(targetId);
removed = true;
}
}
return { type: "deletion", removed };
}
removeComment(eventId) {
const meta = this.metaById.get(eventId);
if (meta) {
this.removeFromParentList(meta.parentKey, eventId);
this.metaById.delete(eventId);
}
this.eventsById.delete(eventId);
// Replies to a removed comment become unreachable from the root and so are
// hidden along with their deleted parent; their child list is left in place
// but never walked by getSnapshot().
}
async fetchAndApplyDeletions() {
const commentIds = Array.from(this.eventsById.keys());
if (!commentIds.length || typeof this.fetchDeletionEvents !== "function") {
return;
}
let deletionEvents = [];
try {
deletionEvents = await this.fetchDeletionEvents(commentIds, {
relays: this.activeRelays,
});
} catch (error) {
logDev(
this.logger?.dev,
"[commentThread] Failed to fetch comment deletions:",
error,
);
return;
}
if (Array.isArray(deletionEvents)) {
for (const deletionEvent of deletionEvents) {
this.processIncomingEvent(deletionEvent, { isInitial: true });
}
}
}
async _defaultFetchDeletions(commentIds, { relays } = {}) {
const pool = this.nostrClient?.pool;
if (
!pool ||
typeof pool.list !== "function" ||
!Array.isArray(commentIds) ||
!commentIds.length
) {
return [];
}
const relayList =
Array.isArray(relays) && relays.length
? relays
: Array.isArray(this.nostrClient?.relays)
? this.nostrClient.relays
: [];
if (!relayList.length) {
return [];
}
try {
const results = await pool.list(relayList, [
{ kinds: [DELETION_EVENT_KIND], "#e": commentIds },
]);
if (!Array.isArray(results)) {
return [];
}
return results
.flat()
.filter((event) => event && typeof event === "object");
} catch (error) {
logDev(
this.logger?.dev,
"[commentThread] Default deletion fetch failed:",
error,
);
return [];
}
}
extractRootIdentifier(event) {
const tags = Array.isArray(event?.tags) ? event.tags : [];
for (let index = tags.length - 1; index >= 0; index -= 1) {
+9 -4
View File
@@ -1,6 +1,6 @@
import { devLogger } from "../utils/logger.js";
import { buildVideoAddressPointer } from "../utils/videoPointer.js";
import { COMMENT_EVENT_KIND } from "../nostr/commentEvents.js";
import { getAllowedCommentKinds } from "../nostr/commentTargetNormalizer.js";
const DEFAULT_MAX_DISCUSSION_COUNT_VIDEOS = 24;
const COUNT_UNSUPPORTED_TITLE = "Relay does not support NIP-45 COUNT queries.";
@@ -131,17 +131,22 @@ export default class DiscussionCountService {
return [];
}
// Count the same kinds the thread actually displays (NIP-22 kind 1111 plus
// legacy kind 1). Counting only 1111 made the badge undercount threads that
// contain legacy comments.
const commentKinds = getAllowedCommentKinds();
const filters = [];
const eventId = typeof video.id === "string" ? video.id.trim() : "";
if (eventId) {
filters.push({ kinds: [COMMENT_EVENT_KIND], "#E": [eventId] });
filters.push({ kinds: commentKinds, "#E": [eventId] });
}
const address = buildVideoAddressPointer(video);
const rootIdentifier =
typeof video.videoRootId === "string" ? video.videoRootId.trim() : "";
const uppercaseFilter = { kinds: [COMMENT_EVENT_KIND] };
const uppercaseFilter = { kinds: commentKinds };
let hasUppercasePointer = false;
if (rootIdentifier) {
@@ -184,7 +189,7 @@ export default class DiscussionCountService {
}
if (address) {
filters.push({ kinds: [COMMENT_EVENT_KIND], "#A": [address] });
filters.push({ kinds: commentKinds, "#A": [address] });
}
return filters;
+72
View File
@@ -0,0 +1,72 @@
// js/services/hashtagPreferencesHelpers.js
//
// Pure helpers extracted from hashtagPreferencesService.js to keep that service
// under the file-size budget. No module state — safe to test in isolation.
import { HEX64_REGEX } from "../utils/hex.js";
const MAX_DECRYPT_RETRY_DELAY_MS = 60000;
// Transient decrypt failures that must RETRY in the background rather than give
// up: our own timeout, a severed nip-07 message port ("message channel closed"),
// and the circuit breaker's fast-fail. Without this a brief channel drop during
// the login burst abandons hashtag prefs until a refresh (KNOWN_BUGS #0).
const TRANSIENT_DECRYPT_ERROR_CODES = new Set([
"hashtag-preferences-decrypt-timeout",
"nip07-channel-unresponsive",
]);
const TRANSIENT_DECRYPT_MESSAGE_PATTERNS = [
"message channel closed",
"could not establish connection",
"receiving end does not exist",
"extension context invalidated",
"connection lost",
"channel is unresponsive",
];
export function isTransientDecryptError(error) {
if (!error) return false;
if (error.code && TRANSIENT_DECRYPT_ERROR_CODES.has(error.code)) return true;
const message =
typeof error.message === "string" ? error.message.toLowerCase() : "";
if (TRANSIENT_DECRYPT_MESSAGE_PATTERNS.some((p) => message.includes(p))) {
return true;
}
// decryptEvent wraps the per-scheme Promise.any failures in a generic
// "decrypt-failed" error and nests the real causes under .errors/.cause —
// recurse so a channel-death sub-error still triggers a retry.
const nested = Array.isArray(error.errors)
? error.errors
: Array.isArray(error.cause)
? error.cause
: null;
if (nested) {
return nested.some((entry) => isTransientDecryptError(entry?.error || entry));
}
return false;
}
export function computeRetryDelay(baseDelayMs, attempt) {
const normalizedAttempt =
Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
const multiplier = 2 ** normalizedAttempt;
const nextDelay = Math.max(250, Math.floor(baseDelayMs * multiplier));
return Math.min(MAX_DECRYPT_RETRY_DELAY_MS, nextDelay);
}
export function normalizeHexPubkey(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (HEX64_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
return null;
}
+19 -67
View File
@@ -39,7 +39,11 @@ import {
computeIncrementalSinceWithOverlap,
selectNewestListEvent,
} from "../nostr/listEventOrdering.js";
import { HEX64_REGEX } from "../utils/hex.js";
import {
isTransientDecryptError,
computeRetryDelay,
normalizeHexPubkey,
} from "./hashtagPreferencesHelpers.js";
import { SHORT_TIMEOUT_MS, NETWORK_RETRY_DELAY_MS } from "../constants.js";
const LOG_PREFIX = "[HashtagPreferences]";
@@ -57,54 +61,6 @@ const DECRYPT_TIMEOUT_MS = 30000;
// PERF: Reduced from 3s to 1.5s — extensions that already granted permission
// should recover near-instantly. Shorter delay speeds up the login path.
const DECRYPT_RETRY_DELAY_MS = NETWORK_RETRY_DELAY_MS;
const MAX_DECRYPT_RETRY_DELAY_MS = 60000;
// Transient decrypt failures that must RETRY in the background rather than give
// up: our own timeout, a severed nip-07 message port ("message channel closed"),
// and the circuit breaker's fast-fail. Without this a brief channel drop during
// the login burst abandons hashtag prefs until a refresh (KNOWN_BUGS #0).
const TRANSIENT_DECRYPT_ERROR_CODES = new Set([
"hashtag-preferences-decrypt-timeout",
"nip07-channel-unresponsive",
]);
const TRANSIENT_DECRYPT_MESSAGE_PATTERNS = [
"message channel closed",
"could not establish connection",
"receiving end does not exist",
"extension context invalidated",
"connection lost",
"channel is unresponsive",
];
function isTransientDecryptError(error) {
if (!error) return false;
if (error.code && TRANSIENT_DECRYPT_ERROR_CODES.has(error.code)) return true;
const message =
typeof error.message === "string" ? error.message.toLowerCase() : "";
if (TRANSIENT_DECRYPT_MESSAGE_PATTERNS.some((p) => message.includes(p))) {
return true;
}
// decryptEvent wraps the per-scheme Promise.any failures in a generic
// "decrypt-failed" error and nests the real causes under .errors/.cause —
// recurse so a channel-death sub-error still triggers a retry.
const nested = Array.isArray(error.errors)
? error.errors
: Array.isArray(error.cause)
? error.cause
: null;
if (nested) {
return nested.some((entry) => isTransientDecryptError(entry?.error || entry));
}
return false;
}
function computeRetryDelay(baseDelayMs, attempt) {
const normalizedAttempt =
Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
const multiplier = 2 ** normalizedAttempt;
const nextDelay = Math.max(250, Math.floor(baseDelayMs * multiplier));
return Math.min(MAX_DECRYPT_RETRY_DELAY_MS, nextDelay);
}
class TinyEventEmitter {
constructor() {
this.listeners = new Map();
@@ -153,23 +109,6 @@ const EVENTS = Object.freeze({
CHANGE: "change",
});
function normalizeHexPubkey(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (HEX64_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
return null;
}
function normalizeEncryptionToken(value) {
if (typeof value !== "string") {
return "";
@@ -1298,7 +1237,20 @@ class HashtagPreferencesService {
s === "nip44" || s === "nip44_v2" ? "nip44" : s;
const seenFamilies = new Set();
const attemptSchemes = [];
const attempts = order
// Route by ciphertext format: only the family matching the payload can
// decrypt it (NIP-04 carries "?iv="; otherwise NIP-44). Racing the other
// family is wasted work — and under a NIP-46 remote signer it's a wasted
// PUBLISHED relay RPC that floods the relay ("rate-limited") and stalls
// decryption. Restrict the probe to the matching family.
const formatFamily =
typeof ciphertext === "string" && ciphertext.includes("?iv=")
? "nip04"
: "nip44";
const matchingOrder = order.filter(
(scheme) => getSchemeFamily(scheme) === formatFamily,
);
const probeOrder = matchingOrder.length ? matchingOrder : order;
const attempts = probeOrder
.map((scheme) => {
const family = getSchemeFamily(scheme);
if (seenFamilies.has(family)) return null;
+98
View File
@@ -0,0 +1,98 @@
// js/services/playbackHelpers.js
//
// Small, self-contained helpers extracted from playbackService.js to keep that
// orchestrator under the file-size budget. Pure logic with no dependency on the
// playback session state.
import { safeDecodeURIComponent } from "../utils/safeDecode.js";
/**
* Minimal synchronous event emitter used by the playback session to surface
* status/fallback/sourcechange events to the coordinator. Errors thrown by a
* listener are swallowed (and optionally logged) so one bad listener can't
* abort the rest of the playback pipeline.
*/
export class SimpleEventEmitter {
constructor(logger = null) {
this.logger = typeof logger === "function" ? logger : null;
this.listeners = new Map();
}
on(eventName, handler) {
if (typeof handler !== "function") {
return () => {};
}
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, new Set());
}
const handlers = this.listeners.get(eventName);
handlers.add(handler);
return () => {
handlers.delete(handler);
if (!handlers.size) {
this.listeners.delete(eventName);
}
};
}
emit(eventName, detail) {
const handlers = this.listeners.get(eventName);
if (!handlers || !handlers.size) {
return;
}
for (const handler of Array.from(handlers)) {
try {
handler(detail);
} catch (err) {
if (this.logger) {
this.logger("[PlaybackService] Listener error", err);
}
}
}
}
}
const WEBSEED_PARAM_KEYS = new Set(["ws", "webseed"]);
/**
* Extract every web seed URL (`ws=`/`webseed=`) from a magnet's query string,
* in order, decoded. Used to seed WebTorrent's urlList from the magnet.
*
* @param {string} magnetUri
* @returns {string[]}
*/
export const extractWebSeedsFromMagnet = (magnetUri) => {
if (typeof magnetUri !== "string") {
return [];
}
const trimmed = magnetUri.trim();
if (!trimmed) {
return [];
}
const [withoutFragment] = trimmed.split("#", 1);
const [, query = ""] = withoutFragment.split("?", 2);
if (!query) {
return [];
}
const webSeeds = [];
for (const segment of query.split("&")) {
if (!segment) {
continue;
}
const [rawKey, rawValue = ""] = segment.split("=", 2);
if (!rawKey) {
continue;
}
const key = rawKey.trim().toLowerCase();
if (!WEBSEED_PARAM_KEYS.has(key)) {
continue;
}
const decodedValue = safeDecodeURIComponent(rawValue.replace(/\+/g, "%20"));
const candidate = decodedValue.trim();
if (candidate) {
webSeeds.push(candidate);
}
}
return webSeeds;
};
+45 -86
View File
@@ -1,7 +1,10 @@
import { userLogger } from "../utils/logger.js";
import { safeDecodeURIComponent } from "../utils/safeDecode.js";
import { PLAYBACK_START_TIMEOUT } from "../constants.js";
import { getCurrentVideo, setCurrentVideo } from "../state/appState.js";
import {
SimpleEventEmitter,
extractWebSeedsFromMagnet,
} from "./playbackHelpers.js";
// js/services/playbackService.js
/**
@@ -21,46 +24,6 @@ import { getCurrentVideo, setCurrentVideo } from "../state/appState.js";
* old session is cancelled and doesn't overwrite the new one.
*/
class SimpleEventEmitter {
constructor(logger = null) {
this.logger = typeof logger === "function" ? logger : null;
this.listeners = new Map();
}
on(eventName, handler) {
if (typeof handler !== "function") {
return () => {};
}
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, new Set());
}
const handlers = this.listeners.get(eventName);
handlers.add(handler);
return () => {
handlers.delete(handler);
if (!handlers.size) {
this.listeners.delete(eventName);
}
};
}
emit(eventName, detail) {
const handlers = this.listeners.get(eventName);
if (!handlers || !handlers.size) {
return;
}
for (const handler of Array.from(handlers)) {
try {
handler(detail);
} catch (err) {
if (this.logger) {
this.logger("[PlaybackService] Listener error", err);
}
}
}
}
}
const HOSTED_URL_SUCCESS_MESSAGE = "✅ Streaming from hosted URL";
const DEFAULT_UNSUPPORTED_BTITH_MESSAGE =
"This magnet link is missing a compatible BitTorrent v1 info hash.";
@@ -68,44 +31,6 @@ const AUTH_STATUS_CODES = new Set([401, 403]);
const SSL_ERROR_PATTERN = /(ssl|cert|certificate)/i;
const CORS_ERROR_PATTERN = /cors/i;
const PROBE_CACHE_TTL_MS = 45000;
const WEBSEED_PARAM_KEYS = new Set(["ws", "webseed"]);
const extractWebSeedsFromMagnet = (magnetUri) => {
if (typeof magnetUri !== "string") {
return [];
}
const trimmed = magnetUri.trim();
if (!trimmed) {
return [];
}
const [withoutFragment] = trimmed.split("#", 1);
const [, query = ""] = withoutFragment.split("?", 2);
if (!query) {
return [];
}
const webSeeds = [];
for (const segment of query.split("&")) {
if (!segment) {
continue;
}
const [rawKey, rawValue = ""] = segment.split("=", 2);
if (!rawKey) {
continue;
}
const key = rawKey.trim().toLowerCase();
if (!WEBSEED_PARAM_KEYS.has(key)) {
continue;
}
const decodedValue = safeDecodeURIComponent(rawValue.replace(/\+/g, "%20"));
const candidate = decodedValue.trim();
if (candidate) {
webSeeds.push(candidate);
}
}
return webSeeds;
};
const getHostedUrlFailureDetails = (probeResult = {}) => {
const outcome = probeResult?.outcome || "error";
const status = Number.isFinite(probeResult?.status)
@@ -868,10 +793,12 @@ class PlaybackSession extends SimpleEventEmitter {
webSeedCandidates.push(trimmed);
};
if (httpsUrl) {
addWebSeedCandidate(httpsUrl);
}
// NOTE: the hosted CDN URL is intentionally NOT added here. As a
// WebTorrent `urlList` entry it is only valid for single-file torrents;
// for multi-file torrents WebTorrent appends the torrent's internal path
// and floods the network with doubled, 404-ing requests. It is forwarded
// separately as `hostedUrlWebSeed` and attached post-metadata only when
// the torrent is a single file (see TorrentClient.handleTorrentStream).
const magnetWebSeeds = extractWebSeedsFromMagnet(this.magnetForPlayback);
if (magnetWebSeeds.length > 0) {
magnetWebSeeds.forEach((seed) => addWebSeedCandidate(seed));
@@ -898,6 +825,20 @@ class PlaybackSession extends SimpleEventEmitter {
}
activeVideoEl.src = "";
activeVideoEl.srcObject = null;
// Direct hosted playback must NOT require CORS. A leftover
// crossOrigin="anonymous" (e.g. set by a prior WebTorrent attempt on
// this reused element) makes the browser issue a CORS request that many
// CDNs — and redirect targets like archive.org's storage nodes — do not
// answer with Access-Control-Allow-Origin, so the load dies with
// MEDIA_ERR_SRC_NOT_SUPPORTED ("source not supported or blocked"). We
// never read pixels from the hosted <video>, so clear it and let any
// URL play.
try {
activeVideoEl.removeAttribute("crossorigin");
} catch (err) {
// ignore
}
activeVideoEl.crossOrigin = null;
try {
activeVideoEl.load();
} catch (err) {
@@ -934,6 +875,7 @@ class PlaybackSession extends SimpleEventEmitter {
torrentInstance = await playViaWebTorrent(this.magnetForPlayback, {
fallbackMagnet: this.fallbackMagnet,
urlList: webSeedCandidates,
hostedUrlWebSeed: httpsUrl,
});
} catch (err) {
const errorMessage = getTorrentErrorMessage(err);
@@ -1086,6 +1028,14 @@ class PlaybackSession extends SimpleEventEmitter {
};
try {
// Guarantee the direct load is CORS-free regardless of how we got
// here (see resetVideoElement) so any hosted URL plays reliably.
try {
activeVideoEl.removeAttribute("crossorigin");
} catch (err) {
// ignore
}
activeVideoEl.crossOrigin = null;
activeVideoEl.src = httpsUrl;
const playPromise = activeVideoEl.play();
if (playPromise && typeof playPromise.catch === "function") {
@@ -1180,6 +1130,13 @@ class PlaybackSession extends SimpleEventEmitter {
// --- Execution Flow ---
let tryUrlFirst = this.service.urlFirstEnabled;
// CDN-first for dual-source videos: whenever a hosted URL is available,
// play it first for instant, reliable startup. WebTorrent is reserved for
// videos that have no URL (or when the viewer explicitly forces P2P). This
// intentionally takes precedence over a torrent-first instance default,
// because waiting on a P2P cold-start when a working CDN exists is the
// slow, flaky path users were hitting.
if (httpsUrl) tryUrlFirst = true;
if (forcedSource === "url") tryUrlFirst = true;
if (forcedSource === "torrent") tryUrlFirst = false;
@@ -1223,9 +1180,11 @@ class PlaybackSession extends SimpleEventEmitter {
// Try Torrent First
if (this.magnetForPlayback) {
try {
// If we have a fallback URL, use the effective timeout.
// Otherwise, we must wait indefinitely for peers because there is no plan B.
const torrentTimeout = httpsUrl ? effectiveTimeout : 0;
// We only reach the torrent-first path when there is no hosted URL
// to fall back to (CDN-first handles the dual-source case above) or
// the viewer explicitly forced P2P. Either way there is no plan B,
// so wait indefinitely for peers rather than abandoning playback.
const torrentTimeout = 0;
// Wrap Torrent attempt in timeout
const torrentResult = await withTimeout(
+233 -200
View File
@@ -19,6 +19,7 @@
import {
buildR2Key,
buildPublicUrl,
computeStorageContentHash,
} from "../r2.js";
import {
sanitizeBucketName,
@@ -34,6 +35,7 @@ import {
ensureBucketCors,
ensureBucketExists,
deleteObject,
listObjects,
} from "../storage/s3-multipart.js";
import { ensureS3SdkLoaded, makeS3Client } from "../storage/s3-client.js";
import { truncateMiddle } from "../utils/formatters.js";
@@ -41,118 +43,209 @@ import { userLogger, devLogger } from "../utils/logger.js";
import {
buildStoragePointerValue,
buildStoragePrefixFromKey,
collectVideoStorageKeys,
} from "../utils/storagePointer.js";
import { safeEncodeNpub } from "../utils/nostrHelpers.js";
import { isLikelyCorsError } from "../utils/uploadErrorHints.js";
import {
getVideoNoteErrorMessage,
normalizeVideoNotePayload,
VIDEO_NOTE_ERROR_CODES,
} from "./videoNotePayload.js";
import storageService from "./storageService.js";
import { calculateTorrentInfoHash } from "../utils/torrentHash.js";
import {
normalizeInfoHash,
isValidInfoHash,
resolveUploadIdentifier,
buildCorsGuidance,
createDefaultSettings,
safeDecodeNpub,
verifyPublicAccess as verifyPublicAccessHelper,
listVideoStorageObjects as listVideoStorageObjectsHelper,
deleteStorageKeys as deleteStorageKeysHelper,
} from "./r2ServiceHelpers.js";
const STATUS_VARIANTS = new Set(["info", "success", "error", "warning"]);
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
function normalizeInfoHash(value) {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
// Shared CORS-error classifier (also used by s3UploadService). Re-exported here
// for backward compatibility with existing imports/tests.
export { isLikelyCorsError };
function isValidInfoHash(value) {
return INFO_HASH_PATTERN.test(value);
}
async function resolveUploadIdentifier({ infoHash = "", file = null } = {}) {
const normalizedInfoHash = normalizeInfoHash(infoHash);
if (isValidInfoHash(normalizedInfoHash)) {
return normalizedInfoHash;
}
if (!file) {
return "";
}
try {
const computedHash = await calculateTorrentInfoHash(file);
const normalizedComputed = normalizeInfoHash(computedHash);
if (isValidInfoHash(normalizedComputed)) {
return normalizedComputed;
}
} catch (err) {
userLogger.warn("Failed to precompute info hash for storage key:", err);
}
return "";
}
function buildCorsGuidance({ accountId } = {}) {
const origin =
typeof window !== "undefined" && window.location
? window.location.origin
: "";
const originLabel = origin && origin !== "null" ? origin : "<your-app-origin>";
const endpoint = accountId
? `https://${accountId}.r2.cloudflarestorage.com`
: "https://<account>.r2.cloudflarestorage.com";
return [
"This is likely a CORS issue.",
`Configure CORS on the R2 S3 API endpoint (${endpoint}) — not just the public domain.`,
`Add AllowedOrigins: ${originLabel} (and any other origins),`,
"AllowedMethods: GET, HEAD, PUT, POST, DELETE, OPTIONS,",
"and AllowedHeaders: *.",
].join(" ");
}
function createDefaultSettings() {
return {
accountId: "",
accessKeyId: "",
secretAccessKey: "",
baseDomain: "", // Now interpreted as the public URL base
buckets: {},
};
}
function safeDecodeNpub(npub) {
if (typeof npub !== "string") {
return null;
}
const trimmed = npub.trim();
if (!trimmed) {
return null;
}
if (!trimmed.startsWith("npub1")) {
return null;
}
try {
if (
typeof window !== "undefined" &&
window.NostrTools &&
window.NostrTools.nip19
) {
const { type, data } = window.NostrTools.nip19.decode(trimmed);
if (type === "npub") {
return data;
}
}
} catch (err) {
// Ignore decode errors
}
return null;
}
class R2Service {
export class R2Service {
constructor({
makeR2Client: makeR2ClientOverride,
makeS3Client: makeS3ClientOverride,
multipartUpload: multipartUploadOverride,
ensureBucketExists: ensureBucketExistsOverride,
ensureBucketCors: ensureBucketCorsOverride,
deleteObject: deleteObjectOverride,
listObjects: listObjectsOverride,
} = {}) {
this.listeners = new Map();
this.cloudflareSettings = null;
this.makeR2Client = makeR2ClientOverride || makeR2Client;
this.makeS3Client = makeS3ClientOverride || makeS3Client;
this.multipartUpload = multipartUploadOverride || multipartUpload;
this.ensureBucketExists = ensureBucketExistsOverride || ensureBucketExists;
this.ensureBucketCors = ensureBucketCorsOverride || ensureBucketCors;
this.deleteObject = deleteObjectOverride || deleteObject;
this.listObjects = listObjectsOverride || listObjects;
}
/**
* Best-effort deletion of the R2/S3 objects backing one or more video notes
* (the video file, its sibling `.torrent`, and the thumbnail). Used so that
* deleting/superseding a video also removes its bytes from storage instead of
* leaving them publicly downloadable forever.
*
* Safety: only objects whose URL lives under the owner's configured bucket
* public base URL are touched (external links are ignored), and the method
* NEVER throws — storage cleanup must not block note deletion. Returns a
* summary describing what happened.
*
* @param {object} params
* @param {Array|object} params.videos - parsed video note(s) with url/thumbnail
* @param {string} [params.npub] - owner npub (preferred)
* @param {string} [params.pubkey] - owner hex pubkey (encoded to npub if npub absent)
* @param {object} [params.credentials] - pre-resolved connection settings
* @returns {Promise<{deleted:string[],failed:Array,skipped:boolean,reason:string}>}
*/
async deleteVideoStorage({
videos = [],
npub = "",
pubkey = "",
credentials = null,
} = {}) {
const list = (Array.isArray(videos) ? videos : [videos]).filter(
(entry) => entry && typeof entry === "object"
);
const summary = { deleted: [], failed: [], skipped: false, reason: "" };
const skip = (reason) => {
summary.skipped = true;
summary.reason = reason;
return summary;
};
if (!list.length) {
return skip("no-videos");
}
let resolvedNpub = (npub || "").trim();
if (!resolvedNpub && pubkey) {
resolvedNpub = safeEncodeNpub(pubkey) || "";
}
let settings = credentials;
if (!settings && resolvedNpub) {
try {
settings = await this.resolveConnection(resolvedNpub);
} catch (err) {
devLogger.warn(
"[R2Service] deleteVideoStorage: failed to resolve connection:",
err
);
}
}
if (!settings) {
return skip("no-connection");
}
const accessKeyId = (settings.accessKeyId || "").trim();
const secretAccessKey = (settings.secretAccessKey || "").trim();
const bucket = (settings.bucket || "").trim();
const publicBaseUrl = (
settings.publicBaseUrl ||
settings.baseDomain ||
""
).trim();
if (!publicBaseUrl) {
// Without the public base URL we can't tell which objects belong to this
// user's bucket, so there's nothing safe to remove.
return skip("missing-bucket-or-base-url");
}
// Determine the affected objects BEFORE the credentials check. publicBaseUrl
// is available even while storage is locked, so this lets us report
// "storage-locked" only when there are genuinely hosted objects to remove —
// an external-URL video (no matching objects) is never a false alarm.
const keys = collectVideoStorageKeys({ videos: list, publicBaseUrl });
if (!keys.length) {
return skip("no-matching-objects");
}
if (!accessKeyId || !secretAccessKey) {
// There ARE objects to remove but storage is locked / keys unavailable —
// surface this distinctly so the caller can warn the user the hosted
// files were left behind.
return skip("storage-locked");
}
if (!bucket) {
return skip("missing-bucket-or-base-url");
}
let s3;
try {
await ensureS3SdkLoaded();
// Provider-aware client: Cloudflare R2 is always path-style; a generic S3
// bucket may be virtual-hosted-style, so honor the connection's
// forcePathStyle (mirrors uploadFile). Using makeR2Client unconditionally
// here forced path-style and broke cleanup for vhost-style S3 buckets.
const useR2 =
settings.provider === "cloudflare_r2" || Boolean(settings.accountId);
if (useR2) {
s3 = this.makeR2Client({
accountId: settings.accountId,
endpoint: settings.endpoint,
accessKeyId,
secretAccessKey,
region: settings.region,
});
} else {
s3 = this.makeS3Client({
endpoint: settings.endpoint,
region: settings.region,
accessKeyId,
secretAccessKey,
forcePathStyle: Boolean(settings.forcePathStyle),
});
}
} catch (err) {
devLogger.warn(
"[R2Service] deleteVideoStorage: failed to build storage client:",
err
);
return skip("client-error");
}
for (const key of keys) {
try {
// deleteObject already treats 404/NoSuchKey as success.
await this.deleteObject({ s3, bucket, key });
summary.deleted.push(key);
} catch (err) {
summary.failed.push({ key, error: err?.message || String(err) });
devLogger.warn(`[R2Service] Failed to delete storage object ${key}:`, err);
}
}
return summary;
}
/**
* List the object keys under the user's storage prefix (u/<npub>/) so the
* "My Videos" tab can reconcile notes against actual bucket contents. Unlock-
* gated (needs credentials); returns { ok, reason, keys }.
*/
async listVideoStorageObjects(args = {}) {
return listVideoStorageObjectsHelper(this, args);
}
/**
* Delete specific bucket object keys (orphan cleanup). Unlock-gated; returns
* { ok, deleted, failed, reason }.
*/
async deleteStorageKeys(args = {}) {
return deleteStorageKeysHelper(this, args);
}
on(event, handler) {
@@ -602,112 +695,7 @@ class R2Service {
* @returns {Promise<{success: boolean, error?: string}>}
*/
async verifyPublicAccess({ settings, npub }) {
if (!settings || !npub) {
return { success: false, error: "Missing settings or npub." };
}
const { accountId, accessKeyId, secretAccessKey, baseDomain } = settings;
if (!accountId || !accessKeyId || !secretAccessKey || !baseDomain) {
return { success: false, error: "Incomplete credentials or missing public URL." };
}
const bucketName =
settings.bucket || settings.meta?.bucket || sanitizeBucketName(npub);
const verifyKey = `.verify-${Date.now()}-${Math.random().toString(36).substring(7)}.txt`;
const verifyContent = "bitvid-verification";
const publicUrl = buildPublicUrl(baseDomain, verifyKey);
userLogger.info(
`[R2] Verifying access for Bucket: '${bucketName}' in Account: '${truncateMiddle(accountId, 6)}'`
);
try {
// 1. Initialize S3
const s3 = this.makeR2Client({
accountId,
accessKeyId,
secretAccessKey,
endpoint: settings.endpoint,
region: settings.region,
});
// 2. Ensure bucket (best effort)
try {
await this.ensureBucketExists({
s3,
bucket: bucketName,
region: settings.region,
});
} catch (setupErr) {
userLogger.warn("Bucket creation/check warning during verification:", setupErr);
// Continue, assuming bucket might already exist and be configured
}
// 3. Ensure CORS (best effort)
try {
const corsOrigins = this.getCorsOrigins();
if (corsOrigins.length > 0) {
await this.ensureBucketCors({
s3,
bucket: bucketName,
origins: corsOrigins,
region: settings.region,
});
}
} catch (corsErr) {
userLogger.warn("CORS setup warning during verification:", corsErr);
}
// 4. Upload Test File
const file = new File([verifyContent], "verify.txt", { type: "text/plain" });
await this.multipartUpload({
s3,
bucket: bucketName,
key: verifyKey,
file,
contentType: "text/plain",
createBucketIfMissing: true,
region: settings.region,
});
// 4. Verify Public Access (Fetch)
// Wait a moment for propagation (R2 is usually instant-ish but helpful to wait)
await new Promise((r) => setTimeout(r, 500));
const response = await fetch(publicUrl, { method: "GET", cache: "no-cache" });
if (!response.ok) {
// Cleanup attempt
try { await this.deleteObject({ s3, bucket: bucketName, key: verifyKey }); } catch (e) {}
if (response.status === 404) {
return { success: false, error: "File not found. Check your Public Bucket URL." };
}
return { success: false, error: `Public access failed (HTTP ${response.status}). Is the bucket public?` };
}
const text = await response.text();
// Cleanup
try { await this.deleteObject({ s3, bucket: bucketName, key: verifyKey }); } catch (e) {}
if (text.trim() !== verifyContent) {
return { success: false, error: "Content mismatch. URL might be pointing elsewhere." };
}
return { success: true };
} catch (err) {
userLogger.error("Verification failed:", err);
let errorMessage = err.message || "Unknown error during verification.";
if (
errorMessage.includes("Failed to fetch") ||
errorMessage.includes("NetworkError")
) {
errorMessage += ` ${buildCorsGuidance({ accountId })} Also verify that the Bucket Name exists and your API Token has 'Object Read & Write' permissions.`;
}
return { success: false, error: errorMessage };
}
return verifyPublicAccessHelper(this, { settings, npub });
}
async updateCloudflareBucketPreview({ hasPubkey = false, npub = "" } = {}) {
@@ -869,9 +857,27 @@ class R2Service {
this.updateCloudflareProgress(0);
this.setCloudflareUploading(true);
// Computing the torrent info-hash (and the content-hash fallback) reads the
// ENTIRE file before the upload starts and before any progress is emitted —
// on multi-GB videos that's a long, silent pause that looks like a freeze.
// Surface a status so the user knows work is happening.
const willHashFile = Boolean(file) && !isValidInfoHash(normalizeInfoHash(infoHash));
if (willHashFile) {
this.setCloudflareUploadStatus(
"Analyzing video (large files can take a moment)…",
"info",
);
}
const keyIdentifier = await resolveUploadIdentifier({ infoHash, file });
const normalizedInfoHash = normalizeInfoHash(infoHash || keyIdentifier);
const hasValidInfoHash = isValidInfoHash(normalizedInfoHash);
// When no info-hash is available, derive a content-based namespace so two
// distinct URL-first uploads that share a filename can't overwrite each
// other. Kept separate from keyIdentifier so magnet generation still keys
// off the real info-hash only.
const storageIdentifier =
keyIdentifier || (file ? await computeStorageContentHash(file) : "");
let bucketResult = null;
try {
@@ -909,7 +915,7 @@ class R2Service {
this.setCloudflareUploadStatus(statusMessage, "info");
// Use forced keys if provided, otherwise generate them
const key = forcedVideoKey || buildR2Key(npub, file, keyIdentifier);
const key = forcedVideoKey || buildR2Key(npub, file, storageIdentifier);
const publicUrl = forcedVideoUrl || buildPublicUrl(bucketEntry.publicBaseUrl, key);
const buildTorrentKey = () => {
@@ -930,6 +936,11 @@ class R2Service {
region: effectiveSettings.region,
});
// Track optional-asset failures so the user gets a visible signal instead
// of a silently-missing thumbnail/torrent (Cloudflare-upload #5).
let thumbnailFailed = false;
let torrentFailed = false;
if (thumbnailFile) {
this.setCloudflareUploadStatus("Uploading thumbnail...", "info");
const thumbExt = thumbnailFile.name.split('.').pop() || 'jpg';
@@ -951,6 +962,11 @@ class R2Service {
}
} catch (err) {
userLogger.warn("Thumbnail upload failed, continuing with video...", err);
thumbnailFailed = true;
this.setCloudflareUploadStatus(
"Thumbnail upload failed — publishing without it.",
"warning"
);
}
}
@@ -990,6 +1006,11 @@ class R2Service {
}
} catch (err) {
userLogger.warn("Torrent metadata upload failed, continuing...", err);
torrentFailed = true;
this.setCloudflareUploadStatus(
"Torrent upload failed — publishing URL-first without the .torrent.",
"warning"
);
}
}
@@ -1088,9 +1109,15 @@ class R2Service {
publishOutcome = Boolean(published);
if (publishOutcome) {
const caveats = [];
if (thumbnailFailed) caveats.push("thumbnail");
if (torrentFailed) caveats.push("torrent");
const suffix = caveats.length
? ` (note: ${caveats.join(" & ")} upload failed)`
: "";
this.setCloudflareUploadStatus(
`Published ${publicUrl}`,
"success"
`Published ${publicUrl}${suffix}`,
caveats.length ? "warning" : "success"
);
}
}
@@ -1098,10 +1125,16 @@ class R2Service {
return publishOutcome;
} catch (err) {
userLogger.error("Cloudflare upload failed:", err);
this.setCloudflareUploadStatus(
err?.message ? `Upload failed: ${err.message}` : "Upload failed.",
"error"
);
let message = err?.message
? `Upload failed: ${err.message}`
: "Upload failed.";
// A CORS rejection during the browser PUT/POST looks like an opaque
// "Failed to fetch" — attach actionable guidance so users who skipped the
// connection test still know what to fix (the #1 first-upload failure).
if (isLikelyCorsError(err)) {
message += ` ${buildCorsGuidance({ accountId })}`;
}
this.setCloudflareUploadStatus(message, "error");
return false;
} finally {
this.setCloudflareUploading(false);
+340
View File
@@ -0,0 +1,340 @@
// js/services/r2ServiceHelpers.js
//
// State-free helpers extracted from r2Service.js to keep that service under the
// file-size budget. No behavior change.
import { userLogger, devLogger } from "../utils/logger.js";
import { calculateTorrentInfoHash } from "../utils/torrentHash.js";
import { buildPublicUrl } from "../r2.js";
import { sanitizeBucketName } from "../storage/r2-mgmt.js";
import { truncateMiddle } from "../utils/formatters.js";
import { safeEncodeNpub } from "../utils/nostrHelpers.js";
import { ensureS3SdkLoaded } from "../storage/s3-client.js";
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
export function normalizeInfoHash(value) {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
export function isValidInfoHash(value) {
return INFO_HASH_PATTERN.test(value);
}
export async function resolveUploadIdentifier({ infoHash = "", file = null } = {}) {
const normalizedInfoHash = normalizeInfoHash(infoHash);
if (isValidInfoHash(normalizedInfoHash)) {
return normalizedInfoHash;
}
if (!file) {
return "";
}
try {
const computedHash = await calculateTorrentInfoHash(file);
const normalizedComputed = normalizeInfoHash(computedHash);
if (isValidInfoHash(normalizedComputed)) {
return normalizedComputed;
}
} catch (err) {
userLogger.warn("Failed to precompute info hash for storage key:", err);
}
return "";
}
export function buildCorsGuidance({ accountId } = {}) {
const origin =
typeof window !== "undefined" && window.location
? window.location.origin
: "";
const originLabel = origin && origin !== "null" ? origin : "<your-app-origin>";
const endpoint = accountId
? `https://${accountId}.r2.cloudflarestorage.com`
: "https://<account>.r2.cloudflarestorage.com";
return [
"This is likely a CORS issue.",
`Configure CORS on the R2 S3 API endpoint (${endpoint}) — not just the public domain.`,
`Add AllowedOrigins: ${originLabel} (and any other origins),`,
"AllowedMethods: GET, HEAD, PUT, POST, DELETE, OPTIONS,",
"and AllowedHeaders: *.",
].join(" ");
}
export function createDefaultSettings() {
return {
accountId: "",
accessKeyId: "",
secretAccessKey: "",
baseDomain: "", // Now interpreted as the public URL base
buckets: {},
};
}
export function safeDecodeNpub(npub) {
if (typeof npub !== "string") {
return null;
}
const trimmed = npub.trim();
if (!trimmed) {
return null;
}
if (!trimmed.startsWith("npub1")) {
return null;
}
try {
if (
typeof window !== "undefined" &&
window.NostrTools &&
window.NostrTools.nip19
) {
const { type, data } = window.NostrTools.nip19.decode(trimmed);
if (type === "npub") {
return data;
}
}
} catch (err) {
// Ignore decode errors
}
return null;
}
// Uploads a tiny verification object, fetches it back over the public URL, and
// cleans up — proving the bucket exists, CORS is configured, and the public URL
// base is correct. Takes the R2Service instance (`ctx`) for its S3 operations.
export async function verifyPublicAccess(ctx, { settings, npub }) {
if (!settings || !npub) {
return { success: false, error: "Missing settings or npub." };
}
const { accountId, accessKeyId, secretAccessKey, baseDomain } = settings;
if (!accountId || !accessKeyId || !secretAccessKey || !baseDomain) {
return { success: false, error: "Incomplete credentials or missing public URL." };
}
const bucketName =
settings.bucket || settings.meta?.bucket || sanitizeBucketName(npub);
const verifyKey = `.verify-${Date.now()}-${Math.random().toString(36).substring(7)}.txt`;
const verifyContent = "bitvid-verification";
const publicUrl = buildPublicUrl(baseDomain, verifyKey);
userLogger.info(
`[R2] Verifying access for Bucket: '${bucketName}' in Account: '${truncateMiddle(accountId, 6)}'`
);
try {
// 1. Initialize S3
const s3 = ctx.makeR2Client({
accountId,
accessKeyId,
secretAccessKey,
endpoint: settings.endpoint,
region: settings.region,
});
// 2. Ensure bucket (best effort)
try {
await ctx.ensureBucketExists({
s3,
bucket: bucketName,
region: settings.region,
});
} catch (setupErr) {
userLogger.warn("Bucket creation/check warning during verification:", setupErr);
// Continue, assuming bucket might already exist and be configured
}
// 3. Ensure CORS (best effort)
try {
const corsOrigins = ctx.getCorsOrigins();
if (corsOrigins.length > 0) {
await ctx.ensureBucketCors({
s3,
bucket: bucketName,
origins: corsOrigins,
region: settings.region,
});
}
} catch (corsErr) {
userLogger.warn("CORS setup warning during verification:", corsErr);
}
// 4. Upload Test File
const file = new File([verifyContent], "verify.txt", { type: "text/plain" });
await ctx.multipartUpload({
s3,
bucket: bucketName,
key: verifyKey,
file,
contentType: "text/plain",
createBucketIfMissing: true,
region: settings.region,
});
// 4. Verify Public Access (Fetch)
// Wait a moment for propagation (R2 is usually instant-ish but helpful to wait)
await new Promise((r) => setTimeout(r, 500));
const response = await fetch(publicUrl, { method: "GET", cache: "no-cache" });
if (!response.ok) {
// Cleanup attempt
try { await ctx.deleteObject({ s3, bucket: bucketName, key: verifyKey }); } catch (e) {}
if (response.status === 404) {
return { success: false, error: "File not found. Check your Public Bucket URL." };
}
return { success: false, error: `Public access failed (HTTP ${response.status}). Is the bucket public?` };
}
const text = await response.text();
// Cleanup
try { await ctx.deleteObject({ s3, bucket: bucketName, key: verifyKey }); } catch (e) {}
if (text.trim() !== verifyContent) {
return { success: false, error: "Content mismatch. URL might be pointing elsewhere." };
}
return { success: true };
} catch (err) {
userLogger.error("Verification failed:", err);
let errorMessage = err.message || "Unknown error during verification.";
if (
errorMessage.includes("Failed to fetch") ||
errorMessage.includes("NetworkError")
) {
errorMessage += ` ${buildCorsGuidance({ accountId })} Also verify that the Bucket Name exists and your API Token has 'Object Read & Write' permissions.`;
}
return { success: false, error: errorMessage };
}
}
/**
* Build a provider-aware S3 client from resolved connection settings.
* Cloudflare R2 always uses path-style (via makeR2Client); a generic S3 bucket
* honors the connection's forcePathStyle. Shared by storage list/delete paths.
*/
export function buildProviderClient(settings, { makeR2Client, makeS3Client } = {}) {
const accessKeyId = (settings?.accessKeyId || "").trim();
const secretAccessKey = (settings?.secretAccessKey || "").trim();
const useR2 =
settings?.provider === "cloudflare_r2" || Boolean(settings?.accountId);
if (useR2) {
return makeR2Client({
accountId: settings.accountId,
endpoint: settings.endpoint,
accessKeyId,
secretAccessKey,
region: settings.region,
});
}
return makeS3Client({
endpoint: settings.endpoint,
region: settings.region,
accessKeyId,
secretAccessKey,
forcePathStyle: Boolean(settings?.forcePathStyle),
});
}
// Shared unlock-gated connection+client resolution for the storage-maintenance
// helpers. Returns { settings, s3, bucket } on success or { reason } on failure.
async function resolveStorageClient(ctx, { npub, pubkey, credentials }) {
let resolvedNpub = (npub || "").trim();
if (!resolvedNpub && pubkey) {
resolvedNpub = safeEncodeNpub(pubkey) || "";
}
let settings = credentials;
if (!settings && resolvedNpub) {
try {
settings = await ctx.resolveConnection(resolvedNpub);
} catch (err) {
devLogger.warn("[R2Service] storage client resolve failed:", err);
}
}
if (!settings) {
return { reason: "no-connection" };
}
if (
!(settings.accessKeyId || "").trim() ||
!(settings.secretAccessKey || "").trim()
) {
return { reason: "storage-locked" };
}
const bucket = (settings.bucket || "").trim();
if (!bucket) {
return { reason: "missing-bucket" };
}
try {
const ensureSdk =
typeof ctx.ensureS3SdkLoaded === "function"
? ctx.ensureS3SdkLoaded
: ensureS3SdkLoaded;
await ensureSdk();
const s3 = buildProviderClient(settings, {
makeR2Client: ctx.makeR2Client,
makeS3Client: ctx.makeS3Client,
});
return { settings, s3, bucket, resolvedNpub };
} catch (err) {
devLogger.warn("[R2Service] storage client build failed:", err);
return { reason: "client-error" };
}
}
/**
* Delete specific bucket object keys (orphan cleanup from the My Videos tab).
* Unlock-gated; returns { ok, deleted, failed, reason }.
*/
export async function deleteStorageKeys(ctx, { keys = [], npub = "", pubkey = "", credentials = null } = {}) {
const list = (Array.isArray(keys) ? keys : []).filter(
(k) => typeof k === "string" && k.trim(),
);
if (!list.length) {
return { ok: false, reason: "no-keys", deleted: [], failed: [] };
}
const resolved = await resolveStorageClient(ctx, { npub, pubkey, credentials });
if (resolved.reason) {
return { ok: false, reason: resolved.reason, deleted: [], failed: [] };
}
const { s3, bucket } = resolved;
const deleted = [];
const failed = [];
for (const key of list) {
try {
await ctx.deleteObject({ s3, bucket, key });
deleted.push(key);
} catch (err) {
failed.push({ key, error: err?.message || String(err) });
}
}
return { ok: true, deleted, failed, reason: "" };
}
/**
* List the object keys under a user's storage prefix (u/<npub>/). `ctx` is the
* R2Service instance (resolveConnection / makeR2Client / makeS3Client /
* listObjects). Unlock-gated; returns { ok, reason, keys }.
*/
export async function listVideoStorageObjects(
ctx,
{ npub = "", pubkey = "", credentials = null } = {},
) {
const resolved = await resolveStorageClient(ctx, { npub, pubkey, credentials });
if (resolved.reason) {
return { ok: false, reason: resolved.reason, keys: [] };
}
const { s3, bucket, resolvedNpub } = resolved;
try {
const keys = await ctx.listObjects({
s3,
bucket,
prefix: resolvedNpub ? `u/${resolvedNpub}/` : "",
});
return { ok: true, keys };
} catch (err) {
devLogger.warn("[R2Service] listVideoStorageObjects: list failed:", err);
return { ok: false, reason: "list-error", keys: [] };
}
}
+60 -7
View File
@@ -1,6 +1,6 @@
import { ensureS3SdkLoaded, makeS3Client } from "../storage/s3-client.js";
import { multipartUpload } from "../storage/s3-multipart.js";
import { buildR2Key } from "../r2.js";
import { buildR2Key, computeStorageContentHash } from "../r2.js";
import {
buildS3ObjectUrl,
getCorsOrigins,
@@ -18,6 +18,10 @@ import {
VIDEO_NOTE_ERROR_CODES,
} from "./videoNotePayload.js";
import { calculateTorrentInfoHash } from "../utils/torrentHash.js";
import {
isLikelyCorsError,
buildGenericCorsGuidance,
} from "../utils/uploadErrorHints.js";
const STATUS_VARIANTS = new Set(["info", "success", "error", "warning"]);
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
@@ -35,6 +39,7 @@ const defaultDeps = {
makeS3Client,
multipartUpload,
buildR2Key,
computeStorageContentHash,
buildS3ObjectUrl,
getCorsOrigins,
prepareS3Connection,
@@ -270,12 +275,31 @@ export class S3UploadService {
try {
await this.deps.ensureS3SdkLoaded();
// Computing the torrent info-hash (and the content-hash fallback) reads the
// ENTIRE file before the upload starts and before any progress is emitted —
// on multi-GB videos that's a long, silent pause that looks like a freeze.
const willHashFile =
Boolean(file) && !isValidInfoHash(normalizeInfoHash(infoHash));
if (willHashFile) {
this.setUploadStatus(
"Analyzing video (large files can take a moment)…",
"info",
);
}
const keyIdentifier = await this._resolveUploadIdentifier({
infoHash,
file,
});
const normalizedInfoHash = normalizeInfoHash(infoHash || keyIdentifier);
const hasValidInfoHash = isValidInfoHash(normalizedInfoHash);
// When no info-hash is available, derive a content-based namespace so two
// distinct URL-first uploads that share a filename can't overwrite each
// other. Kept separate from keyIdentifier so magnet generation still keys
// off the real info-hash only.
const storageIdentifier =
keyIdentifier ||
(file ? await this.deps.computeStorageContentHash(file) : "");
const s3 = this.deps.makeS3Client({
endpoint: normalized.endpoint,
@@ -287,7 +311,7 @@ export class S3UploadService {
const key =
forcedVideoKey ||
this.deps.buildR2Key(npub, file, keyIdentifier);
this.deps.buildR2Key(npub, file, storageIdentifier);
const publicUrl =
forcedVideoUrl ||
this.deps.buildS3ObjectUrl({
@@ -301,6 +325,11 @@ export class S3UploadService {
let statusMessage = `Uploading to ${normalized.bucket}…`;
this.setUploadStatus(statusMessage, "info");
// Track optional-asset failures so the user gets a visible signal instead
// of a silently-missing thumbnail/torrent (Cloudflare-upload #5).
let thumbnailFailed = false;
let torrentFailed = false;
if (thumbnailFile) {
this.setUploadStatus("Uploading thumbnail...", "info");
const thumbExt = thumbnailFile.name.split(".").pop() || "jpg";
@@ -330,6 +359,11 @@ export class S3UploadService {
"Thumbnail upload failed, continuing with video...",
err
);
thumbnailFailed = true;
this.setUploadStatus(
"Thumbnail upload failed — publishing without it.",
"warning"
);
}
}
@@ -384,6 +418,11 @@ export class S3UploadService {
"Torrent metadata upload failed, continuing...",
err
);
torrentFailed = true;
this.setUploadStatus(
"Torrent upload failed — publishing URL-first without the .torrent.",
"warning"
);
}
}
@@ -461,15 +500,29 @@ export class S3UploadService {
});
if (published) {
this.setUploadStatus(`Published ${publicUrl}`, "success");
const caveats = [];
if (thumbnailFailed) caveats.push("thumbnail");
if (torrentFailed) caveats.push("torrent");
const suffix = caveats.length
? ` (note: ${caveats.join(" & ")} upload failed)`
: "";
this.setUploadStatus(
`Published ${publicUrl}${suffix}`,
caveats.length ? "warning" : "success"
);
}
return Boolean(published);
} catch (err) {
this.deps.userLogger.error("S3 upload failed:", err);
this.setUploadStatus(
err?.message ? `Upload failed: ${err.message}` : "Upload failed.",
"error"
);
let message = err?.message
? `Upload failed: ${err.message}`
: "Upload failed.";
// A CORS rejection during the browser PUT/POST is an opaque "Failed to
// fetch" — attach actionable guidance (parity with the R2 path).
if (isLikelyCorsError(err)) {
message += ` ${buildGenericCorsGuidance({ endpoint: normalized?.endpoint })}`;
}
this.setUploadStatus(message, "error");
return false;
} finally {
this.setUploading(false);
+127
View File
@@ -0,0 +1,127 @@
// One-time "offer to pull on login" for encrypted settings sync (todo #15).
// On login, if an encrypted copy of the user's storage and/or wallet settings
// exists on their Nostr account — and this device hasn't already opted in — offer
// to restore it. Runs AFTER login (never blocks it) and respects the signer
// budget: the existence check is list-only (no decrypt); decryption happens only
// if the user accepts.
import { storageSyncService as defaultStorageSync } from "./storageSyncService.js";
const OFFERED_KEY = "bitvid:settings-sync:offered:v1";
function readOffered() {
try {
if (typeof localStorage === "undefined") {
return {};
}
const raw = localStorage.getItem(OFFERED_KEY);
const parsed = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === "object" ? parsed : {};
} catch (error) {
return {};
}
}
function markOffered(pubkey) {
try {
if (typeof localStorage === "undefined") {
return;
}
const map = readOffered();
map[pubkey] = true;
localStorage.setItem(OFFERED_KEY, JSON.stringify(map));
} catch (error) {
// Best-effort.
}
}
function hasOffered(pubkey) {
return readOffered()[pubkey] === true;
}
const ITEM_LABELS = {
storage: "storage settings",
wallet: "wallet connection",
};
export function createSettingsRestorePrompt({
storageSync = defaultStorageSync,
walletSync = null,
confirm = (message) =>
typeof window !== "undefined" && typeof window.confirm === "function"
? window.confirm(message)
: false,
logger = null,
} = {}) {
// Items not already enabled on THIS device that have a remote copy to restore.
async function collectCandidates(pubkey) {
const candidates = [];
const services = [
["storage", storageSync],
["wallet", walletSync],
];
for (const [kind, service] of services) {
if (!service?.isAvailable?.() || service.isEnabled(pubkey)) {
continue;
}
try {
if (await service.hasRemote()) {
candidates.push(kind);
}
} catch (error) {
logger?.warn?.(`[settingsRestore] ${kind} hasRemote check failed`, error);
}
}
return candidates;
}
async function maybeOffer(pubkey, { onRestored } = {}) {
const key = typeof pubkey === "string" ? pubkey.trim() : "";
if (!key) {
return { offered: false, reason: "no-pubkey" };
}
if (hasOffered(key)) {
return { offered: false, reason: "already-offered" };
}
const candidates = await collectCandidates(key);
if (!candidates.length) {
// Don't mark offered — a note may be published later from another device;
// the existence check is cheap (list-only) so re-checking next login is fine.
return { offered: false, reason: "no-remote" };
}
// We are about to prompt — mark so we never nag again on this device.
markOffered(key);
const list = candidates.map((kind) => ITEM_LABELS[kind]).join(" and ");
const accepted = confirm(
`Found an encrypted copy of your ${list} on your Nostr account. ` +
`Restore ${candidates.length > 1 ? "them" : "it"} to this device?`
);
if (!accepted) {
return { offered: true, accepted: false };
}
const restored = [];
const serviceByKind = { storage: storageSync, wallet: walletSync };
for (const kind of candidates) {
try {
const result = await serviceByKind[kind].pull(key);
if (result?.imported) {
restored.push(kind);
}
} catch (error) {
logger?.warn?.(`[settingsRestore] ${kind} pull failed`, error);
}
}
if (restored.length && typeof onRestored === "function") {
onRestored(restored);
}
return { offered: true, accepted: true, restored };
}
return { maybeOffer };
}
export default createSettingsRestorePrompt;
+82
View File
@@ -0,0 +1,82 @@
// Per-pubkey opt-in flags for encrypted settings sync (todo #15). Shared by the
// storage and wallet sync services so the toggle state persists locally.
// Shape: { [pubkey]: { storage?: boolean, wallet?: boolean } }. Off by default.
const FLAG_STORAGE_KEY = "bitvid:settings-sync:v1";
function readFlags() {
try {
if (typeof localStorage === "undefined") {
return {};
}
const raw = localStorage.getItem(FLAG_STORAGE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed : {};
} catch (error) {
return {};
}
}
function writeFlags(flags) {
try {
if (typeof localStorage === "undefined") {
return;
}
localStorage.setItem(FLAG_STORAGE_KEY, JSON.stringify(flags || {}));
} catch (error) {
// Best-effort; the flag is a convenience, not a source of truth.
}
}
function normalizePubkey(pubkey) {
return typeof pubkey === "string" ? pubkey.trim() : "";
}
export function isSyncEnabled(pubkey, kind) {
const key = normalizePubkey(pubkey);
if (!key || !kind) {
return false;
}
return readFlags()[key]?.[kind] === true;
}
export function setSyncEnabled(pubkey, kind, enabled) {
const key = normalizePubkey(pubkey);
if (!key || !kind) {
return;
}
const flags = readFlags();
const entry = flags[key] && typeof flags[key] === "object" ? flags[key] : {};
entry[kind] = enabled === true;
flags[key] = entry;
writeFlags(flags);
}
// Remember the created_at of the note we last published, per pubkey+kind, so a
// later save can detect when the relay copy is NEWER (another device changed it)
// and warn before overwriting.
export function getSyncPushedAt(pubkey, kind) {
const key = normalizePubkey(pubkey);
if (!key || !kind) {
return 0;
}
const value = Number(readFlags()[key]?.[`${kind}PushedAt`]);
return Number.isFinite(value) ? value : 0;
}
export function setSyncPushedAt(pubkey, kind, createdAt) {
const key = normalizePubkey(pubkey);
if (!key || !kind) {
return;
}
const flags = readFlags();
const entry = flags[key] && typeof flags[key] === "object" ? flags[key] : {};
entry[`${kind}PushedAt`] = Number(createdAt) || 0;
flags[key] = entry;
writeFlags(flags);
}
export const SETTINGS_SYNC_FLAG_KEY = FLAG_STORAGE_KEY;
+36
View File
@@ -611,6 +611,42 @@ export class StorageService {
}
}
/**
* Exports the raw on-disk account record for a pubkey, for opt-in encrypted
* cross-login sync (todo #15). The record is already self-protected
* (encryptedMasterKey envelope + per-connection AES-GCM payloads) but its
* `meta` is plaintext (bucket/endpoint names), so callers MUST re-encrypt the
* whole record to self before publishing. Returns null when nothing is stored.
*/
async exportAccountRecord(pubkey) {
if (!pubkey) {
return null;
}
const account = await this._getAccount(pubkey);
return account || null;
}
/**
* Imports an account record previously exported via {@link exportAccountRecord}
* (e.g. pulled from the user's encrypted Nostr note on a second device). The
* record is encrypted-to-self by the SAME pubkey, so its envelope unlocks with
* the same signer. This REPLACES the local record for the pubkey; the next
* unlock decrypts the imported master key. (Merge across devices is a follow-up.)
*/
async importAccountRecord(pubkey, record) {
if (!pubkey) {
throw new Error("Pubkey required to import storage account record.");
}
if (!record || typeof record !== "object" || !record.encryptedMasterKey) {
throw new Error("Invalid storage account record.");
}
const toSave = { ...record, pubkey };
await this._saveAccount(toSave);
// Drop any in-memory master key so the next unlock uses the imported envelope.
this.lock(pubkey);
return toSave;
}
/**
* Sets a connection as default for uploads (updates metadata).
*/
+121
View File
@@ -0,0 +1,121 @@
// Opt-in encrypted cross-login sync for STORAGE credentials (todo #15, step 2).
// Wraps the encrypted-sync core: the on-disk storage account record (already
// self-protected, but with plaintext meta) is re-encrypted to self and published
// as a NIP-78 replaceable note under a stable d-tag, then pulled + imported on
// another device.
//
// Opt-in only, off by default. A per-pubkey local flag remembers the choice so
// the UI toggle persists and a future "re-push on change" knows whether to sync.
import { encryptedSync as defaultEncryptedSync } from "../nostr/encryptedSyncFacade.js";
import defaultStorageService from "./storageService.js";
import { isSyncEnabled, setSyncEnabled } from "./settingsSyncFlags.js";
import { pushWithConflictCheck } from "./syncConflict.js";
import { userLogger } from "../utils/logger.js";
export const STORAGE_SYNC_DTAG = "bitvid:storage-connections";
const SYNC_KIND = "storage";
export function createStorageSyncService({
encryptedSync = defaultEncryptedSync,
storage = defaultStorageService,
} = {}) {
function normalizePubkey(pubkey) {
return typeof pubkey === "string" ? pubkey.trim() : "";
}
function isAvailable() {
return Boolean(encryptedSync?.isAvailable?.());
}
function isEnabled(pubkey) {
return isSyncEnabled(normalizePubkey(pubkey), SYNC_KIND);
}
function setEnabledFlag(pubkey, enabled) {
setSyncEnabled(normalizePubkey(pubkey), SYNC_KIND, enabled);
}
// Cheap check (no decrypt) for whether a synced note exists — for the
// login-restore prompt.
async function hasRemote() {
if (typeof encryptedSync.exists !== "function") {
return false;
}
const result = await encryptedSync.exists(STORAGE_SYNC_DTAG);
return Boolean(result?.exists);
}
// Push the current local storage account record to the user's encrypted note.
async function push(pubkey, { confirmOverwrite } = {}) {
const key = normalizePubkey(pubkey);
if (!key) {
return { ok: false, error: "missing-pubkey" };
}
let record;
try {
record = await storage.exportAccountRecord(key);
} catch (error) {
userLogger.warn("[storageSync] Failed to export storage record:", error);
return { ok: false, error: "export-failed", cause: error };
}
if (!record) {
return { ok: false, error: "nothing-to-sync" };
}
return pushWithConflictCheck({
encryptedSync,
dTag: STORAGE_SYNC_DTAG,
kind: SYNC_KIND,
pubkey: key,
payload: record,
confirmOverwrite,
});
}
// Pull the encrypted note and import it into local storage. Returns the
// encrypted-sync pull result with `imported: true` when a record was applied.
async function pull(pubkey) {
const key = normalizePubkey(pubkey);
if (!key) {
return { found: false, error: "missing-pubkey" };
}
const result = await encryptedSync.pull(STORAGE_SYNC_DTAG);
if (!result?.found) {
return result;
}
try {
await storage.importAccountRecord(key, result.payload);
} catch (error) {
userLogger.warn("[storageSync] Failed to import storage record:", error);
return { ...result, imported: false, error: "import-failed", cause: error };
}
return { ...result, imported: true };
}
// Turn sync on: remember the choice and push immediately.
async function enable(pubkey, options = {}) {
setEnabledFlag(pubkey, true);
return push(pubkey, options);
}
// Turn sync off: remember the choice and wipe the published note.
async function disable(pubkey) {
setEnabledFlag(pubkey, false);
return encryptedSync.clear(STORAGE_SYNC_DTAG);
}
return {
STORAGE_SYNC_DTAG,
isAvailable,
isEnabled,
hasRemote,
push,
pull,
enable,
disable,
};
}
export const storageSyncService = createStorageSyncService();
export default storageSyncService;
Loaded 100 of 5116 files, more files were not shown because too many files have changed in this diff. Show more