mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Promote unstable → beta: encrypted settings sync, watch-history/zap/login fixes, My Videos
This commit is contained in:
commit
f5ad166e2c
5116 files changed
+14031
-520907
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 +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 +0,0 @@
|
||||
MEMORY_STORED
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -521,5 +521,4 @@ export const FEED_TYPES = Object.freeze({
|
||||
CHANNEL: "channel-profile",
|
||||
DOCS: "docs",
|
||||
SEARCH: "search",
|
||||
DEV_DASHBOARD: "dev/agent-dashboard",
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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;
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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
Reference in new issue
Block a user