mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge unstable into main
Brings 14 commits to the production branch, including the BitLogin widget re-vendored at 205b067 with recorded provenance. main and unstable had diverged: both independently committed a "security- audit build" widget update (0592c0eehere,847a855ethere), so the merge conflicted on six vendor/bitlogin files, including a rename/rename on the shared chunk -- Vite content-hashes those filenames, so two builds of the same source produce different names. Every conflict resolved by taking unstable's side wholesale. Vendored files are build output, not authored source; a hand-merged bundle would correspond to no build and no commit. The result was then verified against a clean rebuild of BitLogin at 205b067 rather than trusted: vendor/bitlogin is byte-identical to packages/widget/dist from a from-scratch build, the file set matches origin/unstable exactly, and the manifest reads provenance=attributed at 205b0678cc67. main's widget was the OLDER build (0d118d59), predating BitLogin's second-audit hardening, so this moves production forward rather than back. Existing sign-ins are unaffected. normalize.ts, argon2id.ts, hkdf.ts and scalarExpand.ts are byte-identical across this range, so login name plus password derive the same locator, signing scalar and capsule key. Accounts created with a hand-typed password still work -- the manual-password removal took away the creation, recovery and rotation controls, not the acceptance. What changes: creating, recovering or rotating now issues a generated six-word passphrase. Full suite green on the merged tree: 2354 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
This commit is contained in:
commit
9f20907f35
25 files changed
+2262
-1724
No files matched your search
@@ -0,0 +1,95 @@
|
|||||||
|
# Regenerate the visual-regression baselines IN the environment that compares
|
||||||
|
# them.
|
||||||
|
#
|
||||||
|
# The baselines in tests/visual/baselines.json are pixel-compared on
|
||||||
|
# ubuntu-latest CI, but they were historically captured on a developer machine.
|
||||||
|
# Chromium builds and font fallbacks differ between the two ("Inter"/"Fira
|
||||||
|
# Code" are not shipped or installed anywhere, so every environment renders its
|
||||||
|
# own system fallback), so the kitchen-sink diff drifted past MAX_DIFF_RATIO on
|
||||||
|
# CI while passing locally — ci.yml even documents the mismatch as the reason
|
||||||
|
# visual-regression must not block deploys. Capturing the baselines on the same
|
||||||
|
# runner image, browser install and font stack as the comparison removes the
|
||||||
|
# environment variable from the equation entirely.
|
||||||
|
#
|
||||||
|
# Two explicit triggers, both requiring deliberate human intent — regenerating
|
||||||
|
# baselines must never be a side effect of ordinary work:
|
||||||
|
#
|
||||||
|
# 1. workflow_dispatch — only usable once this file reaches the default
|
||||||
|
# branch (GitHub does not index dispatchable workflows on other branches).
|
||||||
|
# 2. A push whose head commit message contains [update-visual-baselines] —
|
||||||
|
# works from unstable today, before any promotion. The workflow's own
|
||||||
|
# commit omits the token, so it cannot retrigger itself.
|
||||||
|
name: Update visual baselines
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [unstable]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-baselines:
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
contains(github.event.head_commit.message, '[update-visual-baselines]')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Node.js 22
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
# Mirror the visual-regression job's caches so the regenerated baselines
|
||||||
|
# come from the same dependency tree the comparison job will use.
|
||||||
|
- name: Cache npm directory
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: ${{ runner.os }}-node-22-npm-${{ hashFiles('package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-22-npm-
|
||||||
|
|
||||||
|
- name: Cache node_modules
|
||||||
|
id: node-modules-cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-node-22-modules-${{ hashFiles('package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-22-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: Regenerate baselines
|
||||||
|
run: npm run test:visual:update
|
||||||
|
|
||||||
|
- name: Verify the fresh baselines pass in this environment
|
||||||
|
run: npx playwright test --project=visual
|
||||||
|
|
||||||
|
- name: Commit updated baselines
|
||||||
|
run: |
|
||||||
|
if git diff --quiet -- tests/visual/baselines.json; then
|
||||||
|
echo "Baselines unchanged; nothing to commit."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add tests/visual/baselines.json
|
||||||
|
git commit \
|
||||||
|
-m "test(visual): regenerate baselines on ubuntu-latest" \
|
||||||
|
-m "Captured by the update-visual-baselines workflow so the baseline environment matches the visual-regression job that compares against them."
|
||||||
|
git push
|
||||||
+26
-14
@@ -3,11 +3,10 @@
|
|||||||
## Test Infrastructure
|
## Test Infrastructure
|
||||||
|
|
||||||
### Missing `jsdom` Dependency
|
### Missing `jsdom` Dependency
|
||||||
- **Status:** Active
|
- **Status:** Resolved (2026-07-30)
|
||||||
- **Detected:** 2026-02-26
|
- **Detected:** 2026-02-26
|
||||||
- **Description:** Multiple unit tests fail with `ERR_MODULE_NOT_FOUND` because `jsdom` is imported but not installed in the project.
|
- **Description:** Unit tests failed with `ERR_MODULE_NOT_FOUND` because `jsdom` was imported but not installed.
|
||||||
- **Impact:** Prevents running unit tests that rely on DOM simulation.
|
- **Resolution:** `jsdom` has been a devDependency for some time (28.1.0 installed and in `package.json`); the jsdom-based suites run clean.
|
||||||
- **Remediation:** `npm install --save-dev jsdom`
|
|
||||||
|
|
||||||
## Run Notes (2026-03-04)
|
## Run Notes (2026-03-04)
|
||||||
|
|
||||||
@@ -22,19 +21,32 @@
|
|||||||
- **Description:** `tests/nostr-send-direct-message.test.mjs` must pass `Uint8Array` secret keys to `getPublicKey/finalizeEvent` for current `nostr-tools`.
|
- **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`.
|
- **Impact:** Passing hex strings causes failures like `expected Uint8Array, got type=string`.
|
||||||
|
|
||||||
## Run Notes (2026-06-20)
|
## Run Notes (2026-06-20, updated 2026-07-30)
|
||||||
|
|
||||||
### Additional E2E specs flake under full parallel load (pass in isolation)
|
### `login-flows.spec.ts:149` — upload/profile buttons stay visible after logout
|
||||||
|
- **Status:** Active — NOT merely a parallel-load flake
|
||||||
|
- **Description:** Previously filed as a worker-contention flake that "passes deterministically in isolation." On 2026-07-28 it failed **in isolation** as well (chromium, local and CI `e2e-headless`), while passing in the CI `e2e-tests (e2e)` job — environment-sensitive, not load-sensitive. Direct probes of the same flow (raw `__bitvidTest__.loginWithNsec` → `logout()` against a mock relay) hide the button correctly, so the failure is specific to something in the Playwright fixture path. An attempted fix (running `applyLoggedOutUiState()` before the awaited NWC/profile teardown in `_executeAuthLogout`) did not change the outcome and was reverted rather than shipped unverified.
|
||||||
|
- **Impact:** One e2e failure in `e2e-headless`; logout hides gated UI correctly in manual testing and direct harness probes.
|
||||||
|
- **Next step:** Investigate alongside pre-launch TODO #33 ("logout logs out everyone" / NIP-46 session persistence) — same subsystem.
|
||||||
|
|
||||||
|
### `video-upload-publish-lifecycle.spec.ts:49` / `:79` flake under full parallel load
|
||||||
- **Status:** Active
|
- **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).
|
- **Description:** Under the full `test:e2e` run these intermittently fail on feed-appearance timeouts but pass on retry (CI marks them flaky). Worker resource contention.
|
||||||
- **Impact:** Full-suite e2e is flaky; isolated reruns are green.
|
- **Impact:** Full-suite e2e occasionally needs retries; isolated reruns are green.
|
||||||
|
|
||||||
### `webseed-playback.spec.ts:31` fails on Firefox headless (CDN mode)
|
### `webseed-playback.spec.ts:31` fails on Firefox headless (CDN mode)
|
||||||
- **Status:** Active (pre-existing — reproduced on pre-refactor commit `1b11cb1b`)
|
- **Status:** Resolved (2026-07-28, `f48150ed`)
|
||||||
- **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.
|
- **Description:** The test asserted the **final** `src` equals the CDN URL while simultaneously tolerating "No playable source found" in its status assertion — contradictory, because a failed hosted attempt calls `resetVideoElement()`, which clears `src`. Headless CI cannot decode the archive.org H.264 sample, so the cleared-src path was routine there.
|
||||||
- **Impact:** One Firefox-only e2e failure; CDN playback works on Chromium and in manual testing.
|
- **Resolution:** The test now records every `src` the player attaches via a MutationObserver and asserts the CDN URL was attached — the actual behavior under test. Green in both `e2e` and `e2e-firefox` CI jobs.
|
||||||
|
|
||||||
### `uploadModal-reset.test.mjs` ("UploadModal Reset Logic") hangs/cancels
|
### `uploadModal-reset.test.mjs` ("UploadModal Reset Logic") hangs/cancels
|
||||||
- **Status:** Active (pre-existing — reproduced on pre-refactor commit `1b11cb1b`)
|
- **Status:** Not reproducible (2026-07-30)
|
||||||
- **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`.
|
- **Description:** Intermittent `cancelledByParent` / "Promise resolution is still pending" hang, order/timing dependent.
|
||||||
- **Impact:** One unit suite can report `not ok 1`; the runner still exits 0. Other tests unaffected.
|
- **Current state:** Passes cleanly (`pass=3 fail=0 cancelled=0`) in isolation and across repeated full-suite sweeps on 2026-07-28–30. Left on file because the original failure was ordering-dependent; if it recurs, capture the full-suite ordering that produced it.
|
||||||
|
|
||||||
|
## Run Notes (2026-07-30)
|
||||||
|
|
||||||
|
### Visual baselines are CI-canonical — local `test:visual` may fail the kitchen-sink diff
|
||||||
|
- **Status:** By design (since `ab120a03`)
|
||||||
|
- **Description:** `tests/visual/baselines.json` is regenerated on ubuntu-latest by the `update-visual-baselines` workflow (trigger: `workflow_dispatch` once promoted, or a commit containing `[update-visual-baselines]`). Baselines can only match one environment, and the CI gate is the one that matters — so a dev machine with different Chromium/font rendering may now fail `npm run test:visual` locally (typically the kitchen-sink default theme) even though CI is green.
|
||||||
|
- **Workaround:** Run visual tests in the pinned container via `scripts/run-playwright-docker.sh`, or rely on CI. Never regenerate baselines from a dev machine.
|
||||||
+1
-43
@@ -9,7 +9,6 @@
|
|||||||
* Methods use `this` which is bound to the Application instance.
|
* Methods use `this` which is bound to the Application instance.
|
||||||
*/
|
*/
|
||||||
import { SHORT_TIMEOUT_MS, DEBOUNCE_DELAY_MS } from "../constants.js";
|
import { SHORT_TIMEOUT_MS, DEBOUNCE_DELAY_MS } from "../constants.js";
|
||||||
import { getActiveSigner } from "../nostrClientRegistry.js";
|
|
||||||
|
|
||||||
export function createUiCoordinator(deps) {
|
export function createUiCoordinator(deps) {
|
||||||
const {
|
const {
|
||||||
@@ -188,52 +187,11 @@ export function createUiCoordinator(deps) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
syncAuthUiState() {
|
syncAuthUiState() {
|
||||||
// Auth-dependent chrome has to follow login/logout/account switches, but
|
if (this.isUserLoggedIn()) {
|
||||||
// syncAuthUiState() was only ever called at bootstrap — so everything it
|
|
||||||
// gates (notably the share menu's "Share on Nostr" item) kept its
|
|
||||||
// logged-out state for the entire session. Self-install a listener on the
|
|
||||||
// first run rather than adding a call site in js/app.js, which is at its
|
|
||||||
// file-size cap.
|
|
||||||
if (
|
|
||||||
!this.__authUiSyncBound &&
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
typeof window?.addEventListener === "function"
|
|
||||||
) {
|
|
||||||
this.__authUiSyncBound = true;
|
|
||||||
window.addEventListener("bitvid:auth-changed", () => {
|
|
||||||
try {
|
|
||||||
this.syncAuthUiState();
|
|
||||||
} catch (error) {
|
|
||||||
devLogger.warn("[Application] Auth UI resync failed:", error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const loggedIn = this.isUserLoggedIn();
|
|
||||||
if (loggedIn) {
|
|
||||||
this.applyAuthenticatedUiState();
|
this.applyAuthenticatedUiState();
|
||||||
} else {
|
} else {
|
||||||
this.applyLoggedOutUiState();
|
this.applyLoggedOutUiState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gate the share menu's "Share on Nostr" item. Without this the modal's
|
|
||||||
// shareNostrAuthState stays at its initial `false` forever and the item
|
|
||||||
// renders permanently disabled — indistinguishable from plain text.
|
|
||||||
if (typeof this.videoModal?.setShareNostrAuthState === "function") {
|
|
||||||
let signer = null;
|
|
||||||
try {
|
|
||||||
signer = getActiveSigner();
|
|
||||||
} catch (error) {
|
|
||||||
devLogger.warn("[Application] Failed to read active signer:", error);
|
|
||||||
}
|
|
||||||
this.videoModal.setShareNostrAuthState({
|
|
||||||
isLoggedIn: loggedIn,
|
|
||||||
// Mirrors ShareNostrController.handleShare(): a signer that cannot
|
|
||||||
// sign is no signer at all, and enabling the item would only produce
|
|
||||||
// an error toast on click.
|
|
||||||
hasSigner: !!signer && typeof signer.signEvent === "function",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshChromeElements() {
|
refreshChromeElements() {
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
[build]
|
|
||||||
command = "npm run build && npm run verify:dist:deploy-artifact"
|
|
||||||
publish = "dist"
|
|
||||||
|
|
||||||
[build.environment]
|
|
||||||
NODE_ENV = "production"
|
|
||||||
|
|
||||||
[[headers]]
|
|
||||||
for = "/*"
|
|
||||||
[headers.values]
|
|
||||||
Content-Security-Policy = "frame-ancestors *"
|
|
||||||
|
|
||||||
[[headers]]
|
|
||||||
for = "/index.html"
|
|
||||||
[headers.values]
|
|
||||||
Cache-Control = "no-cache, must-revalidate"
|
|
||||||
|
|
||||||
[[headers]]
|
|
||||||
for = "/embed.html"
|
|
||||||
[headers.values]
|
|
||||||
Cache-Control = "no-cache, must-revalidate"
|
|
||||||
|
|
||||||
[[headers]]
|
|
||||||
for = "/*.html"
|
|
||||||
[headers.values]
|
|
||||||
Cache-Control = "no-cache, must-revalidate"
|
|
||||||
|
|
||||||
[[headers]]
|
|
||||||
for = "/css/*"
|
|
||||||
[headers.values]
|
|
||||||
Cache-Control = "public, max-age=31536000, immutable"
|
|
||||||
|
|
||||||
[[headers]]
|
|
||||||
for = "/js/*"
|
|
||||||
[headers.values]
|
|
||||||
Cache-Control = "public, max-age=31536000, immutable"
|
|
||||||
|
|
||||||
[[headers]]
|
|
||||||
for = "/sw.min.js"
|
|
||||||
[headers.values]
|
|
||||||
Cache-Control = "public, max-age=0, must-revalidate"
|
|
||||||
|
|
||||||
[[headers]]
|
|
||||||
for = "/site.webmanifest"
|
|
||||||
[headers.values]
|
|
||||||
Cache-Control = "public, max-age=0, must-revalidate"
|
|
||||||
@@ -33,6 +33,43 @@ if (packageJson.name !== "@bitlogin/widget") {
|
|||||||
throw new Error("BITLOGIN_ENTRY must point to a BitLogin checkout (packages/widget/package.json not found there).");
|
throw new Error("BITLOGIN_ENTRY must point to a BitLogin checkout (packages/widget/package.json not found there).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provenance (BitUnlock entropy audit M1, ported). Hashes prove the vendored
|
||||||
|
// bytes have not changed since they were committed; they cannot say WHICH
|
||||||
|
// BitLogin commit produced them. @bitlogin/widget is a private package pinned
|
||||||
|
// at 0.1.0 across many substantive changes, so the version string distinguishes
|
||||||
|
// nothing. Resolved BEFORE the dist build and before vendorDir is deleted, so
|
||||||
|
// refusing a dirty checkout leaves the committed vendored files untouched.
|
||||||
|
const git = (args) => execSync(`git ${args}`, { cwd: bitloginRoot, encoding: "utf8" }).trim();
|
||||||
|
|
||||||
|
let source;
|
||||||
|
try {
|
||||||
|
const dirty = git("status --porcelain") !== "";
|
||||||
|
if (dirty && process.env.BITLOGIN_ALLOW_DIRTY !== "1") {
|
||||||
|
throw new Error(
|
||||||
|
"DIRTY_CHECKOUT: the BitLogin checkout has uncommitted changes, so the artifact this "
|
||||||
|
+ "would produce corresponds to no commit and cannot be reproduced. Commit there first, "
|
||||||
|
+ "or set BITLOGIN_ALLOW_DIRTY=1 for a throwaway local build.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
source = {
|
||||||
|
repository: git("remote get-url origin"),
|
||||||
|
commit: git("rev-parse HEAD"),
|
||||||
|
commitDate: git("log -1 --format=%cI"),
|
||||||
|
dirty,
|
||||||
|
provenance: dirty ? "dirty" : "attributed",
|
||||||
|
toolchain: { node: process.version, vite: packageJson.devDependencies?.vite },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.startsWith("DIRTY_CHECKOUT:")) {
|
||||||
|
throw new Error(error.message.replace("DIRTY_CHECKOUT: ", ""));
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Could not read BitLogin git provenance from ${bitloginRoot}: ${error.message}. `
|
||||||
|
+ "Vendoring from a non-git source leaves the artifact unattributable; refusing.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`[vendor-bitlogin] vendoring @bitlogin/widget from ${source.commit.slice(0, 12)}`);
|
||||||
|
|
||||||
if (!existsSync(widgetDist)) {
|
if (!existsSync(widgetDist)) {
|
||||||
console.log("[build-bitlogin-widget] dist missing, building @bitlogin/core then @bitlogin/widget first...");
|
console.log("[build-bitlogin-widget] dist missing, building @bitlogin/core then @bitlogin/widget first...");
|
||||||
execSync("npm run build -w @bitlogin/core", { cwd: bitloginRoot, stdio: "inherit" });
|
execSync("npm run build -w @bitlogin/core", { cwd: bitloginRoot, stdio: "inherit" });
|
||||||
@@ -56,7 +93,7 @@ for (const name of distEntries) {
|
|||||||
|
|
||||||
await writeFile(
|
await writeFile(
|
||||||
integrityFile,
|
integrityFile,
|
||||||
`${JSON.stringify({ package: packageJson.name, version: packageJson.version, files }, null, 2)}\n`,
|
`${JSON.stringify({ package: packageJson.name, version: packageJson.version, source, files }, null, 2)}\n`,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -13,12 +13,20 @@ test.describe("runtime utility module coverage", () => {
|
|||||||
"/js/search/searchFilters.js"
|
"/js/search/searchFilters.js"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 9afbc0cb ("Decode npub authors to hex") made the author filter validate
|
||||||
|
// its value, because npub-form authors silently matched nothing. The old
|
||||||
|
// fixture used `author:abc123`, which is neither a decodable npub nor
|
||||||
|
// 64-char hex, so the filter is now (correctly) rejected and
|
||||||
|
// authorPubkeys comes back empty. Use a real pubkey to exercise the
|
||||||
|
// success path, and pin the rejection separately below.
|
||||||
|
const AUTHOR_HEX = "a".repeat(64);
|
||||||
|
|
||||||
const parsed = parseFilterQuery(
|
const parsed = parseFilterQuery(
|
||||||
'author:abc123 tag:#music,nostr kind:30078 relay:wss://relay.example after:2024-01-02 before:1707000000 duration:>=1.5m has:magnet nsfw:safe "exact phrase" looseTerm',
|
`author:${AUTHOR_HEX} tag:#music,nostr kind:30078 relay:wss://relay.example after:2024-01-02 before:1707000000 duration:>=1.5m has:magnet nsfw:safe "exact phrase" looseTerm`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const withErrors = parseFilterQuery(
|
const withErrors = parseFilterQuery(
|
||||||
"duration:oops has:bad unknown:value before:not-a-date",
|
"duration:oops has:bad unknown:value before:not-a-date author:abc123",
|
||||||
);
|
);
|
||||||
|
|
||||||
const serialized = serializeFiltersToQuery(parsed.filters);
|
const serialized = serializeFiltersToQuery(parsed.filters);
|
||||||
@@ -30,7 +38,7 @@ test.describe("runtime utility module coverage", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.parsed.filters.authorPubkeys).toEqual(["abc123"]);
|
expect(result.parsed.filters.authorPubkeys).toEqual(["a".repeat(64)]);
|
||||||
expect(result.parsed.filters.tags).toEqual(["music", "nostr"]);
|
expect(result.parsed.filters.tags).toEqual(["music", "nostr"]);
|
||||||
expect(result.parsed.filters.kind).toBe(30078);
|
expect(result.parsed.filters.kind).toBe(30078);
|
||||||
expect(result.parsed.filters.relay).toBe("wss://relay.example");
|
expect(result.parsed.filters.relay).toBe("wss://relay.example");
|
||||||
@@ -44,8 +52,13 @@ test.describe("runtime utility module coverage", () => {
|
|||||||
expect(result.withErrors.errors.some((e: any) => /Duration/.test(e.message))).toBe(true);
|
expect(result.withErrors.errors.some((e: any) => /Duration/.test(e.message))).toBe(true);
|
||||||
expect(result.withErrors.errors.some((e: any) => /Has filter/.test(e.message))).toBe(true);
|
expect(result.withErrors.errors.some((e: any) => /Has filter/.test(e.message))).toBe(true);
|
||||||
expect(result.withErrors.errors.some((e: any) => /Date value/.test(e.message))).toBe(true);
|
expect(result.withErrors.errors.some((e: any) => /Date value/.test(e.message))).toBe(true);
|
||||||
|
// An undecodable author must be reported rather than silently dropped.
|
||||||
|
expect(
|
||||||
|
result.withErrors.errors.some((e: any) => /Could not decode author/.test(e.message)),
|
||||||
|
).toBe(true);
|
||||||
|
expect(result.withErrors.filters.authorPubkeys).toEqual([]);
|
||||||
|
|
||||||
expect(result.serialized).toContain("author:abc123");
|
expect(result.serialized).toContain(`author:${"a".repeat(64)}`);
|
||||||
expect(result.serialized).toContain("tag:music");
|
expect(result.serialized).toContain("tag:music");
|
||||||
expect(result.serialized).toContain("kind:30078");
|
expect(result.serialized).toContain("kind:30078");
|
||||||
expect(result.serialized).toContain("has:magnet");
|
expect(result.serialized).toContain("has:magnet");
|
||||||
|
|||||||
@@ -491,7 +491,21 @@ test.describe("nostr runtime coverage (deterministic)", () => {
|
|||||||
createdAt: 42,
|
createdAt: 42,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// A legacy plaintext entry must be PURGED, not honored. readV1Entry()
|
||||||
|
// removes it from storage; readStoredSessionActorEntry() then falls back
|
||||||
|
// to the sole remaining (encrypted) account, so the old `=== null`
|
||||||
|
// assertion no longer describes the security property -- it just happened
|
||||||
|
// to hold before 8692dac6 added per-account storage. Assert the property
|
||||||
|
// directly instead: no plaintext key survives, in the returned entry or
|
||||||
|
// in raw storage.
|
||||||
const migratedLegacyEntry = readStoredSessionActorEntry();
|
const migratedLegacyEntry = readStoredSessionActorEntry();
|
||||||
|
const legacyRaw = localStorage.getItem(SESSION_ACTOR_STORAGE_KEY);
|
||||||
|
let legacyRawPlaintextSurvives = false;
|
||||||
|
try {
|
||||||
|
legacyRawPlaintextSurvives = Boolean(legacyRaw && JSON.parse(legacyRaw)?.privateKey);
|
||||||
|
} catch (error) {
|
||||||
|
legacyRawPlaintextSurvives = false;
|
||||||
|
}
|
||||||
|
|
||||||
const actorMatch = isSessionActor({
|
const actorMatch = isSessionActor({
|
||||||
pubkey: actor,
|
pubkey: actor,
|
||||||
@@ -524,7 +538,10 @@ test.describe("nostr runtime coverage (deterministic)", () => {
|
|||||||
wrongPassphraseErrorCode,
|
wrongPassphraseErrorCode,
|
||||||
metadataValid: metadata && metadata.salt === encrypted.salt,
|
metadataValid: metadata && metadata.salt === encrypted.salt,
|
||||||
metadataInvalidIsNull: invalidMetadata === null,
|
metadataInvalidIsNull: invalidMetadata === null,
|
||||||
migratedLegacyEntryIsNull: migratedLegacyEntry === null,
|
migratedLegacyEntryHasPlaintextKey:
|
||||||
|
typeof (migratedLegacyEntry as any)?.privateKey === "string" &&
|
||||||
|
(migratedLegacyEntry as any).privateKey.length > 0,
|
||||||
|
legacyRawPlaintextSurvives,
|
||||||
actorMatch,
|
actorMatch,
|
||||||
actorMismatch,
|
actorMismatch,
|
||||||
nsecActor,
|
nsecActor,
|
||||||
@@ -541,7 +558,8 @@ test.describe("nostr runtime coverage (deterministic)", () => {
|
|||||||
expect(result.wrongPassphraseErrorCode).toBe("decrypt-failed");
|
expect(result.wrongPassphraseErrorCode).toBe("decrypt-failed");
|
||||||
expect(result.metadataValid).toBeTruthy();
|
expect(result.metadataValid).toBeTruthy();
|
||||||
expect(result.metadataInvalidIsNull).toBe(true);
|
expect(result.metadataInvalidIsNull).toBe(true);
|
||||||
expect(result.migratedLegacyEntryIsNull).toBe(true);
|
expect(result.migratedLegacyEntryHasPlaintextKey).toBe(false);
|
||||||
|
expect(result.legacyRawPlaintextSurvives).toBe(false);
|
||||||
expect(result.actorMatch).toBe(true);
|
expect(result.actorMatch).toBe(true);
|
||||||
expect(result.actorMismatch).toBe(false);
|
expect(result.actorMismatch).toBe(false);
|
||||||
expect(result.nsecActor).toBe(false);
|
expect(result.nsecActor).toBe(false);
|
||||||
|
|||||||
@@ -211,8 +211,16 @@ test.describe("component modal pages", () => {
|
|||||||
waitUntil: "networkidle",
|
waitUntil: "networkidle",
|
||||||
});
|
});
|
||||||
|
|
||||||
const modal = page.locator(".bv-modal");
|
// A component page may legitimately ship more than one modal root:
|
||||||
await expect(modal).toHaveCount(1);
|
// profile-modal.html also bundles #storageCorsModal, the B2/S3 CORS setup
|
||||||
|
// helper added in d7ad1516. Asserting exactly one was incidental to what
|
||||||
|
// this test is actually for -- "the page renders a modal that opens and
|
||||||
|
// closes" -- so require at least one and drive the primary one, which is
|
||||||
|
// what the injected script's document.querySelector(".bv-modal") already
|
||||||
|
// operates on.
|
||||||
|
const modals = page.locator(".bv-modal");
|
||||||
|
expect(await modals.count()).toBeGreaterThan(0);
|
||||||
|
const modal = modals.first();
|
||||||
|
|
||||||
// Inject logic to initialize the modal since we are viewing a partial/component in isolation.
|
// Inject logic to initialize the modal since we are viewing a partial/component in isolation.
|
||||||
await page.addScriptTag({
|
await page.addScriptTag({
|
||||||
|
|||||||
@@ -29,6 +29,31 @@ test.describe("Webseed Stream Playback", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("plays directly from HTML link (CDN mode)", async ({ page }) => {
|
test("plays directly from HTML link (CDN mode)", async ({ page }) => {
|
||||||
|
// Record every src the player attaches. Asserting the FINAL src is wrong:
|
||||||
|
// this test already tolerates "No playable source found" in its status
|
||||||
|
// check (headless CI often cannot decode the archive.org H.264 sample), and
|
||||||
|
// when the attempt fails resetVideoElement() clears src back to "". The
|
||||||
|
// behaviour under test is that CDN mode ATTACHES the hosted URL, which is
|
||||||
|
// observable regardless of whether the remote file then decodes.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(window as any).__attachedSrcs = [];
|
||||||
|
new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
const target = mutation.target as HTMLElement;
|
||||||
|
if (mutation.attributeName === "src" && target.tagName === "VIDEO") {
|
||||||
|
const value = target.getAttribute("src");
|
||||||
|
if (value) {
|
||||||
|
(window as any).__attachedSrcs.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).observe(document.body, {
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["src"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 1. Trigger playback with just the URL
|
// 1. Trigger playback with just the URL
|
||||||
await page.evaluate(async (url) => {
|
await page.evaluate(async (url) => {
|
||||||
const { getApplication } = await import("/js/applicationContext.js");
|
const { getApplication } = await import("/js/applicationContext.js");
|
||||||
@@ -55,9 +80,10 @@ test.describe("Webseed Stream Playback", () => {
|
|||||||
const urlToggle = page.locator('[data-source-toggle="url"]');
|
const urlToggle = page.locator('[data-source-toggle="url"]');
|
||||||
await expect(urlToggle).toHaveAttribute("aria-pressed", "true");
|
await expect(urlToggle).toHaveAttribute("aria-pressed", "true");
|
||||||
|
|
||||||
// Check if video is attempting to play from source
|
// Check the player attached the hosted URL (see the observer above).
|
||||||
const video = page.locator("#modalVideo");
|
await expect
|
||||||
await expect(video).toHaveAttribute("src", VIDEO_URL);
|
.poll(async () => await page.evaluate(() => (window as any).__attachedSrcs))
|
||||||
|
.toContain(VIDEO_URL);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("falls back to WebTorrent using HTML link as webseed when CDN fails", async ({ page }) => {
|
test("falls back to WebTorrent using HTML link as webseed when CDN fails", async ({ page }) => {
|
||||||
|
|||||||
@@ -92,18 +92,32 @@ test("trusted report summaries respect personal blocks and admin lists", async (
|
|||||||
|
|
||||||
service.recomputeSummaryForEvent(eventId);
|
service.recomputeSummaryForEvent(eventId);
|
||||||
|
|
||||||
|
// 18e08bf2 ("refine trust controls") removed `status.whitelisted ||` from
|
||||||
|
// isTrustedReporter and updated the doc comment from "trustedContacts set (or
|
||||||
|
// Admin Whitelist)" to "trustedContacts set". Being on the admin whitelist is
|
||||||
|
// permission to publish, not a statement that your reports carry weight, so a
|
||||||
|
// whitelisted reporter now counts toward the report TOTAL but not toward
|
||||||
|
// TRUSTED.
|
||||||
|
//
|
||||||
|
// Counted reporters: trusted (t=100), whitelisted (t=400), random (t=300).
|
||||||
|
// Excluded entirely: blacklisted (admin list) and blocked (personal block).
|
||||||
const summary = service.getTrustedReportSummary(eventId);
|
const summary = service.getTrustedReportSummary(eventId);
|
||||||
assert.equal(summary.totalTrusted, 2);
|
assert.equal(summary.totalTrusted, 1, "only the trusted contact is trusted");
|
||||||
assert.equal(summary.types.nudity.total, 3);
|
assert.equal(summary.types.nudity.total, 3, "whitelisted still counts as a report");
|
||||||
assert.equal(summary.types.nudity.trusted, 2);
|
assert.equal(summary.types.nudity.trusted, 1);
|
||||||
assert.equal(summary.types.nudity.latest, 400);
|
assert.equal(
|
||||||
|
summary.types.nudity.latest,
|
||||||
|
400,
|
||||||
|
"latest still reflects the whitelisted reporter's timestamp"
|
||||||
|
);
|
||||||
|
|
||||||
const reporters = service.getTrustedReporters(eventId, "nudity");
|
const reporters = service.getTrustedReporters(eventId, "nudity");
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
reporters.map((entry) => entry.pubkey),
|
reporters.map((entry) => entry.pubkey),
|
||||||
[whitelistedHex, trustedHex]
|
[trustedHex],
|
||||||
|
"the whitelisted reporter is no longer surfaced as a trusted reporter"
|
||||||
);
|
);
|
||||||
assert.equal(service.trustedReportCount(eventId, "nudity"), 2);
|
assert.equal(service.trustedReportCount(eventId, "nudity"), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("user block updates recompute summaries and emit notifications", async (t) => {
|
test("user block updates recompute summaries and emit notifications", async (t) => {
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
// SignerManager lifecycle scoping for NIP-46 multi-account (pre-launch TODO #33).
|
||||||
|
//
|
||||||
|
// The 2026-06-25 audit blamed "logout logs out everyone" on the single shared
|
||||||
|
// nip46Client being torn down by any scoped logout. Since then the layers under
|
||||||
|
// it were fixed piecemeal (per-account v2 session map, scoped clears, targeted
|
||||||
|
// restore) — but nothing pinned the MANAGER-level contract, so any of those
|
||||||
|
// fixes could regress silently. These tests exercise the real SignerManager
|
||||||
|
// against the real signer registry and real (polyfilled) storage.
|
||||||
|
//
|
||||||
|
// test_integrity_note:
|
||||||
|
// change_type: ["new_tests"]
|
||||||
|
// scenarios:
|
||||||
|
// - id: SCN-nip46-logout-scope
|
||||||
|
// given: "account A active with a live remote-signer client; A and B both have stored sessions; B has a registered signer"
|
||||||
|
// when: "SignerManager.logout() logs out A"
|
||||||
|
// then: "A's live client is destroyed exactly once; BOTH stored sessions survive; B's registry signer survives"
|
||||||
|
// - id: SCN-nip46-restore-failure-scope
|
||||||
|
// given: "stored sessions for A and B"
|
||||||
|
// when: "a targeted restore of A fails (locked session / missing session)"
|
||||||
|
// then: "the failure is typed and NO stored session is deleted"
|
||||||
|
// observable_outcomes:
|
||||||
|
// - "fake client's destroy() call count"
|
||||||
|
// - "readStoredNip46Session(pubkey) after the operation"
|
||||||
|
// - "resolveActiveSigner(pubkey) for the OTHER account"
|
||||||
|
// determinism_controls:
|
||||||
|
// - "in-memory localStorage polyfill; fake in-process client object; no network"
|
||||||
|
// anti_cheat_rationale:
|
||||||
|
// prevents: ["over-mocking internal logic", "hard-coded return value"]
|
||||||
|
// relaxation:
|
||||||
|
// did_relax_any_assertion: false
|
||||||
|
|
||||||
|
import "./test-helpers/setup-localstorage.mjs";
|
||||||
|
import test, { beforeEach } from "node:test";
|
||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { SignerManager } from "../js/nostr/managers/SignerManager.js";
|
||||||
|
import {
|
||||||
|
writeStoredNip46SessionSync,
|
||||||
|
readStoredNip46Session,
|
||||||
|
} from "../js/nostr/nip46Client.js";
|
||||||
|
import {
|
||||||
|
registerSigner,
|
||||||
|
setActiveSigner,
|
||||||
|
clearActiveSigner,
|
||||||
|
resolveActiveSigner,
|
||||||
|
} from "../js/nostrClientRegistry.js";
|
||||||
|
|
||||||
|
const USER_A =
|
||||||
|
"00000000000000000000000000000000000000000000000000000000000000a1";
|
||||||
|
const USER_B =
|
||||||
|
"00000000000000000000000000000000000000000000000000000000000000b2";
|
||||||
|
|
||||||
|
function storedSession(userPubkey, remotePubkey) {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
clientPublicKey:
|
||||||
|
"3333333333333333333333333333333333333333333333333333333333333333",
|
||||||
|
remotePubkey,
|
||||||
|
relays: ["wss://relay.example"],
|
||||||
|
encryption: "nip44.v2",
|
||||||
|
userPubkey,
|
||||||
|
lastConnectedAt: 1700000000000,
|
||||||
|
// Sessions at rest carry encrypted secrets; restoring without the
|
||||||
|
// passphrase must fail typed, not fall through to a broken connect.
|
||||||
|
encryptedSecrets: "cipher",
|
||||||
|
keyEncryption: {
|
||||||
|
salt: "c2FsdA==",
|
||||||
|
iv: "aXY=",
|
||||||
|
iterations: 1000,
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeManager() {
|
||||||
|
// SignerManager only needs the app client for pool access on connect paths;
|
||||||
|
// the scoping contracts under test never reach the network.
|
||||||
|
return new SignerManager({
|
||||||
|
relays: ["wss://relay.example"],
|
||||||
|
ensurePool: async () => {
|
||||||
|
throw new Error("network must not be touched by these tests");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
clearActiveSigner();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logging out the active NIP-46 account keeps every other account intact", () => {
|
||||||
|
writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64)));
|
||||||
|
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
|
||||||
|
|
||||||
|
// B is a signed-in account in the registry (e.g. logged in earlier this
|
||||||
|
// session); A is the ACTIVE account with the live remote-signer client.
|
||||||
|
const signerB = { pubkey: USER_B, signEvent: async (e) => e };
|
||||||
|
registerSigner(USER_B, signerB);
|
||||||
|
|
||||||
|
let destroyCalls = 0;
|
||||||
|
const liveClient = {
|
||||||
|
remotePubkey: "1".repeat(64),
|
||||||
|
userPubkey: USER_A,
|
||||||
|
destroy() {
|
||||||
|
destroyCalls += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = makeManager();
|
||||||
|
manager.pubkey = USER_A;
|
||||||
|
manager.nip46Client = liveClient;
|
||||||
|
|
||||||
|
manager.logout();
|
||||||
|
|
||||||
|
// The departing account's LIVE connection is torn down exactly once…
|
||||||
|
assert.equal(destroyCalls, 1);
|
||||||
|
assert.equal(manager.nip46Client, null);
|
||||||
|
assert.equal(manager.pubkey, null);
|
||||||
|
|
||||||
|
// …but its stored session survives (logout keeps sessions reconnectable —
|
||||||
|
// that is what makes "switch back later" possible)…
|
||||||
|
const storedA = readStoredNip46Session(USER_A);
|
||||||
|
assert.ok(storedA, "A's stored session must survive A's own logout");
|
||||||
|
assert.equal(storedA.remotePubkey, "1".repeat(64));
|
||||||
|
|
||||||
|
// …and account B is completely untouched: stored session AND registry signer.
|
||||||
|
const storedB = readStoredNip46Session(USER_B);
|
||||||
|
assert.ok(storedB, "B's stored session must survive A's logout");
|
||||||
|
assert.equal(storedB.remotePubkey, "2".repeat(64));
|
||||||
|
assert.equal(
|
||||||
|
resolveActiveSigner(USER_B),
|
||||||
|
signerB,
|
||||||
|
"B's registered signer must survive A's logout"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logout is safe when no remote client is live (nsec/nip07 active)", () => {
|
||||||
|
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
|
||||||
|
|
||||||
|
const manager = makeManager();
|
||||||
|
manager.pubkey = USER_A; // active via some non-NIP-46 method
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => manager.logout());
|
||||||
|
assert.ok(
|
||||||
|
readStoredNip46Session(USER_B),
|
||||||
|
"an unrelated account's NIP-46 session must survive a non-NIP-46 logout"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed targeted restore deletes nothing", async () => {
|
||||||
|
writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64)));
|
||||||
|
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
|
||||||
|
|
||||||
|
const manager = makeManager();
|
||||||
|
|
||||||
|
// A's session is passphrase-locked and none was provided: typed failure.
|
||||||
|
await assert.rejects(
|
||||||
|
manager.useStoredRemoteSigner({ pubkey: USER_A, silent: true }),
|
||||||
|
(error) => error?.code === "passphrase-required"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(readStoredNip46Session(USER_A), "A's session survives its own failed restore");
|
||||||
|
assert.ok(readStoredNip46Session(USER_B), "B's session survives A's failed restore");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restoring an account with no stored session fails typed and touches nothing", async () => {
|
||||||
|
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
|
||||||
|
|
||||||
|
const manager = makeManager();
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
manager.useStoredRemoteSigner({ pubkey: USER_A, silent: true }),
|
||||||
|
(error) => error?.code === "no-stored-session"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
readStoredNip46Session(USER_B),
|
||||||
|
"an unrelated stored session must survive a missing-session restore attempt"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disconnectRemoteSigner({keepStored:true}) never clears stored sessions", async () => {
|
||||||
|
writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64)));
|
||||||
|
|
||||||
|
const manager = makeManager();
|
||||||
|
manager.nip46Client = { destroy() {} };
|
||||||
|
|
||||||
|
await manager.disconnectRemoteSigner({ keepStored: true });
|
||||||
|
|
||||||
|
assert.ok(readStoredNip46Session(USER_A));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disconnectRemoteSigner() without keepStored clears only the last-connected default", async () => {
|
||||||
|
// The no-arg clear historically wiped the whole v2 map — "after switching
|
||||||
|
// away you could no longer switch back". Pin the scoped behavior: the v1
|
||||||
|
// default slot goes, the per-account map survives.
|
||||||
|
writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64)));
|
||||||
|
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
|
||||||
|
|
||||||
|
const manager = makeManager();
|
||||||
|
manager.nip46Client = { destroy() {} };
|
||||||
|
|
||||||
|
await manager.disconnectRemoteSigner();
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
readStoredNip46Session(USER_A),
|
||||||
|
"per-account session A must survive a bare disconnect"
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
readStoredNip46Session(USER_B),
|
||||||
|
"per-account session B must survive a bare disconnect"
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -267,8 +267,18 @@ describe("PlaybackService Forced Source Logic", () => {
|
|||||||
assert.equal(probeUrl.mock.callCount(), 0);
|
assert.equal(probeUrl.mock.callCount(), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Forced Source 'torrent': Does NOT fallback to URL even if Torrent fails", async () => {
|
test("Forced Source 'torrent': falls back to the hosted URL when the torrent definitively fails", async () => {
|
||||||
const probeUrl = mock.fn(async () => ({ outcome: "good" }));
|
// Spec changed deliberately in playbackService.js: "Even when P2P was
|
||||||
|
// explicitly forced, a hosted URL is a better outcome than a hard 'No
|
||||||
|
// playable source found' if the torrent DEFINITIVELY failed." The code
|
||||||
|
// carries the rollback note (`restore && forcedSource !== "torrent"` to
|
||||||
|
// make forced P2P hard-fail), so the old assertion here -- source null,
|
||||||
|
// probe never called -- encodes behavior that was intentionally removed.
|
||||||
|
//
|
||||||
|
// It did not merely fail: because forced sources skip the start timeout,
|
||||||
|
// the now-reachable hosted attempt waited forever on a <video> that never
|
||||||
|
// emits, and the test hung ("Promise resolution is still pending").
|
||||||
|
const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 }));
|
||||||
|
|
||||||
const playViaWebTorrent = mock.fn(async () => {
|
const playViaWebTorrent = mock.fn(async () => {
|
||||||
throw new Error("Torrent failed");
|
throw new Error("Torrent failed");
|
||||||
@@ -283,9 +293,18 @@ describe("PlaybackService Forced Source Logic", () => {
|
|||||||
forcedSource: "torrent",
|
forcedSource: "torrent",
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await session.start();
|
const startPromise = session.start();
|
||||||
|
|
||||||
assert.equal(result.source, null);
|
// Let the torrent attempt reject and the hosted fallback attach, then
|
||||||
assert.equal(probeUrl.mock.callCount(), 0);
|
// signal that the element actually started playing.
|
||||||
|
await new Promise(resolve => process.nextTick(resolve));
|
||||||
|
mock.timers.tick(10);
|
||||||
|
await new Promise(resolve => process.nextTick(resolve));
|
||||||
|
video.dispatchEvent(new window.Event("playing"));
|
||||||
|
|
||||||
|
const result = await startPromise;
|
||||||
|
|
||||||
|
assert.equal(playViaWebTorrent.mock.callCount(), 1, "P2P is still tried first");
|
||||||
|
assert.equal(result.source, "url", "a working CDN beats a hard failure");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -133,14 +133,24 @@ describe("trustBootstrap", () => {
|
|||||||
assert.ok(seeds.has("editor1"));
|
assert.ok(seeds.has("editor1"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should subscribe to accessControl changes", async () => {
|
it("should subscribe to the accessControl lists that affect seeds", async () => {
|
||||||
await bootstrapTrustedSeeds();
|
await bootstrapTrustedSeeds();
|
||||||
|
|
||||||
assert.equal(accessControl.onWhitelistChange.mock.callCount(), 1);
|
|
||||||
assert.equal(accessControl.onEditorsChange.mock.callCount(), 1);
|
assert.equal(accessControl.onEditorsChange.mock.callCount(), 1);
|
||||||
assert.equal(accessControl.onBlacklistChange.mock.callCount(), 1);
|
assert.equal(accessControl.onBlacklistChange.mock.callCount(), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should NOT subscribe to whitelist changes", async () => {
|
||||||
|
// buildTrustedSeeds() derives seeds from the super admin, the editors list
|
||||||
|
// and the fallback seeds -- the whitelist is not an input, so re-applying
|
||||||
|
// seeds on a whitelist change is pure churn. 18e08bf2 ("refine trust
|
||||||
|
// controls") removed that subscription deliberately; this pins the removal
|
||||||
|
// so it is not reinstated by reflex.
|
||||||
|
await bootstrapTrustedSeeds();
|
||||||
|
|
||||||
|
assert.equal(accessControl.onWhitelistChange.mock.callCount(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
it("should handle accessControl timeout and apply seeds anyway", async () => {
|
it("should handle accessControl timeout and apply seeds anyway", async () => {
|
||||||
// Mock waitForAccessControl to timeout (reject)
|
// Mock waitForAccessControl to timeout (reject)
|
||||||
accessControl.waitForReady.mock.mockImplementation(async () => {
|
accessControl.waitForReady.mock.mockImplementation(async () => {
|
||||||
|
|||||||
@@ -13,13 +13,7 @@
|
|||||||
|
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { createUiCoordinator } from "../js/app/uiCoordinator.js";
|
|
||||||
import { VideoModal } from "../js/ui/components/VideoModal.js";
|
import { VideoModal } from "../js/ui/components/VideoModal.js";
|
||||||
import {
|
|
||||||
clearActiveSigner,
|
|
||||||
registerSigner,
|
|
||||||
setActiveSigner,
|
|
||||||
} from "../js/nostrClientRegistry.js";
|
|
||||||
|
|
||||||
const PUBKEY = "b".repeat(64);
|
const PUBKEY = "b".repeat(64);
|
||||||
|
|
||||||
@@ -71,103 +65,44 @@ test("setShareNostrAuthState tolerates no popover", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- syncAuthUiState wiring ----------------------------------------------
|
// --- app wiring ----------------------------------------------------------
|
||||||
|
// bitvid ALREADY routed auth changes to the modal: Application
|
||||||
|
// .updateShareNostrAuthState() is called on init, login, logout, pubkey-change
|
||||||
|
// and signer-change, and it forwards to videoModal.setShareNostrAuthState().
|
||||||
|
// That method simply did not exist, so the call short-circuited on its own
|
||||||
|
// `if (!this.videoModal?.setShareNostrAuthState) return;` guard and the gate
|
||||||
|
// stayed shut forever. These pin the contract that guard depends on.
|
||||||
|
|
||||||
function makeApp({ loggedIn }) {
|
test("the app's updateShareNostrAuthState contract is satisfied", async () => {
|
||||||
const coordinator = createUiCoordinator({
|
const { readFile } = await import("node:fs/promises");
|
||||||
devLogger: { warn() {}, error() {}, log() {} },
|
const source = await readFile(new URL("../js/app.js", import.meta.url), "utf8");
|
||||||
});
|
|
||||||
const pushed = [];
|
|
||||||
const app = {
|
|
||||||
...coordinator,
|
|
||||||
isUserLoggedIn: () => loggedIn,
|
|
||||||
// Stub the DOM-touching branches; this test is about the share gate.
|
|
||||||
applyAuthenticatedUiState() {},
|
|
||||||
applyLoggedOutUiState() {},
|
|
||||||
videoModal: {
|
|
||||||
setShareNostrAuthState(state) {
|
|
||||||
pushed.push({ ...state });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return { app, pushed };
|
|
||||||
}
|
|
||||||
|
|
||||||
test("syncAuthUiState pushes the gate into the video modal", () => {
|
// The guard short-circuits when the method is absent, which is exactly how
|
||||||
const { app, pushed } = makeApp({ loggedIn: true });
|
// this stayed broken silently. If the method is ever renamed, fail loudly.
|
||||||
app.syncAuthUiState();
|
assert.match(source, /videoModal\?\.setShareNostrAuthState/);
|
||||||
assert.equal(pushed.length, 1);
|
assert.match(source, /videoModal\.setShareNostrAuthState\(\{/);
|
||||||
assert.equal(pushed[0].isLoggedIn, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logged out propagates as a closed gate", () => {
|
// The lifecycle points that must keep the gate in sync.
|
||||||
const { app, pushed } = makeApp({ loggedIn: false });
|
for (const reason of ["init", "signer-change", "pubkey-change"]) {
|
||||||
app.syncAuthUiState();
|
assert.ok(
|
||||||
assert.deepEqual(pushed[0], { isLoggedIn: false, hasSigner: false });
|
source.includes(`reason: "${reason}"`),
|
||||||
});
|
`app.js should refresh the share gate on ${reason}`
|
||||||
|
|
||||||
test("hasSigner reflects a registered, signing-capable signer", () => {
|
|
||||||
clearActiveSigner();
|
|
||||||
const { app, pushed } = makeApp({ loggedIn: true });
|
|
||||||
|
|
||||||
app.syncAuthUiState();
|
|
||||||
assert.equal(pushed[0].hasSigner, false, "no signer registered yet");
|
|
||||||
|
|
||||||
registerSigner(PUBKEY, { pubkey: PUBKEY, signEvent: async (e) => e });
|
|
||||||
setActiveSigner(PUBKEY);
|
|
||||||
app.syncAuthUiState();
|
|
||||||
assert.equal(pushed[1].hasSigner, true);
|
|
||||||
|
|
||||||
clearActiveSigner();
|
|
||||||
app.syncAuthUiState();
|
|
||||||
assert.equal(pushed[2].hasSigner, false, "logout must close the gate again");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a signer that cannot sign does not open the gate", () => {
|
|
||||||
// Mirrors ShareNostrController.handleShare(): enabling the item for a signer
|
|
||||||
// without signEvent would only produce an error toast on click.
|
|
||||||
clearActiveSigner();
|
|
||||||
registerSigner(PUBKEY, { pubkey: PUBKEY });
|
|
||||||
setActiveSigner(PUBKEY);
|
|
||||||
|
|
||||||
const { app, pushed } = makeApp({ loggedIn: true });
|
|
||||||
app.syncAuthUiState();
|
|
||||||
assert.equal(pushed[0].hasSigner, false);
|
|
||||||
clearActiveSigner();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("syncAuthUiState re-runs on bitvid:auth-changed, and binds only once", () => {
|
|
||||||
// The original bug's second half: the gate was computed once at bootstrap and
|
|
||||||
// never again, so logging in mid-session left the button disabled.
|
|
||||||
const listeners = [];
|
|
||||||
const originalWindow = globalThis.window;
|
|
||||||
globalThis.window = {
|
|
||||||
addEventListener(type, handler) {
|
|
||||||
listeners.push({ type, handler });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { app, pushed } = makeApp({ loggedIn: false });
|
|
||||||
app.syncAuthUiState();
|
|
||||||
assert.equal(pushed.length, 1);
|
|
||||||
|
|
||||||
const bound = listeners.filter((l) => l.type === "bitvid:auth-changed");
|
|
||||||
assert.equal(bound.length, 1, "listener installed on first sync");
|
|
||||||
|
|
||||||
bound[0].handler();
|
|
||||||
assert.equal(pushed.length, 2, "auth change re-syncs the gate");
|
|
||||||
|
|
||||||
// Re-entrancy: the resync must not stack a new listener every time.
|
|
||||||
assert.equal(
|
|
||||||
listeners.filter((l) => l.type === "bitvid:auth-changed").length,
|
|
||||||
1
|
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
if (originalWindow === undefined) {
|
|
||||||
delete globalThis.window;
|
|
||||||
} else {
|
|
||||||
globalThis.window = originalWindow;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
typeof VideoModal.prototype.setShareNostrAuthState,
|
||||||
|
"function",
|
||||||
|
"VideoModal must expose the method app.js calls"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login and logout both refresh the gate", async () => {
|
||||||
|
const { readFile } = await import("node:fs/promises");
|
||||||
|
const source = await readFile(
|
||||||
|
new URL("../js/app/authSessionCoordinator.js", import.meta.url),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
assert.ok(source.includes('reason: "auth-login"'));
|
||||||
|
assert.ok(source.includes('reason: "auth-logout"'));
|
||||||
});
|
});
|
||||||
@@ -125,14 +125,33 @@ test("VideoModal blurs and restores playback when moderation overlay toggles", a
|
|||||||
});
|
});
|
||||||
|
|
||||||
await t.test("trusted mute blur state matches moderation context", async () => {
|
await t.test("trusted mute blur state matches moderation context", async () => {
|
||||||
|
// Fixture mirrors what moderationDecorator actually emits. Two things
|
||||||
|
// made the old hand-rolled shape stop exercising this path:
|
||||||
|
//
|
||||||
|
// 1. normalizeVideoModerationContext derives `shouldShow` from
|
||||||
|
// `original.blurThumbnail` and `trustedCount`, NOT from a flat
|
||||||
|
// `blurThumbnail`/`trustedMuteCount`. The old fixture set fields the
|
||||||
|
// helper never reads, so shouldShow was false and applyModerationOverlay
|
||||||
|
// took its early return and deleted the blur it was asserting on.
|
||||||
|
// 2. 4525c1c6 made a lone trusted mute a non-blocking ranking signal (see
|
||||||
|
// "VideoCard keeps a single trusted mute as a non-blocking ranking
|
||||||
|
// signal"), so trusted-mute alone no longer forces the overlay.
|
||||||
|
//
|
||||||
|
// The scenario under test is still "a trusted mute that DID trigger a
|
||||||
|
// blur renders blurred with a trusted-mute badge", so the fixture now
|
||||||
|
// carries the decorator's `original` block that represents exactly that.
|
||||||
const video = {
|
const video = {
|
||||||
id: "event-trusted-mute",
|
id: "event-trusted-mute",
|
||||||
title: "Trusted mute video",
|
title: "Trusted mute video",
|
||||||
moderation: {
|
moderation: {
|
||||||
blurThumbnail: true,
|
blurThumbnail: true,
|
||||||
|
blurReason: "trusted-mute",
|
||||||
trustedMuted: true,
|
trustedMuted: true,
|
||||||
|
trustedCount: 2,
|
||||||
trustedMuteCount: 2,
|
trustedMuteCount: 2,
|
||||||
|
trustedMuterDisplayNames: ["Carol", "Dave"],
|
||||||
trustedMuteDisplayNames: ["Carol", "Dave"],
|
trustedMuteDisplayNames: ["Carol", "Dave"],
|
||||||
|
original: { blurThumbnail: true, blurReason: "trusted-mute" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"contrast": "iVBORw0KGgoAAAANSUhEUgAABQwAAAXVCAIAAADPSQWyAAAQAElEQVR4nOy9ibbtOm4dCt5x/v8bY1eVY7sqeXEcx3GawXf2aiURPUGKWguzbp0tkWAjNiAmQWn9qrVCIpE4HeqpSAg2wbsA6mYbjgZjgcyFJnPuBg1gQu0yk/FqIFgWpRQIQ7n973RYq8G3ARMrNR4Xb6xkbz/Z0wspnM0i18PxpKGDeDC0aoqWa2OOITqtytekgkZxsaP42C37e/LufYkG7hPSN9iwoAaKdgBdaaAlEolO/IJEIpFYCJ+ybbfdIIDEbFRYgawnEolEIpG4JP6ARCKRUKCL6ekTfxyh/C6GXG//Ox3LNLrkqftQ5LZQQkaOkkQisTSSJCcS34bTTZNK3ijTdMgkGkQ32yrdYHHg87JMbJ4SSCyImFFZU+smEolvRpLkRCIxF3arSvUSX9IVL8J9v0u8G26sQu2IdaNe+6MgOeMSNuSISSQSF0KS5EQicUPlA6suReWjvwWv755dohU+tacCKSidU3cR2gySYCRumDIQDHOHUXJVDEgkEollkSQ5kUiY4CEelbhuM0M/wQrXQj38vQDqAxCFuoiTVF+LQfWt3QJvycVmQhKeVZA9kUgkEvFIkpxIXAvVFOyTPM3m8rOAU/lD3f13/+fx92KI5WFX3d2wRdaOTJUSb9GOJrX3bXKvRCKRSHwtkiQnEl+AfmuXPW0tnNTeXe6cymEfhjnRnN8950W58RaRPuW6gkPZcnDUVVehAAVLNpR7jQP8XCVDdw2+Equ1T5CyUAcyn36sUvJqKb1DLpFIfACSJCcSiQN0Jr/BppGyet47z1qfxcPq7t3ji7AXLR78NqJpY3LpqkHEoevaRfzC8GHjLAjXeSfD8KrvgEwTiUQiocUvSCQSC6BzI1u/Y24SqvSFlAvqSN77kekUKlQkz3nY0GMYCsKXwpdabv+LKfxWVCkd2b1yuaPc8oO5qKBtkN81JWr3m0FT7aDP3lpoZGm3LYAS1/a9T534EAQPhBxXiURiBSRJTiQSFMz0Dz2BreT1KP1m3HqR3NTvtBnAkI8FeIqoz3QPrtxtcr7aO4BiHTjzI9+gitKl1qrl54yNTkbZWK4lZ0y0lmQRiU7M2F7kR3WS4UQisTKSJCcSK+Acb6i6BkpHssSRq6aIVwBuw+l+M/nwV4mTemH/VjOE4pFhjfMtb9s/KlPYVBQQwzmMPys945zL2O5nZnJziD2lb/9ekmLI+wlJns6G+VgPOfghTkO8c8oBkkgkJiBJciLxkajmCGNmOlJNc2TpCyuuU8YPmfO3HERMO60Nm4KeRd2s2RjOec80LMNntljAq/Z4IhtdF4kabYbXW9piSqTmyRPOoV/BD+0gQbY9hhPRO+Fn+H8voEETiURiNPLDXYnE90Lgss6XhIF4CZn77lGVAu6Bco2uw5BP/NbX7YNcYd/lemc453Eq/p+156Xvbq89kOp+42BUKae1QdK0y0HXY6PfVpE2X6+wR5FIJBZBkuRE4kpQvt87fiVHTlabymS9ygRFBjnXdS2YJ5F7ctMlalpjdxVmUmW0fOtPTvHClS8JD+eSgKJGoEb17k0E9k9y2euiOmKcGcajEteJRCIRhyTJicQC0C7zWo7cAxVtqJw4Hrm7Np28ZkLtMpNRX0x0FWLcwkYs5ezOdueH8WQPh+Z6edhRCOvQOncvI2nNNyFyDy6RSCRmIklyIvH5sHBrt4tYECe3/g+kAP+x5KsdtH4dA4a4M81DUV+AEETm5Ste3+yVdxnTcUToJxnz7mdJRnMi2pNFpm0jcCL7PJFIfBSSJCcS56PTuOgyiYgs+CAlk67oJXvUGstF9TRrMeTH3yueRo2ky6/j5TAf1UCVK+9PZoowhcOYrZwrDjIYgY/anUjGmUgkEuciv26dSHw8FDyUkzUeOUXz0p27RimEpvZneu8OT/BBlvq2N7o+HHzooJkfIf5dbNF9M5r92Sfj79vUGV+p3lekFuM3q6vuA9LTH6UHFfKXgbQ4S08Z+yi+S3OQJBIJJZIkJxKnQ22uyAeZhxTbJGgvhMxIP/JequrCXhmtxI2x0DFl3YPEkgJ/wxiehDmG3r7Id2wVyeIMPJmUIizrSv/mkzUrVVo+3QV+2+nLsf4eWl/9FiCghyqoa5TcOZFIHJEkOZG4DNTvWLK3vkzU0vzV/jeh2O93CQWOPI5o27UYVhHu1W1N6s0PI9//6TYCH61++1XkAIvy6GHeRZY2qKOgn7orKD79G8hm5lqZH1pewzlbr/LLwp+D33NwBk8uHVppdRZ/OpJNJxJfgyTJicTZiH4j2S6tdQgfhao5g2qKopiw+SdxIRCVuYsuIMqgrvvMeqnRnXNuAm759VPwV3avu93j97L8SG/4Jk/ykDbTJIKh7fEJ39urXMOZnCzjbAzhwuiwHk8q2RKS0yYSCS+SJCcSnw0TnxTcwLaCkStCABWq1UCbsawrGrom6uFybFXD2WKwb5QcMC8/9hbGkmln8UsATC8gmzNaCB/GID7oWX6PnPx2lwvJihOJRBCSJCcSiRGoUqDEkcFd8nWMywFOY0PhoVT5xj1/LsoEv9GhZHO5An21vYDsOLUseoqv/3rxGZVPbhSAnqPaA5B9mkgkTkP+BFQicRXMefeVDGJ4bLWlY0KFjD7HtVIfb+S+/oOTcKPKQaUfTnVPw70xbWcmPJUkE1Vrgi+HzHzyXenBGNK+1+i0nJSJREKHJMmJxEVg+5rU/GKpZBULpMU5SUVdLuFGXqyKkU3m4KuBhVsKrnxGtkRXtbt7WU1yWTu62xxCUEzB8QUtV0Jy50QicUCS5ETi2lhgaecdyZoMVBxZk/mFDJ2l/OK11lhmG52foWQYCWvuaXm3UDBrD+35NMJeHDGRSTwYWowy89y3SSQSQUiSnEicjJWokjuSFlY5kg+JP5cj17uXsq55crwG1+uMZ6yGYfZRDLaYf3da9SZ1ucrLxVfhRqfUM4ljIpFImJEkOZFIMHBS43OwOOmpr3/WxfVZ8veiTD8r6ypviB/5E899F/2J6MhdBX9TqhLGdFSy/kQiMR5JkhOJD8OHEpPkW7MQ+oryGaeuP8BDbOe7ZtZwFs0Yc9b6SojfzAjOz5ldMYRqIn2yRbhPgp1IJLRIkpxIXANJEhOzkGMtDD6D3EyjClgJh6qIwU7FqDLLxfzI6u51P5jOp1tCyw38IJmYVSFvEolEIgxJkhOJxBXwAZbQnJ8RTixEmYo5Auz1L+ZBFTMIi+fTUrxj8Wp01w31c3pZcuNQHduyRXs2POltIpG4DJIkJxIfhi82PNZ/9OfXle7/waJI25WEkgrwwVE0vtxhZchqcfcLySP9n8c0V+XVsfV2nlgQnMnqE87qzSDr7oki1D1KE4lEgscvMKO+/k46k/c1e8uJxMko7UnbYjl8iwlvwtR5oYKa1OUaR4WXrmaowrU7OSNKVEoWtWkvRcxforwNW/QHs0vxx9LJIBIXNw7cegBT1OXwLn4rYy3uJ0so7Cv+/DBsu6eQd4WUsiGtxUQiEYZfF/gVDLaKyaATCRalBH3IqJsuq6V/213VzZLLmr+udMT26PXuV7JOrXy0Pi2n0MeITIollHB3GcM1sS8px2OaOlcSdh4XDxoOn7Lu357DZYQplGGr+bG14HcVmAoUeHQnJmMfI0WIV6IMyDPpdSKRaHD549Z1D0gkvh4hrxv6hAsbyzjoeI8DJYWWdj1bp7z/O+sY9vPgbnCuMB36Vz3NlaM4LyUOHqhSFUfdDWlEYTaejQM2W5AxZJyeC+mRijusYGUBWgEQsD99XWSGjIyDwvBZtyNZ4MifNVQSicRcOI5bL403T04XcyIRgbfLohydF6+Awh7TLqjTo/CuEFeih9Dd9XEM1myh8Upjhpv35WGe+F5LsLJ8PcJMmI8fWw6JctmYnMuyGxk0ZYIFxpaRnz7gCdyIOSYAvnrerIvpO/GEziv8qy2PALPrmKvIT/aPya2QxpKTt0W+tm1RMUjLMJFIKPFpJPmN/TKQlDnxqZB5oyjB8GBNciIr7Jj3Nq99vir77m7h6Xgj5VPoMHJRslEPf6NQNoWOIszxp6sBTqDHtkKtr1G+IvDM9KEyQ5afwd5j2pbRcenS86JyT2wPooa54X3uO+rIF12Q09TIuyYoJS6KV5rJqqhQKPWL37o5Ml+IrbcSiUTigM8lyXtslonky4lvA01NaVFOrjCfhCn6b3cdWDL2Bh1GC2/WvN/8jJ7+5f3gD9TXP2FlvP6WTcaGJhjNXs30QZUpH+kqzv2qLeEwNhQhnFEeQZBBzzxDGHLPFoCPBC2OolcE3NcUCAar5LpK13Hk5xxshxGKgiP7ii0aISHcL5hIJK6ObyHJG+SJ7ETiAJ7Z0pYeJsNf7ay4g/XWGnO03VZuX15d4htduGX2DH1UMO57Ce/iztdfI85p7+8i8lfuEEg/h4OEKgP7HciupjYkkd8v7Wi9ezx0VUBTREfWK4Al1LgmdFNi4phPqRE8GX/vmRGn5KzcuYsjJxKJRIMvJMlvJF1OXAjF4pSgXh5WhDLRrZv5bWypfcn7+IO1hvBk2uZ7HRCtA5y3UXholvdr0tf4+DaBSE3p/REjff73P50e0mJxF9uaR9marka3MOTebii9HHvk+nvm0m6kncV0eucerDtNrQm5h3ad0qGnBbL/hQsSUhqO3OYGiUQi0YOvJslvPFeFJMuJ6wE7dGdljAV7lRjhxM0Fy5e3ee0cyFBJmozZc+RvkLySPJyFi3/gnnJp7ij+ck8QqxUn6Vh9MWYHspoKvIIBLOFCdrS8i+3qznn7M1B9OBlkXHVtNh1iZr3AVCQSXhQ//kT/+pSob8n66cKVruPu/i7qQDYikUh8L5Ik7/BYF5IrJy4OhiUXj1eZkWc5etGcs268yaC0BTEZgNWpcovy+mfzt+7/mVCLEWpvvi6NoccApheOjXRaoLPr0GMFB/anLine truncated
|
"contrast": "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 truncated
|
||||||
"default": "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 truncated
|
"default": "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 truncated
|
||||||
"light": "iVBORw0KGgoAAAANSUhEUgAABQwAAAXVCAIAAADPSQWyAAAQAElEQVR4nOz9S7rsqs4FCkr/txqblexAFrOahWyy7tkzImwekpB42DhC4+yzpg1CYASyhsGOf/+v//f/BwKBwG4g24kpQxKn9ERNBE07CX99WX0YK31thZe31Q3szGtn7w/kT1pdYpeEkU5a2r2oHvUoxAXtnajSNBOJEyOh7JFeH6Snn39fSfRyrId7PU7/xOiTd6S89XyO6e+oUPvKJTqFD+VvmaP4IQ1pytmkM4M/K8HPIHwfHkMCj5O/rHMS4X/JmfAn5S3zzv0bXXjoAHxL/KVhKvlRiOeQPFOSio7qzrZg1mbA8rKOKo4WaUBD2uM9aCDwMPz//3//36bMPwgEAoFAIBAIBAKBQCDwhyDJgcDPgyZIzK5xkpaOinCSnkDgA4qFoq9AuIFAIBD4HQRJDgQCa/GwyDICYRbB81h8V7eEkZdgE5dC6un0ujA7iKEVCAQeh/+DQCAQqDHnneH7QEF33aBmttir0dcNJC+jBgJLEOMrEAgEJiJIciAQCAR+HSSe7ICgP4EM3gFhl4+hFggEAi/EdutA4BdxRSS05pVhreh+8d3jIs6BXZGP31B5xQXErtMfR3DQHDEhAoHAtoiV5EDgN3F9sLZyA3dsrp4HanbnrF8h2xkLLuVJvTP4y2y79p7dT8y/gv3NT92Zk+qlRn4gEAhciVhJDgQCbzz49eP98AWBXXoJ5tUe8olvho0Xk2PJ7WsxzVcEmwwEAoF5iJXkQCCwENSV5dAaDPkSUH1e/yeLPwiknNnLzZMdLxaYgz27v6tVJBzvghjpgUBgA8RKciDwJFwUPKzcGT0TsT3vQjiXMgsjPHAV9H3B4nV/5drurIsiApzdO7GY7gBNkHCJOqwThgwEAk9ArCQHAr+NqbGUTK+pkd8B4dNdwZBvQ9d7zBvC19YYcIEWunyoLytgRPRhIBAwIkhyILA5VnxBZp7ODdac+Q2/gQVod7WW/fU8efIDp6sg78fYb1v4iOqn+Qdre6cw8Gs7h9wZc9SPqpnYe9Q4DwQC9yNIciCwH+64X15Z59jbyPT+C+nfwNWg5soxKa8yPwRm9hsInIjxkmAxIQ4EAoFViHeSA4GvwIIn8H6VLeY6Kyr6FYY89/pueguQ2PonXtqq68pfnCS2Iuqunrb6xnWm9mUbZHJ8Cumv7PTm0m1DWYB5Z073oCdjmrUsDLSlURf25M2ua1x8LrYbsoFAwIQgyYFAYBVmk9nfWXoYiYpbOs9wbULgxlNgXdRawN6ElTy5pZ6yz3tFOByYiut93u942SZiNgcCv40gyYHAY7Bt9GJq2CzGPJt57wVaf1knr6W5VBVc6mZyZgdVH6vFQpen6bsW01uz+OEF+DA+ryxvnJNVOPbx9+DP7sFcA4HARYh3kgOBnbEiiJqnk7TEWEZ2g669Rvqr8fXfvP36nYrmvK1M08cJGbS6qhxu30WDhKYwOVpgkrdih1pvAzbwNUGfO3Bhp2V77MNYgcB3IlaSA4Hnw3OPpuaJ845PlsRZjHk2894Fbyp2dwP+W6GZuQc7xYW/oUpL1prUvdfvhXSavD6vNAXmg9kNcO4ih946/8oSLmzxAZSzhvQG5uOCaXIVvuhSAoFAiiDJgcBmuPJDK23MYMwT8MVBa88PchlLdEVuqyK+tM1oF1WAuooFO8ktWg08mUY/iwXrzDRV8wi3/mJQR06fXE9x3N/dmocoFR+kMz+p+98Dwxi2gcDPI0hyIPBTIEeqTxOJOa10a03fw5Rn9HdvEbQUWBwgjlgyC3ob2cs/BO3IexIEbjF2edessC9SMt33XOQCZkKvvrULZeq34SXQoi0L6/amQCAQ2BdBkgOBnSBvhrYV6a5LTewHHf/MVvogkHLmLDwBhsiMkiXA7aK4ukO4/cHMyXi9n8XkllpTF28eH8/uR3rGN5fInTFzhl7i2a52n6hlfA1PjLXnQOALESQ5EHgAZsU1Mi/urMFO6rsqeBodfoOaCc7yk9HgmakcXrW4PADSePLKijSZtiyN0oTVBON8UgJpa8FbbdYlG44napyr83HJXKWB3BHNoqRhC7Z728od2JuTf80Tg0DgWxBftw4EfgfEHl5Q5yhDpmfuuaas5fYS14PaG/G37ngSn9csaPaThuA4sq6kMq1bWWAvjLMzFE900a/B/IfEgUDgXgRJDgT2xPyb6soNgywpuWbFYxP8MYj3Lyq1yS4J/92LR/PkEmsa+2GJrcVFW+2jnPMOnG3ua/bTnnbZrWnXdyUwr9rw2jBv2s6vAKJBxlwLCqWuRLDdQOB3ECQ5ENgGHbffsTi1H2sjBZLOtgxQyLhcvg8ZVmDgyfs2v7lvdkktkoyLJ2+NVf7iq/gGVQfTsWYBFpVTc41DTUNDigNDBiD1NBAI/BSCJAcCAQZWJjCuxaZhS3LmaNSDgi1+iZseQ5Uz2Nnqmrq/EsQcOcsn8/peTHywspe9sZWJshjyx1bqyhVBTZYaQrsiKHQg8N0Y+3DXlR4ivmcQ+FUsnWckntwFJny+vF3+CsmV/DwQ890m2vnb18tb1rr6S3pnRxNY8eS2rwQN5KpAS2lq6iiEWvu3/8yMjPCg8ZvsXhTaGTEpAoGd8O8xQZzeznArgadj8UzsUu8pJO847qm6nyGTctaA14382DoCMez4Ok7ajwVxJ230e0bXNEOuxVo/J7dTP/6HZ87ok/zKNBiP1+g1mf9+ZdjSCRM2V3tUPCC+6x3EQYoDgZ3xLT8BtTQUDgQCV8K9FZOMaSY1KGc5NX0f0n22737a1Z1uEX1+bwj8Q8H9zMns19VkrTjf3djZMl+4OmwPFbxsEXjeyGU0BecNBL4KP/k7yc1bTiBwM4g50qR8OmeKTtZhu/CmlqHC//3T5wa+lRuzeD9SsO68vAFJxBrB667YbTH5XsxivB89eKwgq5ob1dpbxW6ndpqWKccRb5OKrRCjPBB4Gn6SJOvovkWF+wvsiieRtzF+O+tKf4ruBjowOeIdUqdsgVgIbvt9YAgSG8XExmQobhE7l4ubtTY12sB/xNo7cmKkBQKBqxAkeR4irA5cie7xJr3uu9MAjsn0FBxb43HnBcFFDZuk9mc4pnaht3TCc/0MQ1r1l41bMmc+vl+mYPSDXBzLUywOVPEs0bwEvWKH9hbvaAQCgT0QPwEVCOyLTW+XGzaLZi4jB74GvzQk6M7LjbnnxBwyhgO5iix+/kGG/fL/5TKoV2Pmzajmpuk7PGaiCRKBQGAjzFxJXjT9Y3NNIHAl5k7kS6ICvLrCQInw08O4Z9P0ELLl3+614J9ZSXeDXfjNE2sRbO6ervdRJ3mYfNIBu/xpymwxXR6Wia7P/jZpFIR3G2sx/AOBbfFv/4iy44MRgUCgiS3nPna269wKeKUnCEIe2ApxGxzAHbOZZ8Fo+/QgV7j+UleZkuymLhRkrzQfxcHzpWvkz4pl6nM5WWKwyJTFUm5PdhmcNxD4HrhWku+NCBt+R/9MRSDwOJA749eBV/dNrGA/Bb9wE4gb3R6Y7YYymppQ3FQCqbGeXIrUHDhbaIa0mrcMtK4Lz8KfI/OYzFacyyz++KhAFBhATKZAIPBHkp8S2zXbiX0lwxkGdgOZkjZHd6iIN18t6xFIcxSUFQu2/CPY59bx/JvYH0nbcA3ONZnREGxoG6Fb+6h7WiDrRO7TXAeTfh8mfBkOqixXnhxhuQL8WT7GUyRLkdUxafo42Xo+1AYKBAK74pu+bt3pe6ijTCCwDNM51rafsJbRy5M/8Rb10nOUsxhQmZmw5R+hyqicBX4OIzT3Xor8nPmKxQeolWVkdcc1z5hzngxZx1C9cwazOpNiRSqW0lV76z8F61bKCVIoKNzPTW34eCgQCMBX/wRUz/5r468bBAIrcUe8tqxON99N47/uSq+ZsUUtxISMdPzzhXiAX3zvAF3U0rgxfEd8P2WCdvgsrgi7wOtziek26Sz5P/6L0ke7MHvql8lwz/2k5V+RISeH+TIyimyW9eQ1hS7LwD0YmwnBkwOBDfGbv5PMfCnDXiAcWeALsD1v4+KyzSGGkqhexrMoNLIJ4RU5XNArl3X8JRVdTxSun3wIzbeH1T3Z+Vm9AlwvLL849nsx+ZP94cfpWSaf82T4jyq3baMxZNejqkr45NXAcWlo6rPJXY8gx4HAxvhNklzDwYKl+1cgMA5ypF7YgItxRmmPpcpFmragnG/W3hGoJO/s+pY2MHz+EnwLZ2CYL3qmOMpf6mrS7DSNo9YoLBScK8Z1pblZiEuEWphjsdIysshjsSyIXC5TywDs9DsQCHwxgiTX6GHBwZwD41jBkMdK45iCgeJZlIaj13E76tiuuiDa6DJRz3mEZ1vc0jWa454BCTn8DYiUtV20oNJMBkeV+cXkWgrkp3uoNqk8SqYiZkc8xcX6APSarDK+3C70POT5lIlF5UBgN0wiyYtCu40cRs99u9kr4RADB25fQx7DAoKXRWnP58kFqnWZlNTRlheLz3FYOHFRiVU/RyYX3bd7v/ROdfckw+r1YHbh9zg6/snWpVHccV3ofO24flPj1+5rlicf68nQoMr15VRHAkP+tPfMyjmzxLQLhVozkE+vWTkiLy8mBgKBn8G/rSNP46PU60BzG/FdUX/guUB1bJNDfEBUUYA/NlmOr3TDDRfOceGH0mNYueJ7f598dQj/zPVkluu6/Re2XktGxx7stEkfnox/1JjOptbryQWDh8aXFVLJ9BTZXHaj9eu4WkY+i2KhozgpdaHQpB4EWw4EfhLP3249mbeONCL8aGAqvoAXTqG4byVv3viFbFnsoDMO/ORPv3psnMP+jo1fwVrWbM8ysr0NP7GM/J23SJd/yxaBQXsIyS0jlzKpwtcBu5h8/gRUXs0hLPJkKKmy4zplQpt8z/oUZjkzGCZ1TYbvHmcUCyeBwHfge99JVpzNKg8qPfsNBH4AfCAGzhhSUv73L72Line truncated
|
"light": "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 truncated
|
||||||
"video-modal-mobile": "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 truncated
|
"video-modal-mobile": "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 truncated
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
# TODO: innerHTML Migration for ShareNostrModal.js
|
# TODO: innerHTML Migration for ShareNostrModal.js
|
||||||
|
|
||||||
- [ ] L107: `wrapper.innerHTML = html;` (Template loading)
|
- [x] L107: `wrapper.innerHTML = html;` (Template loading)
|
||||||
- Strategy: Use `new DOMParser().parseFromString(html, 'text/html')` and append children to `wrapper`.
|
- Strategy: Use `new DOMParser().parseFromString(html, 'text/html')` and append children to `wrapper`.
|
||||||
- [ ] L274: `this.relayPills.innerHTML = "";` (Clearing content)
|
- [x] L274: `this.relayPills.innerHTML = "";` (Clearing content)
|
||||||
- Strategy: `this.relayPills.replaceChildren();` or `textContent = ""` (replaceChildren is preferred for performance).
|
- Strategy: `this.relayPills.replaceChildren();` or `textContent = ""` (replaceChildren is preferred for performance).
|
||||||
- [ ] L300: `removeButton.innerHTML = '<svg ...>...</svg>';` (SVG Icon)
|
- [x] L300: `removeButton.innerHTML = '<svg ...>...</svg>';` (SVG Icon)
|
||||||
- Strategy: Create SVG elements using `document.createElementNS("http://www.w3.org/2000/svg", "svg")` and children.
|
- Strategy: Create SVG elements using `document.createElementNS("http://www.w3.org/2000/svg", "svg")` and children.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**CLOSED 2026-07-30.** All three migrations already landed in later refactors —
|
||||||
|
`grep innerHTML js/ui/components/ShareNostrModal.js` returns nothing and the
|
||||||
|
innerHTML lint baseline holds. Kept for the record.
|
||||||
@@ -103,13 +103,31 @@ Likely a shared root cause in session/identity persistence + multi-account handl
|
|||||||
`SignerManager.logout` (previousPubkey/hadRemoteClient), and
|
`SignerManager.logout` (previousPubkey/hadRemoteClient), and
|
||||||
`authService.switchProfile` (from/to + whether requestLogin resolved the expected
|
`authService.switchProfile` (from/to + whether requestLogin resolved the expected
|
||||||
pubkey). **Remove these once the fix lands.**
|
pubkey). **Remove these once the fix lands.**
|
||||||
|
- [x] **CODE-LEVEL SUSPECTS RE-AUDITED + PINNED (2026-07-30).** Every root cause the
|
||||||
|
2026-06-25 audit named has since been fixed by later work, verified end-to-end
|
||||||
|
by reading the current code:
|
||||||
|
- Per-account NIP-46 session storage exists (`bitvid:nip46:sessions:v2` map,
|
||||||
|
v1 slot = last-connected default, migration on read).
|
||||||
|
- `logout()` → `disconnectRemoteSigner({ keepStored: true })` — stored
|
||||||
|
sessions survive; the no-arg `clearStoredNip46Session()` clears ONLY the
|
||||||
|
v1 default slot, never the map (the wipe-all was fixed earlier).
|
||||||
|
- Switch is fully wired: coordinator sets `reuseStored: true` for nip46
|
||||||
|
targets → `switchProfile` passes `expectPubkey` → provider forwards it →
|
||||||
|
`useStoredRemoteSigner({ pubkey })` restores the TARGET account's session,
|
||||||
|
and forgets only the attempted account on access-denial.
|
||||||
|
- Boot restore (`scheduleStoredRemoteSignerRestore`) enforces the
|
||||||
|
access-control validator and fails scoped (nothing else cleared).
|
||||||
|
NEW: `tests/nip46-signer-lifecycle.test.mjs` pins the manager-level contract
|
||||||
|
(logout scoping, restore-failure scoping, bare-disconnect scoping) — mutation-
|
||||||
|
checked: reinjecting the historical keepStored/wipe-all bugs fails 2/6.
|
||||||
- [ ] **VERIFY / REPRODUCE (user action):** on the phone with Amber on
|
- [ ] **VERIFY / REPRODUCE (user action):** on the phone with Amber on
|
||||||
unstable.bitvid.network, exercise (1) fresh load, (2) switch profiles, (3) logout,
|
unstable.bitvid.network, exercise (1) fresh load, (2) switch profiles, (3) logout,
|
||||||
and capture the `[nip46-diag]` console lines for each — restore succeeded/FAILED,
|
and capture the `[nip46-diag]` console lines for each — restore succeeded/FAILED,
|
||||||
switchProfile `matchesTarget`, and disconnect `keepStored`/`hadClient`/caller stack.
|
switchProfile `matchesTarget`, and disconnect `keepStored`/`hadClient`/caller stack.
|
||||||
- [ ] **THEN fix** based on the captured trace (likely: per-profile NIP-46 session keyed
|
If all three behave, close this item and REMOVE the `[nip46-diag]` diagnostics;
|
||||||
by pubkey; don't tear the shared client on a scoped logout; reconnect on
|
if not, the trace now points at whatever the unit contract does NOT cover
|
||||||
switch/restore) and REMOVE the temporary `[nip46-diag]` diagnostics.
|
(likely UI/state reconciliation, e.g. restore-failure leaving "unknown user"
|
||||||
|
rendered instead of a reconnect prompt).
|
||||||
|
|
||||||
### 1. Delete is not fully working — tombstoned videos still show in the UI
|
### 1. Delete is not fully working — tombstoned videos still show in the UI
|
||||||
- [x] **Root cause found + fixed** (`afb6200b`): deletes published only to the CAPPED
|
- [x] **Root cause found + fixed** (`afb6200b`): deletes published only to the CAPPED
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Static asset caching on Vercel — 2026-07-31
|
||||||
|
|
||||||
|
Carried over from the BitUnlock/BitLogin deployment work. Findings below were
|
||||||
|
verified against the live site on 2026-07-31, not assumed.
|
||||||
|
|
||||||
|
## What is actually happening
|
||||||
|
|
||||||
|
`vercel.json` declares exactly three header rules — `/sw.min.js`, `/`, and
|
||||||
|
`/(.*\.html)`. Nothing covers `/css/*`, `/js/*`, `/vendor/*`, or `/assets/*`,
|
||||||
|
so those fall through to Vercel's default. Measured live:
|
||||||
|
|
||||||
|
| Asset | Cache-Control |
|
||||||
|
| ---------------------------- | ------------------------------------------ |
|
||||||
|
| `css/tailwind.generated.css` | `public, max-age=0, must-revalidate` |
|
||||||
|
| `js/app.js` | `public, max-age=0, must-revalidate` |
|
||||||
|
| `index.html` | `no-cache, must-revalidate` |
|
||||||
|
|
||||||
|
Every CSS and JS file revalidates on **every** page load. The 304s are cheap
|
||||||
|
but they are still round-trips, and they are on the critical render path.
|
||||||
|
|
||||||
|
## The trap: do NOT just add `immutable`
|
||||||
|
|
||||||
|
The obvious fix — port the old `immutable` + 1-year rules across — would be a
|
||||||
|
**regression**, not an improvement. Asset URLs in `index.html` are not
|
||||||
|
content-hashed:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link href="css/tailwind.generated.css" />
|
||||||
|
<script src="js/index.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
A long `max-age` with `immutable` on an unhashed filename tells every browser
|
||||||
|
to keep that exact file for a year and never revalidate. The next deploy
|
||||||
|
changes the file's contents but not its URL, so returning users are pinned to
|
||||||
|
stale CSS/JS with no way to bust it short of a hard reload — and `immutable`
|
||||||
|
specifically instructs the browser to skip revalidation even on reload in some
|
||||||
|
engines. `scripts/hash-dist.mjs` exists but writes a hash *manifest* for
|
||||||
|
deploy verification; it does not rename assets or rewrite references.
|
||||||
|
|
||||||
|
## Two ways forward
|
||||||
|
|
||||||
|
- [ ] **Option A (correct, more work): content-hash the asset filenames.**
|
||||||
|
Emit `css/tailwind.<hash>.css` / `js/index.<hash>.js` from the build and
|
||||||
|
rewrite references in the emitted HTML. Only then add
|
||||||
|
`public, max-age=31536000, immutable` for `/css/*`, `/js/*`, `/assets/*`.
|
||||||
|
This is the only combination where `immutable` is safe, because the URL
|
||||||
|
changes whenever the bytes do.
|
||||||
|
- [ ] **Option B (safe now, smaller win): a bounded max-age, no `immutable`.**
|
||||||
|
e.g. `public, max-age=600, stale-while-revalidate=86400` for `/css/*` and
|
||||||
|
`/js/*`. Repeat views inside the window skip the round-trip entirely,
|
||||||
|
a deploy is picked up within ten minutes, and nothing can pin a user to a
|
||||||
|
stale bundle. Choose this if hashing is not happening soon.
|
||||||
|
|
||||||
|
**DECIDED 2026-07-31: Option A.** Content-hash the filenames, then apply
|
||||||
|
`immutable`. Do not ship Option B as an interim step — the two are not
|
||||||
|
stackable, and a bounded `max-age` shipped now becomes the thing nobody
|
||||||
|
revisits.
|
||||||
|
|
||||||
|
## Also worth doing
|
||||||
|
|
||||||
|
- [ ] Cover `/vendor/*` in whichever option is chosen — those files
|
||||||
|
(`marked.min.js`, `highlight.min.js`) are third-party and genuinely
|
||||||
|
change rarely, so they benefit most.
|
||||||
|
- [ ] Confirm `/sw.min.js` keeps a short/no-cache policy under any change. A
|
||||||
|
cached service worker is the one asset that can persist a bad deploy
|
||||||
|
past a user's next visit.
|
||||||
|
- [ ] Re-measure with `curl -sI` after deploying. Vercel's dashboard settings
|
||||||
|
can override `vercel.json`, so the header on the wire is the only
|
||||||
|
source of truth.
|
||||||
|
|
||||||
|
## Corrected note
|
||||||
|
|
||||||
|
An earlier hand-off claimed bitvid had "~10 pre-existing test failures". That
|
||||||
|
is **not true** as of 2026-07-31 — `npm run test:unit` (13/13),
|
||||||
|
`npm run test:dm:unit` (2/2 and 4/4), and `npm run test:smoke` all pass on
|
||||||
|
`unstable`. Do not spend time chasing failures that are not there.
|
||||||
+23
-12
@@ -1,26 +1,37 @@
|
|||||||
{
|
{
|
||||||
"package": "@bitlogin/widget",
|
"package": "@bitlogin/widget",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"files": {
|
"source": {
|
||||||
"bitlogin-shared-B2Rc9khL.js": {
|
"repository": "https://github.com/PR0M3TH3AN/bitlogin.git",
|
||||||
"sha256": "6a9df1b27d73499d3bd488182b3134e0173c6b0bf1b5537cddc0e9ec4073d8e7",
|
"commit": "205b0678cc67ecbde18278b8f46e7f8270fb519c",
|
||||||
"bytes": 288486
|
"commitDate": "2026-07-31T22:04:01-04:00",
|
||||||
|
"dirty": false,
|
||||||
|
"provenance": "attributed",
|
||||||
|
"toolchain": {
|
||||||
|
"node": "v22.23.1",
|
||||||
|
"vite": "^6.1.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"bitlogin-shared-DxcpMAo3.js": {
|
"files": {
|
||||||
"sha256": "b6cb73f3544a8c378416c50740a91eaea700c4af3e4f6b131bb3694e7c816758",
|
"bitlogin-shared--Zs4uvXt.js": {
|
||||||
|
"sha256": "550434b2ed3df975fc1d8896dca02f8622f2819d1fd9fa288049f2c22baa6bc4",
|
||||||
"bytes": 483718
|
"bytes": 483718
|
||||||
},
|
},
|
||||||
"bitlogin-shared-IsyHQNSG.js": {
|
"bitlogin-shared-BxVSgyOf.js": {
|
||||||
"sha256": "bd6d38087df246792cd61b82323f2997ecf478d3abcc3885235f45a925614131",
|
"sha256": "d7f157c2de3d4b81de0055df2582306df9d862706d1dad35ff4096d06e1c1c93",
|
||||||
"bytes": 545712
|
"bytes": 545712
|
||||||
},
|
},
|
||||||
|
"bitlogin-shared-DTz11m56.js": {
|
||||||
|
"sha256": "c52f485c6390658e8da75d7c31f01167eb4f8c22d836f9d92e22c2b0885bf79e",
|
||||||
|
"bytes": 289118
|
||||||
|
},
|
||||||
"bitlogin.js": {
|
"bitlogin.js": {
|
||||||
"sha256": "0d118d599efd30d4c0d0c197d463fd64900e803aa1a84c7880e1780bed9f3aba",
|
"sha256": "56eeb7584b0117e2e360c80d37a5bfddb2e1f7a8ee6d94995c24043c0e87b906",
|
||||||
"bytes": 85342
|
"bytes": 77303
|
||||||
},
|
},
|
||||||
"cryptoWorker.js": {
|
"cryptoWorker.js": {
|
||||||
"sha256": "4210e0d6e7c5fc33e4df2dd3eedbe9434439600d0e60fcb3cad90699c198d452",
|
"sha256": "4d569dac3b808d118bf432d628f084c8c1d8ba76d94cf101030ace1077c63b36",
|
||||||
"bytes": 104078
|
"bytes": 110827
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { a8 as Ye } from "./bitlogin-shared-B2Rc9khL.js";
|
import { a9 as Ye } from "./bitlogin-shared-DTz11m56.js";
|
||||||
function We(we, je) {
|
function We(we, je) {
|
||||||
for (var Ie = 0; Ie < je.length; Ie++) {
|
for (var Ie = 0; Ie < je.length; Ie++) {
|
||||||
const Ae = je[Ie];
|
const Ae = je[Ie];
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
import { a8 as D2 } from "./bitlogin-shared-B2Rc9khL.js";
|
import { a9 as D2 } from "./bitlogin-shared-DTz11m56.js";
|
||||||
const wl = {
|
const wl = {
|
||||||
debug() {
|
debug() {
|
||||||
}
|
}
|
||||||
@@ -7918,7 +7918,7 @@ let ke;
|
|||||||
async function l2() {
|
async function l2() {
|
||||||
try {
|
try {
|
||||||
if (ke) return ke;
|
if (ke) return ke;
|
||||||
const e = (await import("./bitlogin-shared-DxcpMAo3.js").then((t) => t.i)).default;
|
const e = (await import("./bitlogin-shared--Zs4uvXt.js").then((t) => t.i)).default;
|
||||||
return ke = new e(), ke;
|
return ke = new e(), ke;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw console.error(e), new Error("LNC is not available");
|
throw console.error(e), new Error("LNC is not available");
|
||||||
+266
-256
File diff suppressed because it is too large.
Load diff
Vendored
+198
-351
@@ -1,116 +1,57 @@
|
|||||||
import { r as w, e as k, E as Q, p as x, R as C, i as p, a as d } from "./bitlogin-shared-B2Rc9khL.js";
|
import { r as g, e as v, E as m, p as w, R as Q, i as p, a as u } from "./bitlogin-shared-DTz11m56.js";
|
||||||
const S = 6, f = 64;
|
const k = 6, b = 64;
|
||||||
function u(r = S) {
|
function d(a = k) {
|
||||||
const e = k(), t = [];
|
const e = v(), t = [];
|
||||||
for (let s = 0; s < r; s++)
|
for (let r = 0; r < a; r++)
|
||||||
t.push(e[w(e.length)]);
|
t.push(e[g(e.length)]);
|
||||||
const a = r * Q;
|
const s = a * m;
|
||||||
if (a < f)
|
if (s < b)
|
||||||
throw new Error(`Passphrase of ${r} words provides only ~${a.toFixed(1)} bits; must exceed ${f}.`);
|
throw new Error(`Passphrase of ${a} words provides only ~${s.toFixed(1)} bits; must exceed ${b}.`);
|
||||||
return { kind: "passphrase", secret: t.join(" "), entropyBits: a };
|
return { kind: "passphrase", secret: t.join(" "), entropyBits: s };
|
||||||
}
|
}
|
||||||
const b = 64, m = 12, P = /* @__PURE__ */ new Set([
|
const x = 6e4;
|
||||||
"password",
|
class S {
|
||||||
"password1",
|
|
||||||
"password123",
|
|
||||||
"123456",
|
|
||||||
"123456789",
|
|
||||||
"12345678",
|
|
||||||
"qwerty",
|
|
||||||
"qwerty123",
|
|
||||||
"letmein",
|
|
||||||
"welcome",
|
|
||||||
"monkey",
|
|
||||||
"dragon",
|
|
||||||
"master",
|
|
||||||
"abc123",
|
|
||||||
"iloveyou",
|
|
||||||
"admin",
|
|
||||||
"login",
|
|
||||||
"starwars",
|
|
||||||
"sunshine",
|
|
||||||
"princess",
|
|
||||||
"football",
|
|
||||||
"baseball",
|
|
||||||
"trustno1",
|
|
||||||
"000000",
|
|
||||||
"111111",
|
|
||||||
"123123",
|
|
||||||
"1234567890",
|
|
||||||
"changeme",
|
|
||||||
"passw0rd",
|
|
||||||
"correcthorsebatterystaple"
|
|
||||||
]);
|
|
||||||
function L(r) {
|
|
||||||
let e = 0;
|
|
||||||
return /[a-z]/u.test(r) && (e += 26), /[A-Z]/u.test(r) && (e += 26), /[0-9]/u.test(r) && (e += 10), /[^a-zA-Z0-9]/u.test(r) && (e += 33), e || 1;
|
|
||||||
}
|
|
||||||
function E(r) {
|
|
||||||
return r.length * Math.log2(L(r));
|
|
||||||
}
|
|
||||||
function $(r) {
|
|
||||||
if (/^(.)\1*$/u.test(r) || /(.)\1{2,}/u.test(r))
|
|
||||||
return !0;
|
|
||||||
const e = r.toLowerCase(), t = ["0123456789", "abcdefghijklmnopqrstuvwxyz"];
|
|
||||||
for (const a of t)
|
|
||||||
for (let s = 0; s + 4 <= a.length; s++) {
|
|
||||||
const n = a.slice(s, s + 4), o = [...n].reverse().join("");
|
|
||||||
if (e.includes(n) || e.includes(o))
|
|
||||||
return !0;
|
|
||||||
}
|
|
||||||
return !1;
|
|
||||||
}
|
|
||||||
function g(r, e) {
|
|
||||||
const t = E(r);
|
|
||||||
return r.length < m ? { ok: !1, entropyBits: t, reason: `Must be at least ${m} characters.` } : P.has(r.toLowerCase()) ? { ok: !1, entropyBits: t, reason: "This is on a list of extremely common passwords." } : e.length >= 3 && r.toLowerCase().includes(e) ? { ok: !1, entropyBits: t, reason: "Must not contain your login name." } : $(r) ? { ok: !1, entropyBits: t, reason: "Too predictable (repeated characters or a simple sequence)." } : t < b ? {
|
|
||||||
ok: !1,
|
|
||||||
entropyBits: t,
|
|
||||||
reason: `Estimated entropy (~${t.toFixed(0)} bits) is below the required ${b}. Use a longer password or mix character types.`
|
|
||||||
} : { ok: !0, entropyBits: t };
|
|
||||||
}
|
|
||||||
const R = 6e4;
|
|
||||||
class N {
|
|
||||||
worker;
|
worker;
|
||||||
pending = /* @__PURE__ */ new Map();
|
pending = /* @__PURE__ */ new Map();
|
||||||
counter = 0;
|
counter = 0;
|
||||||
dead = !1;
|
dead = !1;
|
||||||
constructor() {
|
constructor() {
|
||||||
const e = ["cryptoWorker", ".js"].join(""), t = new URL(e, import.meta.url);
|
const e = ["cryptoWorker", ".js"].join(""), t = new URL(e, import.meta.url);
|
||||||
this.worker = new Worker(t, { type: "module" }), this.worker.addEventListener("message", (s) => {
|
this.worker = new Worker(t, { type: "module" }), this.worker.addEventListener("message", (r) => {
|
||||||
const n = s.data;
|
const i = r.data;
|
||||||
if (!n || typeof n != "object" || typeof n.id != "string") return;
|
if (!i || typeof i != "object" || typeof i.id != "string") return;
|
||||||
const o = this.pending.get(n.id);
|
const o = this.pending.get(i.id);
|
||||||
if (o)
|
if (o)
|
||||||
if (this.pending.delete(n.id), clearTimeout(o.timer), n.ok)
|
if (this.pending.delete(i.id), clearTimeout(o.timer), i.ok)
|
||||||
o.resolve(n.result);
|
o.resolve(i.result);
|
||||||
else {
|
else {
|
||||||
const l = new Error(n.error);
|
const l = new Error(i.error);
|
||||||
l.name = n.errorName ?? "Error", o.reject(l);
|
l.name = i.errorName ?? "Error", o.reject(l);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const a = (s) => () => this.failAll(s);
|
const s = (r) => () => this.failAll(r);
|
||||||
this.worker.addEventListener("error", a("The BitLogin crypto worker stopped unexpectedly.")), this.worker.addEventListener(
|
this.worker.addEventListener("error", s("The BitLogin crypto worker stopped unexpectedly.")), this.worker.addEventListener(
|
||||||
"messageerror",
|
"messageerror",
|
||||||
a("The BitLogin crypto worker sent an unreadable message.")
|
s("The BitLogin crypto worker sent an unreadable message.")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
/** Settles every outstanding call with an error. Idempotent. */
|
/** Settles every outstanding call with an error. Idempotent. */
|
||||||
failAll(e) {
|
failAll(e) {
|
||||||
const t = [...this.pending.values()];
|
const t = [...this.pending.values()];
|
||||||
this.pending.clear();
|
this.pending.clear();
|
||||||
for (const a of t)
|
for (const s of t)
|
||||||
clearTimeout(a.timer), a.reject(new Error(e));
|
clearTimeout(s.timer), s.reject(new Error(e));
|
||||||
}
|
}
|
||||||
call(e, t) {
|
call(e, t) {
|
||||||
if (this.dead)
|
if (this.dead)
|
||||||
return Promise.reject(new Error("This BitLogin session was torn down; reload the page to sign in again."));
|
return Promise.reject(new Error("This BitLogin session was torn down; reload the page to sign in again."));
|
||||||
const a = `${Date.now().toString(36)}-${(this.counter++).toString(36)}`;
|
const s = `${Date.now().toString(36)}-${(this.counter++).toString(36)}`;
|
||||||
return new Promise((s, n) => {
|
return new Promise((r, i) => {
|
||||||
const o = setTimeout(() => {
|
const o = setTimeout(() => {
|
||||||
this.pending.delete(a), n(new Error(`BitLogin's ${e} call timed out.`));
|
this.pending.delete(s), i(new Error(`BitLogin's ${e} call timed out.`));
|
||||||
}, R);
|
}, x);
|
||||||
this.pending.set(a, { resolve: s, reject: n, timer: o });
|
this.pending.set(s, { resolve: r, reject: i, timer: o });
|
||||||
const l = { id: a, action: e, payload: t };
|
const l = { id: s, action: e, payload: t };
|
||||||
this.worker.postMessage(l);
|
this.worker.postMessage(l);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -202,57 +143,57 @@ class N {
|
|||||||
this.dead = !0, this.worker.terminate(), this.failAll("The BitLogin crypto worker was terminated.");
|
this.dead = !0, this.worker.terminate(), this.failAll("The BitLogin crypto worker was terminated.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const M = "BitLogin: no identity is unlocked yet. Add <bitlogin-auth> to the page and let the user sign in, or call it programmatically before invoking window.nostr.";
|
const L = "BitLogin: no identity is unlocked yet. Add <bitlogin-auth> to the page and let the user sign in, or call it programmatically before invoking window.nostr.";
|
||||||
function c(r) {
|
function c(a) {
|
||||||
return r.catch((e) => {
|
return a.catch((e) => {
|
||||||
throw e.message.includes("No identity is unlocked") ? new Error(M) : e;
|
throw e.message.includes("No identity is unlocked") ? new Error(L) : e;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function B(r, e) {
|
function C(a, e) {
|
||||||
return {
|
return {
|
||||||
async getPublicKey() {
|
async getPublicKey() {
|
||||||
const { publicKey: t } = await c(r.getPublicKey());
|
const { publicKey: t } = await c(a.getPublicKey());
|
||||||
return t;
|
return t;
|
||||||
},
|
},
|
||||||
async signEvent(t) {
|
async signEvent(t) {
|
||||||
return c(r.signEvent(t));
|
return c(a.signEvent(t));
|
||||||
},
|
},
|
||||||
async getRelays() {
|
async getRelays() {
|
||||||
const t = {};
|
const t = {};
|
||||||
for (const a of e()) t[a] = { read: !0, write: !0 };
|
for (const s of e()) t[s] = { read: !0, write: !0 };
|
||||||
return t;
|
return t;
|
||||||
},
|
},
|
||||||
nip44: {
|
nip44: {
|
||||||
async encrypt(t, a) {
|
async encrypt(t, s) {
|
||||||
const { ciphertext: s } = await c(r.nip44Encrypt({ peerPublicKey: t, plaintext: a }));
|
const { ciphertext: r } = await c(a.nip44Encrypt({ peerPublicKey: t, plaintext: s }));
|
||||||
return s;
|
return r;
|
||||||
},
|
},
|
||||||
async decrypt(t, a) {
|
async decrypt(t, s) {
|
||||||
const { plaintext: s } = await c(r.nip44Decrypt({ peerPublicKey: t, payload: a }));
|
const { plaintext: r } = await c(a.nip44Decrypt({ peerPublicKey: t, payload: s }));
|
||||||
return s;
|
return r;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
nip04: {
|
nip04: {
|
||||||
async encrypt(t, a) {
|
async encrypt(t, s) {
|
||||||
const { ciphertext: s } = await c(r.nip04Encrypt({ peerPublicKey: t, plaintext: a }));
|
const { ciphertext: r } = await c(a.nip04Encrypt({ peerPublicKey: t, plaintext: s }));
|
||||||
return s;
|
return r;
|
||||||
},
|
},
|
||||||
async decrypt(t, a) {
|
async decrypt(t, s) {
|
||||||
const { plaintext: s } = await c(r.nip04Decrypt({ peerPublicKey: t, payload: a }));
|
const { plaintext: r } = await c(a.nip04Decrypt({ peerPublicKey: t, payload: s }));
|
||||||
return s;
|
return r;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
_bitlogin: !0
|
_bitlogin: !0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function T(r) {
|
function E(a) {
|
||||||
const e = r.getAttribute("vault-relays"), t = r.getAttribute("discovery-relays");
|
const e = a.getAttribute("vault-relays"), t = a.getAttribute("discovery-relays");
|
||||||
return {
|
return {
|
||||||
vaultRelayUrls: e ? e.split(",").map((a) => a.trim()).filter(Boolean) : void 0,
|
vaultRelayUrls: e ? e.split(",").map((s) => s.trim()).filter(Boolean) : void 0,
|
||||||
discoveryRelayUrls: t ? t.split(",").map((a) => a.trim()).filter(Boolean) : void 0
|
discoveryRelayUrls: t ? t.split(",").map((s) => s.trim()).filter(Boolean) : void 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const q = (
|
const R = (
|
||||||
/* css */
|
/* css */
|
||||||
`
|
`
|
||||||
:host {
|
:host {
|
||||||
@@ -493,16 +434,16 @@ button.link {
|
|||||||
}
|
}
|
||||||
@keyframes bl-spin { to { transform: rotate(360deg); } }
|
@keyframes bl-spin { to { transform: rotate(360deg); } }
|
||||||
`
|
`
|
||||||
), y = "bc:config", U = /^nostr\+walletconnect:\/\//iu;
|
), y = "bc:config", $ = /^nostr\+walletconnect:\/\//iu;
|
||||||
let h = null;
|
let h = null;
|
||||||
function F(r) {
|
function N(a) {
|
||||||
if (!h) {
|
if (!h) {
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(y);
|
localStorage.removeItem(y);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
h = import("./bitlogin-shared-IsyHQNSG.js").then((e) => (e.init({
|
h = import("./bitlogin-shared-BxVSgyOf.js").then((e) => (e.init({
|
||||||
appName: r,
|
appName: a,
|
||||||
filters: ["nwc"],
|
filters: ["nwc"],
|
||||||
showBalance: !1,
|
showBalance: !1,
|
||||||
autoConnect: !1,
|
autoConnect: !1,
|
||||||
@@ -510,7 +451,7 @@ function F(r) {
|
|||||||
providerConfig: {
|
providerConfig: {
|
||||||
nwc: {
|
nwc: {
|
||||||
authorizationUrlOptions: {
|
authorizationUrlOptions: {
|
||||||
name: r,
|
name: a,
|
||||||
requestMethods: ["pay_invoice"]
|
requestMethods: ["pay_invoice"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -521,19 +462,19 @@ function F(r) {
|
|||||||
}
|
}
|
||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
function I(r) {
|
function P(a) {
|
||||||
const e = r instanceof Error ? r.message : String(r);
|
const e = a instanceof Error ? a.message : String(a);
|
||||||
return /closed|cancell?ed|dismissed|aborted/iu.test(e);
|
return /closed|cancell?ed|dismissed|aborted/iu.test(e);
|
||||||
}
|
}
|
||||||
async function O(r) {
|
async function B(a) {
|
||||||
const e = await F(r);
|
const e = await N(a);
|
||||||
try {
|
try {
|
||||||
const t = await e.requestProvider(), a = e.getConnectorConfig()?.nwcUrl, s = t.client?.nostrWalletConnectUrl, n = typeof a == "string" ? a : typeof s == "string" ? s : "";
|
const t = await e.requestProvider(), s = e.getConnectorConfig()?.nwcUrl, r = t.client?.nostrWalletConnectUrl, i = typeof s == "string" ? s : typeof r == "string" ? r : "";
|
||||||
if (!U.test(n.trim()))
|
if (!$.test(i.trim()))
|
||||||
throw new Error("The wallet did not return an NWC connection. Try again or paste one manually.");
|
throw new Error("The wallet did not return an NWC connection. Try again or paste one manually.");
|
||||||
return n.trim();
|
return i.trim();
|
||||||
} catch (t) {
|
} catch (t) {
|
||||||
if (I(t)) return null;
|
if (P(t)) return null;
|
||||||
throw t;
|
throw t;
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
@@ -546,7 +487,19 @@ async function O(r) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class D extends HTMLElement {
|
function T(a) {
|
||||||
|
const e = [];
|
||||||
|
return a.rollbackWarnings.length > 0 && e.push(
|
||||||
|
`${a.rollbackWarnings.length} wallet record${a.rollbackWarnings.length === 1 ? " was" : "s were"} withheld because relays served an older version than this device previously accepted.`
|
||||||
|
), a.quorumMet || e.push(
|
||||||
|
"Not enough vault relays answered. This connection list may be incomplete."
|
||||||
|
), a.truncated && e.push(
|
||||||
|
"A relay returned the maximum record page. Some wallet connections may be missing."
|
||||||
|
), a.unreadable.length > 0 && e.push(
|
||||||
|
`${a.unreadable.length} encrypted wallet record${a.unreadable.length === 1 ? " was" : "s were"} unreadable and omitted.`
|
||||||
|
), e;
|
||||||
|
}
|
||||||
|
class I extends HTMLElement {
|
||||||
root;
|
root;
|
||||||
worker;
|
worker;
|
||||||
vaultRelayUrls = [];
|
vaultRelayUrls = [];
|
||||||
@@ -560,16 +513,6 @@ class D extends HTMLElement {
|
|||||||
savedCheckbox = !1;
|
savedCheckbox = !1;
|
||||||
recoveryPhrase = "";
|
recoveryPhrase = "";
|
||||||
confirmSlots = [];
|
confirmSlots = [];
|
||||||
// Manual password opt-in (§9.3). Off by default; the generated passphrase remains the
|
|
||||||
// recommendation. manualPasswordFeedback is updated on every keystroke WITHOUT a full
|
|
||||||
// re-render (see onInput) so the field never loses focus while the user is typing.
|
|
||||||
// manualPasswordDraft/manualPasswordConfirmDraft re-populate the fields' value attribute
|
|
||||||
// across a validation-failure re-render, so a rejected password isn't silently wiped,
|
|
||||||
// forcing the user to retype it (and re-check "I saved it") from scratch.
|
|
||||||
manualPasswordMode = !1;
|
|
||||||
manualPasswordFeedback = null;
|
|
||||||
manualPasswordDraft = "";
|
|
||||||
manualPasswordConfirmDraft = "";
|
|
||||||
// Import flow (§SF10). importKey holds the pasted nsec/hex only until registration completes.
|
// Import flow (§SF10). importKey holds the pasted nsec/hex only until registration completes.
|
||||||
importKey = "";
|
importKey = "";
|
||||||
importPreviewNpub = "";
|
importPreviewNpub = "";
|
||||||
@@ -608,6 +551,7 @@ class D extends HTMLElement {
|
|||||||
vaultUnsaved = !1;
|
vaultUnsaved = !1;
|
||||||
vaultUnsavedReason;
|
vaultUnsavedReason;
|
||||||
vaultConnections = null;
|
vaultConnections = null;
|
||||||
|
vaultIntegrityWarnings = [];
|
||||||
/** offerNwcConnection state: the app already holds this URI; the only
|
/** offerNwcConnection state: the app already holds this URI; the only
|
||||||
* question on screen is whether a copy enters the user's vault. */
|
* question on screen is whether a copy enters the user's vault. */
|
||||||
/** Claimed synchronously by offerNwcConnection before any await (see there). */
|
/** Claimed synchronously by offerNwcConnection before any await (see there). */
|
||||||
@@ -616,11 +560,11 @@ class D extends HTMLElement {
|
|||||||
constructor() {
|
constructor() {
|
||||||
super(), this.root = this.attachShadow({ mode: "open" });
|
super(), this.root = this.attachShadow({ mode: "open" });
|
||||||
const e = new CSSStyleSheet();
|
const e = new CSSStyleSheet();
|
||||||
e.replaceSync(q), this.root.adoptedStyleSheets = [e], this.worker = new N();
|
e.replaceSync(R), this.root.adoptedStyleSheets = [e], this.worker = new S();
|
||||||
}
|
}
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
const e = T(this);
|
const e = E(this);
|
||||||
if (this.vaultRelayUrls = e.vaultRelayUrls ?? [], this.discoveryRelayUrls = e.discoveryRelayUrls ?? [], this.worker.configure({ vaultRelayUrls: this.vaultRelayUrls, discoveryRelayUrls: this.discoveryRelayUrls }).then(() => this.tryRestoreSession()), this.root.addEventListener("click", (t) => this.onClick(t)), this.root.addEventListener("submit", (t) => this.onSubmit(t)), this.root.addEventListener("input", (t) => this.onInput(t)), this.root.addEventListener("change", (t) => void this.onFileChange(t)), this.render(), this.installedProvider = B(this.worker, () => this.vaultRelayUrls), !window.nostr)
|
if (this.vaultRelayUrls = e.vaultRelayUrls ?? [], this.discoveryRelayUrls = e.discoveryRelayUrls ?? [], this.worker.configure({ vaultRelayUrls: this.vaultRelayUrls, discoveryRelayUrls: this.discoveryRelayUrls }).then(() => this.tryRestoreSession()), this.root.addEventListener("click", (t) => this.onClick(t)), this.root.addEventListener("submit", (t) => this.onSubmit(t)), this.root.addEventListener("change", (t) => void this.onFileChange(t)), this.render(), this.installedProvider = C(this.worker, () => this.vaultRelayUrls), !window.nostr)
|
||||||
try {
|
try {
|
||||||
window.nostr = this.installedProvider;
|
window.nostr = this.installedProvider;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -681,17 +625,17 @@ class D extends HTMLElement {
|
|||||||
*/
|
*/
|
||||||
async requestNwcConnection(e = {}) {
|
async requestNwcConnection(e = {}) {
|
||||||
if (this.vaultRequest) throw new Error("A wallet connection request is already in progress.");
|
if (this.vaultRequest) throw new Error("A wallet connection request is already in progress.");
|
||||||
const t = window.location.origin, a = e.appName?.trim() || window.location.hostname || "This app";
|
const t = window.location.origin, s = e.appName?.trim() || window.location.hostname || "This app";
|
||||||
return new Promise((s) => {
|
return new Promise((r) => {
|
||||||
this.vaultRequest = { appName: a, reason: e.reason, origin: t, stage: "auth", resolve: s }, this.dispatchEvent(new CustomEvent("bitlogin-request-pending")), (async () => {
|
this.vaultRequest = { appName: s, reason: e.reason, origin: t, stage: "auth", resolve: r }, this.dispatchEvent(new CustomEvent("bitlogin-request-pending")), (async () => {
|
||||||
try {
|
try {
|
||||||
if (!(await this.worker.getSessionStatus()).unlocked) {
|
if (!(await this.worker.getSessionStatus()).unlocked) {
|
||||||
this.goto("login");
|
this.goto("login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.continueVaultRequest();
|
await this.continueVaultRequest();
|
||||||
} catch (n) {
|
} catch (i) {
|
||||||
this.fail(n);
|
this.fail(i);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
@@ -766,8 +710,8 @@ class D extends HTMLElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.vaultUnsaved = !1;
|
this.vaultUnsaved = !1;
|
||||||
const a = await this.worker.vaultFindForOrigin();
|
const s = await this.worker.vaultFindForOrigin();
|
||||||
a.connection ? (this.vaultCandidate = a.connection, this.goto("vault-consent")) : this.goto("vault-import");
|
s.connection ? (this.vaultCandidate = s.connection, this.goto("vault-consent")) : this.goto("vault-import");
|
||||||
}
|
}
|
||||||
async approveVaultCandidate() {
|
async approveVaultCandidate() {
|
||||||
const e = this.vaultCandidate;
|
const e = this.vaultCandidate;
|
||||||
@@ -786,7 +730,7 @@ class D extends HTMLElement {
|
|||||||
if (e) {
|
if (e) {
|
||||||
this.setBusy(!0);
|
this.setBusy(!0);
|
||||||
try {
|
try {
|
||||||
const t = await O(e.appName);
|
const t = await B(e.appName);
|
||||||
if (t === null) {
|
if (t === null) {
|
||||||
this.setBusy(!1);
|
this.setBusy(!1);
|
||||||
return;
|
return;
|
||||||
@@ -809,20 +753,20 @@ class D extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async saveAndShareVaultUri(e, t) {
|
async saveAndShareVaultUri(e, t) {
|
||||||
const a = this.vaultRequest;
|
const s = this.vaultRequest;
|
||||||
if (!a) return;
|
if (!s) return;
|
||||||
if (this.vaultUnsaved) {
|
if (this.vaultUnsaved) {
|
||||||
this.setBusy(!1), this.finishVaultRequest(e);
|
this.setBusy(!1), this.finishVaultRequest(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const s = t.trim() || `${a.appName} wallet`;
|
const r = t.trim() || `${s.appName} wallet`;
|
||||||
await this.worker.vaultSaveNwc({ uri: e, label: s }), this.setBusy(!1), this.finishVaultRequest(e), this.flashSuccess(this.screen, "Wallet connected");
|
await this.worker.vaultSaveNwc({ uri: e, label: r }), this.setBusy(!1), this.finishVaultRequest(e), this.flashSuccess(this.screen, "Wallet connected");
|
||||||
}
|
}
|
||||||
async loadVaultManage() {
|
async loadVaultManage() {
|
||||||
this.vaultConnections = null, this.goto("vault-manage");
|
this.vaultConnections = null, this.vaultIntegrityWarnings = [], this.goto("vault-manage");
|
||||||
try {
|
try {
|
||||||
const e = await this.worker.vaultList();
|
const e = await this.worker.vaultList();
|
||||||
this.vaultConnections = e.connections;
|
this.vaultConnections = e.connections, this.vaultIntegrityWarnings = T(e);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.errorMessage = e instanceof Error ? e.message : String(e), this.vaultConnections = [];
|
this.errorMessage = e instanceof Error ? e.message : String(e), this.vaultConnections = [];
|
||||||
}
|
}
|
||||||
@@ -930,32 +874,6 @@ class D extends HTMLElement {
|
|||||||
field(e) {
|
field(e) {
|
||||||
return this.root.querySelector(`[name="${e}"]`)?.value ?? "";
|
return this.root.querySelector(`[name="${e}"]`)?.value ?? "";
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Shared manual-password field markup (§9.3), reused on every screen that lets a user set
|
|
||||||
* their own password (initial creation, phrase recovery, and password rotation) so the
|
|
||||||
* option isn't only available at signup.
|
|
||||||
*/
|
|
||||||
renderManualPasswordFields() {
|
|
||||||
return `
|
|
||||||
<label for="manualPassword">Password</label>
|
|
||||||
<input type="password" name="manualPassword" id="manualPassword" autocomplete="new-password" required minlength="12" value="${i(this.manualPasswordDraft)}" />
|
|
||||||
<div class="notice info" id="manual-password-feedback"></div>
|
|
||||||
<label for="manualPasswordConfirm">Confirm password</label>
|
|
||||||
<input type="password" name="manualPasswordConfirm" id="manualPasswordConfirm" autocomplete="new-password" required minlength="12" value="${i(this.manualPasswordConfirmDraft)}" />
|
|
||||||
<div class="notice info" id="manual-password-match-feedback"></div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Reads and validates the manual-password fields against a caller-supplied login name.
|
|
||||||
* Always stashes what was typed into manualPasswordDraft/manualPasswordConfirmDraft so a
|
|
||||||
* validation-failure re-render can restore it (see renderManualPasswordFields).
|
|
||||||
*/
|
|
||||||
readManualPassword(e) {
|
|
||||||
const t = this.field("manualPassword"), a = this.field("manualPasswordConfirm");
|
|
||||||
this.manualPasswordDraft = t, this.manualPasswordConfirmDraft = a;
|
|
||||||
const s = g(t, e);
|
|
||||||
return s.ok ? t !== a ? { password: "", error: "Passwords do not match. Please re-enter both." } : { password: t } : { password: "", error: `Password not accepted: ${s.reason}` };
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Explicitly asks the browser to offer saving this credential via the Credential
|
* Explicitly asks the browser to offer saving this credential via the Credential
|
||||||
* Management API (Chromium-based browsers only — Firefox and Safari never implemented
|
* Management API (Chromium-based browsers only — Firefox and Safari never implemented
|
||||||
@@ -970,10 +888,10 @@ class D extends HTMLElement {
|
|||||||
*/
|
*/
|
||||||
async offerToSaveCredential(e, t) {
|
async offerToSaveCredential(e, t) {
|
||||||
try {
|
try {
|
||||||
const a = window.PasswordCredential;
|
const s = window.PasswordCredential;
|
||||||
if (!a || !navigator.credentials?.store) return;
|
if (!s || !navigator.credentials?.store) return;
|
||||||
const s = new a({ id: e, password: t, name: e });
|
const r = new s({ id: e, password: t, name: e });
|
||||||
await navigator.credentials.store(s);
|
await navigator.credentials.store(r);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1032,28 +950,25 @@ class D extends HTMLElement {
|
|||||||
this.goto("confirm-phrase");
|
this.goto("confirm-phrase");
|
||||||
return;
|
return;
|
||||||
case "goto-change-password":
|
case "goto-change-password":
|
||||||
this.changePasswordNewCredential = u().secret, this.manualPasswordMode = !1, this.manualPasswordFeedback = null, this.manualPasswordDraft = "", this.manualPasswordConfirmDraft = "", this.goto("change-password");
|
this.changePasswordNewCredential = d().secret, this.goto("change-password");
|
||||||
return;
|
return;
|
||||||
case "goto-export":
|
case "goto-export":
|
||||||
this.goto("export");
|
this.goto("export");
|
||||||
return;
|
return;
|
||||||
case "regenerate-credential":
|
case "regenerate-credential":
|
||||||
this.generatedCredential = u().secret, this.savedCheckbox = !1, this.render();
|
this.generatedCredential = d().secret, this.savedCheckbox = !1, this.render();
|
||||||
return;
|
|
||||||
case "toggle-manual-password":
|
|
||||||
this.manualPasswordMode = !this.manualPasswordMode, this.manualPasswordFeedback = null, this.manualPasswordDraft = "", this.manualPasswordConfirmDraft = "", this.savedCheckbox = !1, this.render();
|
|
||||||
return;
|
return;
|
||||||
case "copy-credential": {
|
case "copy-credential": {
|
||||||
const s = this.root.querySelector("#credential-box"), n = t, o = n.textContent ?? "Copy", l = (v) => {
|
const r = this.root.querySelector("#credential-box"), i = t, o = i.textContent ?? "Copy", l = (f) => {
|
||||||
n.textContent = v, setTimeout(() => {
|
i.textContent = f, setTimeout(() => {
|
||||||
n.isConnected && (n.textContent = o);
|
i.isConnected && (i.textContent = o);
|
||||||
}, 2e3);
|
}, 2e3);
|
||||||
};
|
};
|
||||||
if (!s || !navigator.clipboard?.writeText) {
|
if (!r || !navigator.clipboard?.writeText) {
|
||||||
l("Copy not available — select the text manually");
|
l("Copy not available — select the text manually");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigator.clipboard.writeText(s.textContent ?? "").then(
|
navigator.clipboard.writeText(r.textContent ?? "").then(
|
||||||
() => l("Copied"),
|
() => l("Copied"),
|
||||||
() => l("Copy failed — select the text manually")
|
() => l("Copy failed — select the text manually")
|
||||||
);
|
);
|
||||||
@@ -1068,8 +983,8 @@ class D extends HTMLElement {
|
|||||||
case "logout":
|
case "logout":
|
||||||
return this.logout();
|
return this.logout();
|
||||||
case "rollback-retry": {
|
case "rollback-retry": {
|
||||||
const s = this.pendingRollback?.kind;
|
const r = this.pendingRollback?.kind;
|
||||||
this.pendingRollback = null, this.goto(s === "change-password" ? "change-password" : "login");
|
this.pendingRollback = null, this.goto(r === "change-password" ? "change-password" : "login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case "rollback-continue":
|
case "rollback-continue":
|
||||||
@@ -1080,9 +995,9 @@ class D extends HTMLElement {
|
|||||||
}
|
}
|
||||||
async onSubmit(e) {
|
async onSubmit(e) {
|
||||||
if (e.preventDefault(), !e.isTrusted) return;
|
if (e.preventDefault(), !e.isTrusted) return;
|
||||||
const a = e.target.dataset.form;
|
const s = e.target.dataset.form;
|
||||||
try {
|
try {
|
||||||
switch (a) {
|
switch (s) {
|
||||||
case "import-key":
|
case "import-key":
|
||||||
return await this.handlePreviewImport();
|
return await this.handlePreviewImport();
|
||||||
case "create-name":
|
case "create-name":
|
||||||
@@ -1104,31 +1019,10 @@ class D extends HTMLElement {
|
|||||||
default:
|
default:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (s) {
|
} catch (r) {
|
||||||
this.fail(s);
|
this.fail(r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Live entropy/strength and match feedback for the manual-password fields, updated by
|
|
||||||
* DIRECTLY patching feedback elements' text — never calling this.render() — so neither
|
|
||||||
* input loses focus or cursor position while the user is typing (§9.3).
|
|
||||||
*/
|
|
||||||
onInput(e) {
|
|
||||||
const t = e.target;
|
|
||||||
if (!(t instanceof HTMLInputElement) || t.name !== "manualPassword" && t.name !== "manualPasswordConfirm") return;
|
|
||||||
const a = this.root.querySelector('input[name="manualPassword"]')?.value ?? "", s = this.root.querySelector('input[name="manualPasswordConfirm"]')?.value ?? "";
|
|
||||||
this.manualPasswordFeedback = a ? g(a, this.loginName) : null;
|
|
||||||
const n = this.root.querySelector("#manual-password-feedback");
|
|
||||||
if (n)
|
|
||||||
if (!this.manualPasswordFeedback)
|
|
||||||
n.textContent = "", n.className = "notice info";
|
|
||||||
else {
|
|
||||||
const l = this.manualPasswordFeedback.entropyBits.toFixed(0);
|
|
||||||
this.manualPasswordFeedback.ok ? (n.textContent = `Looks good (~${l} bits estimated).`, n.className = "notice info") : (n.textContent = `${this.manualPasswordFeedback.reason} (~${l} bits estimated)`, n.className = "notice warn");
|
|
||||||
}
|
|
||||||
const o = this.root.querySelector("#manual-password-match-feedback");
|
|
||||||
o && (s ? s === a ? (o.textContent = "Passwords match.", o.className = "notice info") : (o.textContent = "Passwords do not match.", o.className = "notice warn") : (o.textContent = "", o.className = "notice info"));
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Reads and validates an optional recovery-export file (§19.5) for the recover-phrase
|
* Reads and validates an optional recovery-export file (§19.5) for the recover-phrase
|
||||||
* screen. Purely a relay-outage fallback -- the file never contains the phrase or any
|
* screen. Purely a relay-outage fallback -- the file never contains the phrase or any
|
||||||
@@ -1137,16 +1031,16 @@ class D extends HTMLElement {
|
|||||||
async onFileChange(e) {
|
async onFileChange(e) {
|
||||||
const t = e.target;
|
const t = e.target;
|
||||||
if (!(t instanceof HTMLInputElement) || t.name !== "offlineExportFile" || t.type !== "file") return;
|
if (!(t instanceof HTMLInputElement) || t.name !== "offlineExportFile" || t.type !== "file") return;
|
||||||
const a = t.files?.[0];
|
const s = t.files?.[0];
|
||||||
if (!a) {
|
if (!s) {
|
||||||
this.offlineExportFile = null, this.offlineExportFileNotice = void 0, this.render();
|
this.offlineExportFile = null, this.offlineExportFileNotice = void 0, this.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const s = await a.text();
|
const r = await s.text();
|
||||||
this.offlineExportFile = x(JSON.parse(s)), this.offlineExportFileNotice = `Loaded recovery export from ${new Date(this.offlineExportFile.created_at * 1e3).toLocaleString()}.`;
|
this.offlineExportFile = w(JSON.parse(r)), this.offlineExportFileNotice = `Loaded recovery export from ${new Date(this.offlineExportFile.created_at * 1e3).toLocaleString()}.`;
|
||||||
} catch (s) {
|
} catch (r) {
|
||||||
this.offlineExportFile = null, this.offlineExportFileNotice = s instanceof C || s instanceof SyntaxError ? `Couldn't read that file: ${s.message}` : `Couldn't read that file: ${String(s)}`;
|
this.offlineExportFile = null, this.offlineExportFileNotice = r instanceof Q || r instanceof SyntaxError ? `Couldn't read that file: ${r.message}` : `Couldn't read that file: ${String(r)}`;
|
||||||
}
|
}
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
@@ -1177,21 +1071,13 @@ class D extends HTMLElement {
|
|||||||
this.errorMessage = "Login name must be 3-32 characters: a-z, 0-9, '.', '_', '-', and not start/end with punctuation.", this.render();
|
this.errorMessage = "Login name must be 3-32 characters: a-z, 0-9, '.', '_', '-', and not start/end with punctuation.", this.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loginName = e, this.generatedCredential = u().secret, this.savedCheckbox = !1, this.manualPasswordMode = !1, this.manualPasswordFeedback = null, this.manualPasswordDraft = "", this.manualPasswordConfirmDraft = "", this.goto("create-credential");
|
this.loginName = e, this.generatedCredential = d().secret, this.savedCheckbox = !1, this.goto("create-credential");
|
||||||
}
|
}
|
||||||
async handleCreateCredentialSubmit() {
|
async handleCreateCredentialSubmit() {
|
||||||
this.savedCheckbox = this.root.querySelector("#saved-check")?.checked ?? !1;
|
this.savedCheckbox = this.root.querySelector("#saved-check")?.checked ?? !1;
|
||||||
let e = this.generatedCredential;
|
const e = this.generatedCredential;
|
||||||
if (this.manualPasswordMode) {
|
|
||||||
const { password: n, error: o } = this.readManualPassword(this.loginName);
|
|
||||||
if (o) {
|
|
||||||
this.errorMessage = o, this.render();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e = n;
|
|
||||||
}
|
|
||||||
if (!this.savedCheckbox) {
|
if (!this.savedCheckbox) {
|
||||||
this.errorMessage = this.manualPasswordMode ? "Please confirm you've saved your password (or written it down) before continuing." : "Please confirm you saved your password before continuing.", this.render();
|
this.errorMessage = "Please confirm you saved your password before continuing.", this.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.setBusy(!0);
|
this.setBusy(!0);
|
||||||
@@ -1201,8 +1087,8 @@ class D extends HTMLElement {
|
|||||||
importKey: this.importKey || void 0
|
importKey: this.importKey || void 0
|
||||||
});
|
});
|
||||||
this.importKey = "", this.importPreviewNpub = "", this.recoveryPhrase = t.recoveryPhrase;
|
this.importKey = "", this.importPreviewNpub = "", this.recoveryPhrase = t.recoveryPhrase;
|
||||||
const a = this.recoveryPhrase.split(" "), s = K(a.length, 3);
|
const s = this.recoveryPhrase.split(" "), r = M(s.length, 3);
|
||||||
this.confirmSlots = s.map((n) => ({ index: n, value: "" })), this.session = { publicKey: t.everydayPublicKey, npub: d(t.everydayPublicKey), accountId: t.accountId }, this.busy = !1, this.goto("confirm-phrase"), this.worker.publishProfileAndRelayLists({
|
this.confirmSlots = r.map((i) => ({ index: i, value: "" })), this.session = { publicKey: t.everydayPublicKey, npub: u(t.everydayPublicKey), accountId: t.accountId }, this.busy = !1, this.goto("confirm-phrase"), this.worker.publishProfileAndRelayLists({
|
||||||
name: this.loginName,
|
name: this.loginName,
|
||||||
generalRelays: this.vaultRelayUrls,
|
generalRelays: this.vaultRelayUrls,
|
||||||
dmRelays: this.vaultRelayUrls
|
dmRelays: this.vaultRelayUrls
|
||||||
@@ -1237,7 +1123,7 @@ class D extends HTMLElement {
|
|||||||
if (!e.restored || !e.everydayPublicKey) return;
|
if (!e.restored || !e.everydayPublicKey) return;
|
||||||
this.session = {
|
this.session = {
|
||||||
publicKey: e.everydayPublicKey,
|
publicKey: e.everydayPublicKey,
|
||||||
npub: d(e.everydayPublicKey),
|
npub: u(e.everydayPublicKey),
|
||||||
accountId: e.accountId
|
accountId: e.accountId
|
||||||
}, this.noteSignerClaim(this.claimSigner()), this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: e.everydayPublicKey } })), this.goto("dashboard");
|
}, this.noteSignerClaim(this.claimSigner()), this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: e.everydayPublicKey } })), this.goto("dashboard");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1248,17 +1134,17 @@ class D extends HTMLElement {
|
|||||||
* paths grant a session identically -- claimSigner() and the bitlogin-login event only ever
|
* paths grant a session identically -- claimSigner() and the bitlogin-login event only ever
|
||||||
* fire once a RollbackDetectedError (if any) has been resolved one way or the other.
|
* fire once a RollbackDetectedError (if any) has been resolved one way or the other.
|
||||||
*/
|
*/
|
||||||
async attemptLogin(e, t, a = !1) {
|
async attemptLogin(e, t, s = !1) {
|
||||||
this.setBusy(!0);
|
this.setBusy(!0);
|
||||||
try {
|
try {
|
||||||
const s = await this.worker.login({ loginName: e, password: t, acknowledgeRollback: a });
|
const r = await this.worker.login({ loginName: e, password: t, acknowledgeRollback: s });
|
||||||
this.loginName = e, this.session = { publicKey: s.everydayPublicKey, npub: d(s.everydayPublicKey), accountId: s.accountId }, this.sessionWarnings = [s.rollbackWarning, s.relayDisagreementWarning].filter((n) => !!n), this.busy = !1, this.noteSignerClaim(this.claimSigner()), this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: s.everydayPublicKey } })), this.flashSuccess("dashboard", "Signed in"), this.offerToSaveCredential(e, t);
|
this.loginName = e, this.session = { publicKey: r.everydayPublicKey, npub: u(r.everydayPublicKey), accountId: r.accountId }, this.sessionWarnings = [r.rollbackWarning, r.relayDisagreementWarning].filter((i) => !!i), this.busy = !1, this.noteSignerClaim(this.claimSigner()), this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: r.everydayPublicKey } })), this.flashSuccess("dashboard", "Signed in"), this.offerToSaveCredential(e, t);
|
||||||
} catch (s) {
|
} catch (r) {
|
||||||
if (s instanceof Error && s.name === "RollbackDetectedError") {
|
if (r instanceof Error && r.name === "RollbackDetectedError") {
|
||||||
this.pendingRollback = { kind: "login", loginName: e, password: t }, this.rollbackMessage = s.message, this.busy = !1, this.goto("rollback-confirm");
|
this.pendingRollback = { kind: "login", loginName: e, password: t }, this.rollbackMessage = r.message, this.busy = !1, this.goto("rollback-confirm");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.fail(s);
|
this.fail(r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async handleRollbackContinue() {
|
async handleRollbackContinue() {
|
||||||
@@ -1277,7 +1163,7 @@ class D extends HTMLElement {
|
|||||||
generalRelaysCount: t.generalRelays.length,
|
generalRelaysCount: t.generalRelays.length,
|
||||||
dmRelaysCount: t.dmRelays.length,
|
dmRelaysCount: t.dmRelays.length,
|
||||||
chainWarning: t.chainWarning
|
chainWarning: t.chainWarning
|
||||||
}, this.session = { publicKey: t.everydayPublicKey, npub: d(t.everydayPublicKey), accountId: t.accountId }, this.newCredentialAfterRecovery = u().secret, this.manualPasswordMode = !1, this.manualPasswordFeedback = null, this.manualPasswordDraft = "", this.manualPasswordConfirmDraft = "", this.busy = !1, this.goto("recover-new-credentials");
|
}, this.session = { publicKey: t.everydayPublicKey, npub: u(t.everydayPublicKey), accountId: t.accountId }, this.newCredentialAfterRecovery = d().secret, this.busy = !1, this.goto("recover-new-credentials");
|
||||||
}
|
}
|
||||||
async handleRecoverNewCredentialsSubmit() {
|
async handleRecoverNewCredentialsSubmit() {
|
||||||
const e = this.field("newLoginName").trim().toLowerCase();
|
const e = this.field("newLoginName").trim().toLowerCase();
|
||||||
@@ -1285,51 +1171,34 @@ class D extends HTMLElement {
|
|||||||
this.errorMessage = "Login name must be 3-32 characters: a-z, 0-9, '.', '_', '-', and not start/end with punctuation.", this.render();
|
this.errorMessage = "Login name must be 3-32 characters: a-z, 0-9, '.', '_', '-', and not start/end with punctuation.", this.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let t = this.newCredentialAfterRecovery;
|
const t = this.newCredentialAfterRecovery;
|
||||||
if (this.manualPasswordMode) {
|
|
||||||
const { password: a, error: s } = this.readManualPassword(e);
|
|
||||||
if (s) {
|
|
||||||
this.errorMessage = s, this.render();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
t = a;
|
|
||||||
}
|
|
||||||
this.setBusy(!0), await this.worker.completeRecovery({ newLoginName: e, newPassword: t }), this.loginName = e, this.busy = !1, this.sessionWarnings = [], this.noteSignerClaim(this.claimSigner()), this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: this.session?.publicKey } })), this.flashSuccess("dashboard", "Account recovered"), this.offerToSaveCredential(e, t);
|
this.setBusy(!0), await this.worker.completeRecovery({ newLoginName: e, newPassword: t }), this.loginName = e, this.busy = !1, this.sessionWarnings = [], this.noteSignerClaim(this.claimSigner()), this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: this.session?.publicKey } })), this.flashSuccess("dashboard", "Account recovered"), this.offerToSaveCredential(e, t);
|
||||||
}
|
}
|
||||||
async handleChangePasswordSubmit() {
|
async handleChangePasswordSubmit() {
|
||||||
const e = this.field("oldPassword");
|
const e = this.field("oldPassword"), t = this.changePasswordNewCredential;
|
||||||
let t = this.changePasswordNewCredential;
|
|
||||||
if (this.manualPasswordMode) {
|
|
||||||
const { password: a, error: s } = this.readManualPassword(this.loginName);
|
|
||||||
if (s) {
|
|
||||||
this.errorMessage = s, this.render();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
t = a;
|
|
||||||
}
|
|
||||||
await this.attemptChangePassword(e, t);
|
await this.attemptChangePassword(e, t);
|
||||||
}
|
}
|
||||||
/** Shared by the rotation form and the "continue anyway" rollback-confirmation step; see attemptLogin. */
|
/** Shared by the rotation form and the "continue anyway" rollback-confirmation step; see attemptLogin. */
|
||||||
async attemptChangePassword(e, t, a = !1) {
|
async attemptChangePassword(e, t, s = !1) {
|
||||||
this.setBusy(!0);
|
this.setBusy(!0);
|
||||||
try {
|
try {
|
||||||
await this.worker.changePassword({
|
await this.worker.changePassword({
|
||||||
loginName: this.loginName,
|
loginName: this.loginName,
|
||||||
oldPassword: e,
|
oldPassword: e,
|
||||||
newPassword: t,
|
newPassword: t,
|
||||||
acknowledgeRollback: a
|
acknowledgeRollback: s
|
||||||
}), this.busy = !1, this.sessionWarnings = [], this.noteSignerClaim(this.claimSigner()), this.flashSuccess("dashboard", "Password updated"), this.offerToSaveCredential(this.loginName, t);
|
}), this.busy = !1, this.sessionWarnings = [], this.noteSignerClaim(this.claimSigner()), this.flashSuccess("dashboard", "Password updated"), this.offerToSaveCredential(this.loginName, t);
|
||||||
} catch (s) {
|
} catch (r) {
|
||||||
if (s instanceof Error && s.name === "RollbackDetectedError") {
|
if (r instanceof Error && r.name === "RollbackDetectedError") {
|
||||||
this.pendingRollback = { kind: "change-password", oldPassword: e, newPassword: t }, this.rollbackMessage = s.message, this.busy = !1, this.goto("rollback-confirm");
|
this.pendingRollback = { kind: "change-password", oldPassword: e, newPassword: t }, this.rollbackMessage = r.message, this.busy = !1, this.goto("rollback-confirm");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.fail(s);
|
this.fail(r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async handleDownloadRecoveryExport() {
|
async handleDownloadRecoveryExport() {
|
||||||
const e = await this.worker.buildRecoveryExport(), t = new Blob([JSON.stringify(e, null, 2)], { type: "application/json" }), a = URL.createObjectURL(t), s = document.createElement("a");
|
const e = await this.worker.buildRecoveryExport(), t = new Blob([JSON.stringify(e, null, 2)], { type: "application/json" }), s = URL.createObjectURL(t), r = document.createElement("a");
|
||||||
s.href = a, s.download = "bitlogin-recovery-export.json", s.click(), URL.revokeObjectURL(a);
|
r.href = s, r.download = "bitlogin-recovery-export.json", r.click(), URL.revokeObjectURL(s);
|
||||||
}
|
}
|
||||||
async handleSignTestEvent() {
|
async handleSignTestEvent() {
|
||||||
const e = await this.worker.signEvent({ kind: 1, content: `Hello from BitLogin at ${(/* @__PURE__ */ new Date()).toISOString()}` });
|
const e = await this.worker.signEvent({ kind: 1, content: `Hello from BitLogin at ${(/* @__PURE__ */ new Date()).toISOString()}` });
|
||||||
@@ -1358,7 +1227,7 @@ class D extends HTMLElement {
|
|||||||
<path d="M27 32.5l3.6 3.6L38 28.2" fill="none" stroke="url(#bl-success-brass)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M27 32.5l3.6 3.6L38 28.2" fill="none" stroke="url(#bl-success-brass)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<p class="success-label">${i(e)}</p>
|
<p class="success-label">${n(e)}</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -1376,10 +1245,10 @@ class D extends HTMLElement {
|
|||||||
}, 1100);
|
}, 1100);
|
||||||
}
|
}
|
||||||
renderError() {
|
renderError() {
|
||||||
return this.errorMessage ? `<div class="notice error">${i(this.errorMessage)}</div>` : "";
|
return this.errorMessage ? `<div class="notice error">${n(this.errorMessage)}</div>` : "";
|
||||||
}
|
}
|
||||||
renderWarnings() {
|
renderWarnings() {
|
||||||
return this.sessionWarnings.map((e) => `<div class="notice warn">${i(e)}</div>`).join("");
|
return this.sessionWarnings.map((e) => `<div class="notice warn">${n(e)}</div>`).join("");
|
||||||
}
|
}
|
||||||
/** The widget's own brand lockup (mark + wordmark), inlined as vector paths rather than
|
/** The widget's own brand lockup (mark + wordmark), inlined as vector paths rather than
|
||||||
* relying on the host page having loaded any particular font -- shown once, on the
|
* relying on the host page having loaded any particular font -- shown once, on the
|
||||||
@@ -1414,7 +1283,7 @@ class D extends HTMLElement {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
${e ? `<div class="notice info">This key's public identity:</div>
|
${e ? `<div class="notice info">This key's public identity:</div>
|
||||||
<div class="credential-box">${i(this.importPreviewNpub)}</div>
|
<div class="credential-box">${n(this.importPreviewNpub)}</div>
|
||||||
<button class="primary" type="button" data-action="import-continue">This is my identity — continue</button>` : ""}
|
<button class="primary" type="button" data-action="import-continue">This is my identity — continue</button>` : ""}
|
||||||
<button class="link" data-action="goto-welcome">Back</button>
|
<button class="link" data-action="goto-welcome">Back</button>
|
||||||
`;
|
`;
|
||||||
@@ -1433,36 +1302,21 @@ class D extends HTMLElement {
|
|||||||
`;
|
`;
|
||||||
case "create-credential": {
|
case "create-credential": {
|
||||||
const e = this.busy ? `<span class="spinner"></span>${this.importKey ? "Importing…" : "Creating account…"}` : this.importKey ? "Import account" : "Create account";
|
const e = this.busy ? `<span class="spinner"></span>${this.importKey ? "Importing…" : "Creating account…"}` : this.importKey ? "Import account" : "Create account";
|
||||||
return this.manualPasswordMode ? `
|
return `
|
||||||
<h2>Choose your own password</h2>
|
|
||||||
<p class="sub">Not recommended: a downloadable encrypted file can be guessed against forever, offline, with no rate limit. BitLogin can only run basic checks here — not a breach-database lookup — so weak or reused passwords are still your risk to carry.</p>
|
|
||||||
<div class="notice warn">Offline guessing against this password can never be fully prevented. The generated passphrase remains the safer default.</div>
|
|
||||||
${this.renderError()}
|
|
||||||
<form data-form="create-credential">
|
|
||||||
${this.renderManualPasswordFields()}
|
|
||||||
<label class="checkbox-row">
|
|
||||||
<input type="checkbox" id="saved-check" ${this.savedCheckbox ? "checked" : ""} />
|
|
||||||
I have saved this password somewhere safe.
|
|
||||||
</label>
|
|
||||||
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>${e}</button>
|
|
||||||
</form>
|
|
||||||
<button class="link" data-action="toggle-manual-password">Use a generated password instead (recommended)</button>
|
|
||||||
` : `
|
|
||||||
<h2>Your generated password</h2>
|
<h2>Your generated password</h2>
|
||||||
<p class="sub">BitLogin generates your password because no server can rate-limit guesses against a downloadable encrypted file.</p>
|
<p class="sub">BitLogin generates your password because no server can rate-limit guesses against a downloadable encrypted file.</p>
|
||||||
<div class="credential-box" id="credential-box">${i(this.generatedCredential)}</div>
|
<div class="credential-box" id="credential-box">${n(this.generatedCredential)}</div>
|
||||||
<button class="secondary" type="button" data-action="copy-credential">Copy</button>
|
<button class="secondary" type="button" data-action="copy-credential">Copy</button>
|
||||||
<button class="secondary" type="button" data-action="regenerate-credential">Generate a different one</button>
|
<button class="secondary" type="button" data-action="regenerate-credential">Generate a different one</button>
|
||||||
${this.renderError()}
|
${this.renderError()}
|
||||||
<form data-form="create-credential" autocomplete="on">
|
<form data-form="create-credential" autocomplete="on">
|
||||||
<input type="text" name="username" autocomplete="username" value="${i(this.loginName)}" readonly hidden />
|
<input type="text" name="username" autocomplete="username" value="${n(this.loginName)}" readonly hidden />
|
||||||
<label class="checkbox-row">
|
<label class="checkbox-row">
|
||||||
<input type="checkbox" id="saved-check" ${this.savedCheckbox ? "checked" : ""} />
|
<input type="checkbox" id="saved-check" ${this.savedCheckbox ? "checked" : ""} />
|
||||||
I have saved this password somewhere safe.
|
I have saved this password somewhere safe.
|
||||||
</label>
|
</label>
|
||||||
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>${e}</button>
|
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>${e}</button>
|
||||||
</form>
|
</form>
|
||||||
<button class="link" data-action="toggle-manual-password">Use my own password instead (not recommended)</button>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
case "confirm-phrase":
|
case "confirm-phrase":
|
||||||
@@ -1471,7 +1325,7 @@ class D extends HTMLElement {
|
|||||||
<p class="sub">These 12 words can recover your identity if you forget your password or lose all your devices. We cannot recover these words for you.</p>
|
<p class="sub">These 12 words can recover your identity if you forget your password or lose all your devices. We cannot recover these words for you.</p>
|
||||||
<p class="small">Do not enter a Bitcoin or other cryptocurrency-wallet recovery phrase into BitLogin. This is a BitLogin-only phrase.</p>
|
<p class="small">Do not enter a Bitcoin or other cryptocurrency-wallet recovery phrase into BitLogin. This is a BitLogin-only phrase.</p>
|
||||||
<div class="phrase-grid">
|
<div class="phrase-grid">
|
||||||
${this.recoveryPhrase.split(" ").map((e, t) => `<div class="phrase-word"><span>${t + 1}.</span>${i(e)}</div>`).join("")}
|
${this.recoveryPhrase.split(" ").map((e, t) => `<div class="phrase-word"><span>${t + 1}.</span>${n(e)}</div>`).join("")}
|
||||||
</div>
|
</div>
|
||||||
${this.renderError()}
|
${this.renderError()}
|
||||||
<button class="primary" type="button" data-action="goto-verify-phrase">I've saved my phrase — continue</button>
|
<button class="primary" type="button" data-action="goto-verify-phrase">I've saved my phrase — continue</button>
|
||||||
@@ -1520,7 +1374,7 @@ class D extends HTMLElement {
|
|||||||
<label for="offlineExportFile" style="margin-top:14px">Recovery export file (optional)</label>
|
<label for="offlineExportFile" style="margin-top:14px">Recovery export file (optional)</label>
|
||||||
<input type="file" name="offlineExportFile" id="offlineExportFile" accept=".json,application/json" />
|
<input type="file" name="offlineExportFile" id="offlineExportFile" accept=".json,application/json" />
|
||||||
<p class="small">Only needed if relays are unreachable -- the phrase above is still required either way. The file alone can never recover an account by itself.</p>
|
<p class="small">Only needed if relays are unreachable -- the phrase above is still required either way. The file alone can never recover an account by itself.</p>
|
||||||
${this.offlineExportFileNotice ? `<div class="notice ${this.offlineExportFile ? "info" : "warn"}">${i(this.offlineExportFileNotice)}</div>` : ""}
|
${this.offlineExportFileNotice ? `<div class="notice ${this.offlineExportFile ? "info" : "warn"}">${n(this.offlineExportFileNotice)}</div>` : ""}
|
||||||
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>
|
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>
|
||||||
${this.busy ? '<span class="spinner"></span>Recovering…' : "Continue"}
|
${this.busy ? '<span class="spinner"></span>Recovering…' : "Continue"}
|
||||||
</button>
|
</button>
|
||||||
@@ -1528,33 +1382,30 @@ class D extends HTMLElement {
|
|||||||
<button class="link" data-action="goto-welcome">Back</button>
|
<button class="link" data-action="goto-welcome">Back</button>
|
||||||
`;
|
`;
|
||||||
case "recover-new-credentials": {
|
case "recover-new-credentials": {
|
||||||
const e = this.recoveredPreview?.chainWarning ? `<div class="notice warn">${i(this.recoveredPreview.chainWarning)}</div>` : "", t = this.busy ? '<span class="spinner"></span>Finishing recovery…' : "Finish recovery";
|
const e = this.recoveredPreview?.chainWarning ? `<div class="notice warn">${n(this.recoveredPreview.chainWarning)}</div>` : "", t = this.busy ? '<span class="spinner"></span>Finishing recovery…' : "Finish recovery";
|
||||||
return `
|
return `
|
||||||
<h2>Identity recovered</h2>
|
<h2>Identity recovered</h2>
|
||||||
<p class="sub">Found your account. Restored ${this.recoveredPreview?.generalRelaysCount ?? 0} general relay(s) and ${this.recoveredPreview?.dmRelaysCount ?? 0} DM relay(s) from your public events.</p>
|
<p class="sub">Found your account. Restored ${this.recoveredPreview?.generalRelaysCount ?? 0} general relay(s) and ${this.recoveredPreview?.dmRelaysCount ?? 0} DM relay(s) from your public events.</p>
|
||||||
${e}
|
${e}
|
||||||
<p class="sub">Now set a new login name and password.</p>
|
<p class="sub">Now set a new login name. BitLogin generated a new high-entropy password for this account.</p>
|
||||||
${this.manualPasswordMode ? '<div class="notice warn">Offline guessing against this password can never be fully prevented. The generated passphrase remains the safer default.</div>' : `<div class="credential-box">${i(this.newCredentialAfterRecovery)}</div>`}
|
<div class="credential-box" id="credential-box">${n(this.newCredentialAfterRecovery)}</div>
|
||||||
|
<button class="secondary" type="button" data-action="copy-credential">Copy generated password</button>
|
||||||
${this.renderError()}
|
${this.renderError()}
|
||||||
<form data-form="recover-new-credentials">
|
<form data-form="recover-new-credentials">
|
||||||
<label for="newLoginName">New login name</label>
|
<label for="newLoginName">New login name</label>
|
||||||
<input type="text" name="newLoginName" id="newLoginName" autocomplete="off" required />
|
<input type="text" name="newLoginName" id="newLoginName" autocomplete="off" required />
|
||||||
${this.manualPasswordMode ? this.renderManualPasswordFields() : ""}
|
|
||||||
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>${t}</button>
|
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>${t}</button>
|
||||||
</form>
|
</form>
|
||||||
<button class="link" data-action="toggle-manual-password">
|
|
||||||
${this.manualPasswordMode ? "Use a generated password instead (recommended)" : "Use my own password instead (not recommended)"}
|
|
||||||
</button>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
case "dashboard":
|
case "dashboard":
|
||||||
return `
|
return `
|
||||||
<h2>Signed in</h2>
|
<h2>Signed in</h2>
|
||||||
${this.renderWarnings()}
|
${this.renderWarnings()}
|
||||||
<p class="pubkey">${i(this.session?.npub ?? "")}</p>
|
<p class="pubkey">${n(this.session?.npub ?? "")}</p>
|
||||||
${this.renderError()}
|
${this.renderError()}
|
||||||
<button class="secondary" type="button" data-action="sign-test-event">Sign a test event</button>
|
<button class="secondary" type="button" data-action="sign-test-event">Sign a test event</button>
|
||||||
${this.lastSignedEventJson ? `<div class="credential-box" style="white-space:pre-wrap">${i(this.lastSignedEventJson)}</div>` : ""}
|
${this.lastSignedEventJson ? `<div class="credential-box" style="white-space:pre-wrap">${n(this.lastSignedEventJson)}</div>` : ""}
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<button class="secondary" type="button" data-action="goto-vault-manage">Wallet connections</button>
|
<button class="secondary" type="button" data-action="goto-vault-manage">Wallet connections</button>
|
||||||
<button class="secondary" type="button" data-action="goto-change-password">Rotate password</button>
|
<button class="secondary" type="button" data-action="goto-change-password">Rotate password</button>
|
||||||
@@ -1564,16 +1415,16 @@ class D extends HTMLElement {
|
|||||||
case "vault-consent": {
|
case "vault-consent": {
|
||||||
const e = this.vaultRequest, t = this.vaultCandidate;
|
const e = this.vaultRequest, t = this.vaultCandidate;
|
||||||
if (!e || !t) return '<div class="notice error">No wallet request is in progress.</div>';
|
if (!e || !t) return '<div class="notice error">No wallet request is in progress.</div>';
|
||||||
const a = new Date(t.createdAt * 1e3).toLocaleDateString();
|
const s = new Date(t.createdAt * 1e3).toLocaleDateString();
|
||||||
return `
|
return `
|
||||||
<h2>Share a wallet with ${i(e.appName)}?</h2>
|
<h2>Share a wallet with ${n(e.appName)}?</h2>
|
||||||
${e.reason ? `<p class="sub">Reason given: ${i(e.reason)}</p>` : ""}
|
${e.reason ? `<p class="sub">Reason given: ${n(e.reason)}</p>` : ""}
|
||||||
${this.renderError()}
|
${this.renderError()}
|
||||||
<div class="credential-box">
|
<div class="credential-box">
|
||||||
<strong>${i(t.label)}</strong><br />
|
<strong>${n(t.label)}</strong><br />
|
||||||
Connected ${i(a)} · wallet ${i((t.walletPubkey ?? "").slice(0, 10))}…
|
Connected ${n(s)} · wallet ${n((t.walletPubkey ?? "").slice(0, 10))}…
|
||||||
</div>
|
</div>
|
||||||
<div class="notice info">${i(e.appName)} will receive this connection and can spend within the budget your wallet enforces, until you revoke it from Wallet connections.</div>
|
<div class="notice info">${n(e.appName)} will receive this connection and can spend within the budget your wallet enforces, until you revoke it from Wallet connections.</div>
|
||||||
<button class="primary" type="button" data-action="vault-approve" ${this.busy ? "disabled" : ""}>
|
<button class="primary" type="button" data-action="vault-approve" ${this.busy ? "disabled" : ""}>
|
||||||
${this.busy ? '<span class="spinner"></span>Sharing…' : "Use this wallet"}
|
${this.busy ? '<span class="spinner"></span>Sharing…' : "Use this wallet"}
|
||||||
</button>
|
</button>
|
||||||
@@ -1586,8 +1437,8 @@ class D extends HTMLElement {
|
|||||||
if (!e) return '<div class="notice error">No wallet request is in progress.</div>';
|
if (!e) return '<div class="notice error">No wallet request is in progress.</div>';
|
||||||
const t = this.vaultUnsaved ? `<div class="notice warn">${this.vaultUnsavedReason === "no-vault" ? "This account predates the Connection Vault, so the connection will be handed to the app but not saved to your account. Enable the vault from your account manager (recovery phrase required) to make wallets portable." : "Sign in again to save connections to your account — until then the connection will be handed to the app but not saved."}</div>` : "";
|
const t = this.vaultUnsaved ? `<div class="notice warn">${this.vaultUnsavedReason === "no-vault" ? "This account predates the Connection Vault, so the connection will be handed to the app but not saved to your account. Enable the vault from your account manager (recovery phrase required) to make wallets portable." : "Sign in again to save connections to your account — until then the connection will be handed to the app but not saved."}</div>` : "";
|
||||||
return `
|
return `
|
||||||
<h2>Connect a wallet for ${i(e.appName)}</h2>
|
<h2>Connect a wallet for ${n(e.appName)}</h2>
|
||||||
${e.reason ? `<p class="sub">Reason given: ${i(e.reason)}</p>` : ""}
|
${e.reason ? `<p class="sub">Reason given: ${n(e.reason)}</p>` : ""}
|
||||||
${t}
|
${t}
|
||||||
${this.renderError()}
|
${this.renderError()}
|
||||||
<button class="primary" type="button" data-action="vault-bc" ${this.busy ? "disabled" : ""}>
|
<button class="primary" type="button" data-action="vault-bc" ${this.busy ? "disabled" : ""}>
|
||||||
@@ -1598,7 +1449,7 @@ class D extends HTMLElement {
|
|||||||
<label for="vaultUri">Or paste an NWC connection</label>
|
<label for="vaultUri">Or paste an NWC connection</label>
|
||||||
<input type="password" name="vaultUri" id="vaultUri" autocomplete="off" placeholder="nostr+walletconnect://…" />
|
<input type="password" name="vaultUri" id="vaultUri" autocomplete="off" placeholder="nostr+walletconnect://…" />
|
||||||
<label for="vaultLabel">Name this connection</label>
|
<label for="vaultLabel">Name this connection</label>
|
||||||
<input type="text" name="vaultLabel" id="vaultLabel" autocomplete="off" maxlength="120" value="${i(`${e.appName} wallet`)}" />
|
<input type="text" name="vaultLabel" id="vaultLabel" autocomplete="off" maxlength="120" value="${n(`${e.appName} wallet`)}" />
|
||||||
<button class="secondary" type="submit" ${this.busy ? "disabled" : ""}>
|
<button class="secondary" type="submit" ${this.busy ? "disabled" : ""}>
|
||||||
${this.vaultUnsaved ? "Share without saving" : "Save and share"}
|
${this.vaultUnsaved ? "Share without saving" : "Save and share"}
|
||||||
</button>
|
</button>
|
||||||
@@ -1611,26 +1462,26 @@ class D extends HTMLElement {
|
|||||||
const e = this.vaultOffer;
|
const e = this.vaultOffer;
|
||||||
return e ? `
|
return e ? `
|
||||||
<h2>Save this wallet to your BitLogin?</h2>
|
<h2>Save this wallet to your BitLogin?</h2>
|
||||||
<p class="sub">${i(e.appName)} just connected a wallet on this device. Saved to your BitLogin, the connection follows you — on a new device it's one tap instead of another paste.</p>
|
<p class="sub">${n(e.appName)} just connected a wallet on this device. Saved to your BitLogin, the connection follows you — on a new device it's one tap instead of another paste.</p>
|
||||||
${this.renderError()}
|
${this.renderError()}
|
||||||
<label for="vaultOfferLabel">Name this connection</label>
|
<label for="vaultOfferLabel">Name this connection</label>
|
||||||
<input type="text" name="vaultOfferLabel" id="vaultOfferLabel" autocomplete="off" maxlength="120" value="${i(e.label)}" />
|
<input type="text" name="vaultOfferLabel" id="vaultOfferLabel" autocomplete="off" maxlength="120" value="${n(e.label)}" />
|
||||||
<button class="primary" type="button" data-action="vault-offer-save" ${this.busy ? "disabled" : ""}>
|
<button class="primary" type="button" data-action="vault-offer-save" ${this.busy ? "disabled" : ""}>
|
||||||
${this.busy ? '<span class="spinner"></span>Saving…' : "Save to BitLogin"}
|
${this.busy ? '<span class="spinner"></span>Saving…' : "Save to BitLogin"}
|
||||||
</button>
|
</button>
|
||||||
<button class="link" type="button" data-action="vault-offer-decline" ${this.busy ? "disabled" : ""}>No thanks — keep it on this device only</button>
|
<button class="link" type="button" data-action="vault-offer-decline" ${this.busy ? "disabled" : ""}>No thanks — keep it on this device only</button>
|
||||||
<div class="notice info">Stored encrypted on your account; ${i(e.appName)} keeps working either way. Remove it any time from Wallet connections.</div>
|
<div class="notice info">Stored encrypted on your account; ${n(e.appName)} keeps working either way. Remove it any time from Wallet connections.</div>
|
||||||
` : '<div class="notice error">No wallet offer is in progress.</div>';
|
` : '<div class="notice error">No wallet offer is in progress.</div>';
|
||||||
}
|
}
|
||||||
case "vault-manage": {
|
case "vault-manage": {
|
||||||
const e = this.vaultConnections, t = e === null ? '<p class="sub">Loading…</p>' : e.length === 0 ? `<p class="sub">No connections stored yet. They're added when you connect a wallet inside an app.</p>` : e.map(
|
const e = this.vaultConnections, t = e === null ? '<p class="sub">Loading…</p>' : e.length === 0 ? `<p class="sub">No connections stored yet. They're added when you connect a wallet inside an app.</p>` : e.map(
|
||||||
(a) => `
|
(s) => `
|
||||||
<div class="credential-box">
|
<div class="credential-box">
|
||||||
<strong>${i(a.label)}</strong> <span class="sub">(${i(a.connectionType)})</span><br />
|
<strong>${n(s.label)}</strong> <span class="sub">(${n(s.connectionType)})</span><br />
|
||||||
${a.origin ? `Linked to ${i(a.origin)}` : "Not linked to an app"}
|
${s.origin ? `Linked to ${n(s.origin)}` : "Not linked to an app"}
|
||||||
<div>
|
<div>
|
||||||
${a.origin ? `<button class="link" type="button" data-action="vault-unbind" data-id="${i(a.connectionId)}" ${this.busy ? "disabled" : ""}>Revoke app access</button>` : ""}
|
${s.origin ? `<button class="link" type="button" data-action="vault-unbind" data-id="${n(s.connectionId)}" ${this.busy ? "disabled" : ""}>Revoke app access</button>` : ""}
|
||||||
<button class="link" type="button" data-action="vault-delete" data-id="${i(a.connectionId)}" ${this.busy ? "disabled" : ""}>Remove from BitLogin</button>
|
<button class="link" type="button" data-action="vault-delete" data-id="${n(s.connectionId)}" ${this.busy ? "disabled" : ""}>Remove from BitLogin</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`
|
</div>`
|
||||||
).join("");
|
).join("");
|
||||||
@@ -1638,6 +1489,7 @@ class D extends HTMLElement {
|
|||||||
<h2>Wallet connections</h2>
|
<h2>Wallet connections</h2>
|
||||||
<p class="sub">Connections your apps use, stored encrypted on your account and restored on any device you sign in to.</p>
|
<p class="sub">Connections your apps use, stored encrypted on your account and restored on any device you sign in to.</p>
|
||||||
${this.renderError()}
|
${this.renderError()}
|
||||||
|
${this.vaultIntegrityWarnings.map((s) => `<div class="notice warn">${n(s)}</div>`).join("")}
|
||||||
${t}
|
${t}
|
||||||
<div class="notice info">"Remove from BitLogin" deletes the stored copy only — the connection itself keeps working for any app that already has it. To revoke spending authority, delete the connection inside your wallet app.</div>
|
<div class="notice info">"Remove from BitLogin" deletes the stored copy only — the connection itself keeps working for any app that already has it. To revoke spending authority, delete the connection inside your wallet app.</div>
|
||||||
<button class="link" data-action="goto-dashboard">Back</button>
|
<button class="link" data-action="goto-dashboard">Back</button>
|
||||||
@@ -1646,7 +1498,7 @@ class D extends HTMLElement {
|
|||||||
case "rollback-confirm":
|
case "rollback-confirm":
|
||||||
return `
|
return `
|
||||||
<h2>This looks like a stale or revoked credential</h2>
|
<h2>This looks like a stale or revoked credential</h2>
|
||||||
<div class="notice warn">${i(this.rollbackMessage)}</div>
|
<div class="notice warn">${n(this.rollbackMessage)}</div>
|
||||||
<p class="sub">This usually means the password just entered was rotated away in an earlier session on this device, and a relay hasn't caught up with that change. If you rotated your password, use the new one instead. Only continue if you're confident this is relay lag, not a stale credential.</p>
|
<p class="sub">This usually means the password just entered was rotated away in an earlier session on this device, and a relay hasn't caught up with that change. If you rotated your password, use the new one instead. Only continue if you're confident this is relay lag, not a stale credential.</p>
|
||||||
<button class="primary" type="button" data-action="rollback-retry">Try again</button>
|
<button class="primary" type="button" data-action="rollback-retry">Try again</button>
|
||||||
<button class="link" type="button" data-action="rollback-continue">I understand the risk — continue anyway</button>
|
<button class="link" type="button" data-action="rollback-continue">I understand the risk — continue anyway</button>
|
||||||
@@ -1656,19 +1508,16 @@ class D extends HTMLElement {
|
|||||||
return `
|
return `
|
||||||
<h2>Rotate password</h2>
|
<h2>Rotate password</h2>
|
||||||
<p class="sub">Your old password's capsule will be tombstoned and a deletion request issued. This does not erase copies an attacker may already have downloaded, and a relay that hasn't processed the deletion may keep serving the old password's capsule until a device that has already seen the new generation refuses it.</p>
|
<p class="sub">Your old password's capsule will be tombstoned and a deletion request issued. This does not erase copies an attacker may already have downloaded, and a relay that hasn't processed the deletion may keep serving the old password's capsule until a device that has already seen the new generation refuses it.</p>
|
||||||
${this.manualPasswordMode ? '<div class="notice warn">Offline guessing against this password can never be fully prevented. The generated passphrase remains the safer default.</div>' : `<div class="credential-box">${i(this.changePasswordNewCredential)}</div>`}
|
<div class="credential-box" id="credential-box">${n(this.changePasswordNewCredential)}</div>
|
||||||
|
<button class="secondary" type="button" data-action="copy-credential">Copy generated password</button>
|
||||||
${this.renderError()}
|
${this.renderError()}
|
||||||
<form data-form="change-password">
|
<form data-form="change-password">
|
||||||
<label for="oldPassword">Current password</label>
|
<label for="oldPassword">Current password</label>
|
||||||
<input type="password" name="oldPassword" id="oldPassword" autocomplete="current-password" required />
|
<input type="password" name="oldPassword" id="oldPassword" autocomplete="current-password" required />
|
||||||
${this.manualPasswordMode ? this.renderManualPasswordFields() : ""}
|
|
||||||
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>
|
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>
|
||||||
${this.busy ? '<span class="spinner"></span>Rotating…' : "Confirm rotation"}
|
${this.busy ? '<span class="spinner"></span>Rotating…' : "Confirm rotation"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<button class="link" data-action="toggle-manual-password">
|
|
||||||
${this.manualPasswordMode ? "Use a generated password instead (recommended)" : "Use my own password instead (not recommended)"}
|
|
||||||
</button>
|
|
||||||
<button class="link" data-action="goto-dashboard">Cancel</button>
|
<button class="link" data-action="goto-dashboard">Cancel</button>
|
||||||
`;
|
`;
|
||||||
case "export":
|
case "export":
|
||||||
@@ -1676,11 +1525,11 @@ class D extends HTMLElement {
|
|||||||
<h2>Export identity</h2>
|
<h2>Export identity</h2>
|
||||||
<p class="sub">Your public identity (npub) is safe to share. Your private key (nsec) is not.</p>
|
<p class="sub">Your public identity (npub) is safe to share. Your private key (nsec) is not.</p>
|
||||||
<label>Public key (npub)</label>
|
<label>Public key (npub)</label>
|
||||||
<div class="credential-box">${i(this.session?.npub ?? "")}</div>
|
<div class="credential-box">${n(this.session?.npub ?? "")}</div>
|
||||||
<button class="secondary" type="button" data-action="download-recovery-export">Download recovery export</button>
|
<button class="secondary" type="button" data-action="download-recovery-export">Download recovery export</button>
|
||||||
${this.renderError()}
|
${this.renderError()}
|
||||||
<button class="secondary" type="button" data-action="reveal-nsec">Reveal private key (nsec)</button>
|
<button class="secondary" type="button" data-action="reveal-nsec">Reveal private key (nsec)</button>
|
||||||
${this.exportedNsec ? `<div class="notice warn">Never share this. Anyone with it controls your identity.</div><div class="credential-box">${i(
|
${this.exportedNsec ? `<div class="notice warn">Never share this. Anyone with it controls your identity.</div><div class="credential-box">${n(
|
||||||
this.exportedNsec
|
this.exportedNsec
|
||||||
)}</div>` : ""}
|
)}</div>` : ""}
|
||||||
<button class="link" data-action="goto-dashboard">Back</button>
|
<button class="link" data-action="goto-dashboard">Back</button>
|
||||||
@@ -1690,30 +1539,28 @@ class D extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function K(r, e) {
|
function M(a, e) {
|
||||||
const t = Array.from({ length: r }, (s, n) => n), a = [];
|
const t = Array.from({ length: a }, (r, i) => i), s = [];
|
||||||
for (let s = 0; s < e && t.length > 0; s++) {
|
for (let r = 0; r < e && t.length > 0; r++)
|
||||||
const n = Math.floor(Math.random() * t.length);
|
s.push(t.splice(g(t.length), 1)[0]);
|
||||||
a.push(t.splice(n, 1)[0]);
|
return s.sort((r, i) => r - i);
|
||||||
}
|
}
|
||||||
return a.sort((s, n) => s - n);
|
function n(a) {
|
||||||
|
return a.replace(/[&<>"']/gu, (e) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[e]);
|
||||||
}
|
}
|
||||||
function i(r) {
|
customElements.get("bitlogin-auth") || customElements.define("bitlogin-auth", I);
|
||||||
return r.replace(/[&<>"']/gu, (e) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[e]);
|
|
||||||
}
|
|
||||||
customElements.get("bitlogin-auth") || customElements.define("bitlogin-auth", D);
|
|
||||||
window.bitlogin = {
|
window.bitlogin = {
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
isActiveSigner() {
|
isActiveSigner() {
|
||||||
return window.nostr?._bitlogin === !0;
|
return window.nostr?._bitlogin === !0;
|
||||||
},
|
},
|
||||||
releaseSigner() {
|
releaseSigner() {
|
||||||
const r = window;
|
const a = window;
|
||||||
return r.nostr?._bitlogin === !0 ? (delete r.nostr, window.dispatchEvent(new CustomEvent("bitlogin-signer-released")), !0) : !1;
|
return a.nostr?._bitlogin === !0 ? (delete a.nostr, window.dispatchEvent(new CustomEvent("bitlogin-signer-released")), !0) : !1;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
export {
|
export {
|
||||||
D as BitLoginAuthElement,
|
I as BitLoginAuthElement,
|
||||||
N as WorkerClient,
|
S as WorkerClient,
|
||||||
B as createNip07Provider
|
C as createNip07Provider
|
||||||
};
|
};
|
||||||
Vendored
+1105
-865
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user