Compare commits

...
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 5 9f20907f35 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 (0592c0ee here, 847a855e there), 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
2026-07-31 23:22:47 -04:00
thePR0M3TH3ANandClaude Opus 5 fbac54dd48 Re-vendor BitLogin at 205b067, and record its provenance
Updates the vendored widget to BitLogin 205b067, which includes the
second-audit hardening the previous bytes predated -- phrase-gated sudo key,
deterministic migration, the pinned derivation chain -- plus the entropy
work: the closed nonce/IV surface, fail-closed RNG tests, and the
package-wide Math.random ban.

Also ports the provenance recording from BitUnlock's vendoring script. The
manifest now carries repository, commit, commit date and toolchain, and the
script refuses to vendor from a BitLogin checkout with uncommitted changes,
resolved before vendorDir is deleted so the refusal leaves the committed
files intact. Hashes prove the bytes have not changed since they were
committed; only the commit says which BitLogin produced them, and version
"0.1.0" has covered many substantive changes.

EXISTING SIGN-INS ARE UNAFFECTED, which matters most here since this is the
one consumer with real users. normalize.ts, argon2id.ts, hkdf.ts and
scalarExpand.ts are byte-identical between the previously vendored build and
205b067, so login name + password derive the same locator, signing scalar
and capsule key as before. Accounts created with a hand-typed password still
sign in: the manual-password removal took away the creation, recovery and
rotation CONTROLS, not the acceptance, and nothing on the login path
inspects the password's shape. BitLogin's legacyPassword.e2e.test.ts pins
that, including the migration path of signing in with an old typed password
and rotating onto a generated one.

What does change for users: creating a new account, recovering, or rotating
now hands out a generated six-word passphrase instead of accepting a typed
one.

2354 unit tests pass, including the 10 bitlogin-specific ones in
auth-providers-bitlogin and bitlogin-modal-integration.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
2026-07-31 22:17:08 -04:00
thePR0M3TH3ANandClaude Fable 5 58922e55c3 Record the caching decision: content-hash filenames, then immutable
Option A chosen. Noting explicitly that Option B should not ship as an interim
step -- the two are not stackable, and a bounded max-age shipped now is the
thing that never gets revisited.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 14:53:07 -04:00
thePR0M3TH3ANandClaude Fable 5 5e69fa4a7d Todo: static asset caching, and why "just add immutable" would break it
/css/* and /js/* have no header rule at all, so every stylesheet and script
revalidates on every page load. The obvious fix is a trap: asset URLs are not
content-hashed, so a long max-age with immutable would pin returning users to
stale CSS/JS after each deploy with no way to bust it. Records both real
options and the measured live headers.

Also corrects an earlier hand-off note claiming ~10 failing tests here; all
three suites pass on unstable as of today.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 12:31:36 -04:00
thePR0M3TH3ANandClaude Fable 5 8d40d85079 Delete netlify.toml; the site deploys on Vercel
Vestigial. Verified dead before removing: the live headers on
bitvid.network come from vercel.json, not from this file -- /js/* is
served with Vercel's default `max-age=0, must-revalidate` rather than
the `immutable, max-age=31536000` netlify.toml asks for, which is proof
the file is not consulted by anything deploying this site. It cost real
time today by making the deploy target look like Netlify.

Two rules it carried that vercel.json does NOT, both deliberately left
out rather than silently dropped:
- `Content-Security-Policy: frame-ancestors *` on /*. No behaviour
  change: absence is equally permissive, and the embed page relies on
  being framable, not on the header.
- Long-lived immutable caching for /css/* and /js/*. This one is a real
  (small) performance regression that predates this commit -- the live
  site has been serving those with max-age=0 for as long as it has been
  on Vercel. Worth porting into vercel.json separately; deleting a file
  nothing reads is not the change that should carry it.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 10:14:43 -04:00
thePR0M3TH3ANandClaude Fable 5 847a855ec8 Update the vendored BitLogin widget to the security-audit build
Was on the 2026-07-25 build. Carries an adversarial audit's fixes, the
ones that matter most for a site that hands the widget real identities:

- Origin binding is enforced INSIDE the crypto worker and derived from
  self.location instead of a caller-supplied field, so a page cannot
  read or adopt a wallet connection bound to a different site.
- Phrase recovery no longer trips the rollback alarm on every later
  login (the mark is keyed by the everyday pubkey, which recovery
  preserves, while the recovered capsule restarts at generation 0) --
  users who had ever rotated a password previously saw "possibly
  revoked credentials" forever, with no way to clear it.
- quorumMet counts relays that ANSWERED rather than ones that merely
  accepted a socket, and relay responses are now checked against the
  address requested, so a single junk event can no longer block
  registration, rotation, and recovery.
- Password rotation checks the new capsule reached quorum BEFORE
  publishing the tombstone that destroys the old one; a failed rotation
  used to be able to leave neither password working.
- Derived secrets (Argon2id output, both PRKs, the BIP-39 seed) are now
  wiped at their derivation site, as the spec always claimed.
- The worker RPC validates message shapes, times out, and rejects
  pending calls when the worker dies, so a torn-down worker surfaces as
  an error instead of a permanent hang.
- Synthetic (script-dispatched) clicks no longer drive consent UI.

Verified: the 10 pre-existing test failures in this suite are unchanged
by this update (checked by stashing the vendor directory and re-running
-- 1330 pass / 10 fail either way), and the BitLogin auth-provider
tests pass. dist/ is gitignored build output and regenerates.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 10:02:48 -04:00
thePR0M3TH3ANandClaude Fable 5 5cce3b726a docs: close stale innerHTML TODO — ShareNostrModal has no innerHTML left
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:45:23 -04:00
thePR0M3TH3ANandClaude Fable 5 975b996f4c [nostr-core] Pin the NIP-46 multi-account lifecycle contract (TODO #33)
The 2026-06-25 audit blamed "logout logs out everyone" / broken profile
switching / unknown-user-after-refresh on the single shared nip46Client and
single-slot session storage. Re-auditing the current code shows every named
suspect has since been fixed piecemeal: per-account v2 session map with v1
last-connected default, keepStored logout, scoped no-arg clear (v1 slot only),
reuseStored+expectPubkey switch wired end-to-end into
useStoredRemoteSigner({pubkey}), and a validator-enforced boot restore.

None of that was pinned at the manager level, so any layer could regress
silently. tests/nip46-signer-lifecycle.test.mjs now exercises the real
SignerManager against the real signer registry and real (polyfilled) storage:

- logging out the active NIP-46 account destroys its live client exactly once
  while BOTH accounts' stored sessions and the other account's registry signer
  survive
- a failed targeted restore (passphrase-locked or missing session) fails typed
  and deletes nothing
- disconnectRemoteSigner with and without keepStored never touches the
  per-account map

Mutation-checked: reinjecting the historical bugs (logout without keepStored;
no-arg clear wiping the v2 map) fails 2/6; real code passes 6/6.

TODO #33 updated: remaining work is the on-phone Amber verification the item
already gates on — and if that still misbehaves, the failure is now known to
live ABOVE this contract (UI/state reconciliation on restore failure), which
narrows the search considerably.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:33:26 -04:00
thePR0M3TH3ANandClaude Fable 5 2eb7d09865 docs: note that visual baselines are CI-canonical now
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:23:10 -04:00
thePR0M3TH3ANandClaude Fable 5 e99ba0e817 docs: refresh KNOWN_ISSUES against current test reality
- jsdom entry: resolved long ago (28.1.0 installed, in devDeps) — was still
  marked Active with an install remediation.
- webseed-playbook.spec.ts:31: resolved by f48150ed; documents the actual fix
  (record attached srcs instead of asserting the final cleared src).
- uploadModal-reset: not reproducible across 2026-07-28..30 sweeps; left on
  file with instructions if the ordering-dependent hang recurs.
- login-flows.spec.ts:149: corrected — it is NOT a passes-in-isolation flake;
  it failed in isolation on 2026-07-28 (local + CI e2e-headless) while passing
  in the e2e job. Notes the reverted fix attempt and links it to pre-launch
  TODO #33, which is the same subsystem.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:22:21 -04:00
github-actions[bot] ab120a03e1 test(visual): regenerate baselines on ubuntu-latest
Captured by the update-visual-baselines workflow so the baseline environment matches the visual-regression job that compares against them.
2026-07-30 15:16:54 +00:00
thePR0M3TH3ANandClaude Fable 5 5d0709f5f4 [ci] Fix baseline workflow YAML — literal block was terminated early [update-visual-baselines]
The multi-line git commit message inside the run block dropped below the
literal block's indentation, ending the block mid-command and failing the
whole workflow file at parse time (0s failure, no jobs). Use two -m flags on
one shell line instead. YAML now validated locally before push.

Token included so this push performs the regeneration.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:15:26 -04:00
thePR0M3TH3ANandClaude Fable 5 350feae907 [ci] Allow triggering baseline regeneration from unstable [update-visual-baselines]
workflow_dispatch only works for workflows on the default branch, which this
file will not reach until the next promotion. Gate the job on an explicit
[update-visual-baselines] commit-message token instead so it is dispatchable
from unstable now; the workflow's own commit omits the token, so it cannot
retrigger itself.

This commit carries the token, so pushing it performs the first regeneration.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:14:17 -04:00
thePR0M3TH3ANandClaude Fable 5 31be84ba82 [ci] Add a workflow to regenerate visual baselines on the CI runner
The visual-regression job pixel-compares tests/visual/baselines.json on
ubuntu-latest, but the committed baselines were captured on developer machines.
Chromium builds and font fallbacks differ between environments — the token
stack's "Inter"/"Fira Code" are not shipped or installed anywhere, so every
machine renders its own system fallback — and the kitchen-sink default-theme
diff drifted past MAX_DIFF_RATIO on CI while passing 13/13 locally. ci.yml
documents exactly this mismatch as the reason the job is quarantined from
blocking deploys.

The CI diff artifact confirms it: baseline and actual render identically except
for ghosted/offset text in the high-contrast lower half — font-metric drift,
not a design change. (The dark upper half diffs too little to trip the 0.2
pixelmatch threshold, which is why only the default theme fails.)

Fix the class of problem instead of the instance: capture baselines in the SAME
runner image, browser install and font stack that the comparison job uses.
workflow_dispatch only — regenerating baselines is an explicit decision made
when a visual change is intentional, never a side effect of a push. The
workflow re-runs the comparison against its own output before committing, so a
bad capture cannot land.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:11:14 -04:00
thePR0M3TH3ANandClaude Opus 5 f48150ed6b [ci] Fix the stale tests that had CI red
CI has been failing on unstable for weeks, across four unit files and a set of
e2e specs. None of it was a product regression: every failure is a test that
encodes behavior a later commit deliberately changed. Each is corrected against
the spec that superseded it, not adjusted until it passed.

Unit:

- video-modal-moderation: the fixture set flat `blurThumbnail`/`trustedMuteCount`,
  but normalizeVideoModerationContext derives shouldShow from
  `original.blurThumbnail`/`trustedCount`, so the assertion ran against a
  context whose early return had just deleted the blur it was checking. 4525c1c6
  also made a lone trusted mute a non-blocking ranking signal (its sibling test
  was renamed to say exactly that; this one was missed). Fixture now mirrors
  what moderationDecorator emits.
- trustBootstrap: asserted a subscription to onWhitelistChange that 18e08bf2
  deliberately removed -- buildTrustedSeeds derives seeds from the super admin,
  editors and fallback seeds, so a whitelist change cannot alter them. The
  removal is now pinned so it is not reinstated by reflex.
- moderation-service: 18e08bf2 removed `status.whitelisted ||` from
  isTrustedReporter (being allowed to publish is not a statement that your
  reports carry weight). A whitelisted reporter now counts toward the report
  total but not toward trusted.
- playbackService_forcedSource: forced P2P now falls back to a hosted URL when
  the torrent definitively fails -- playbackService documents the change and its
  rollback. The old assertion did not merely fail, it HUNG: forced sources skip
  the start timeout, so the now-reachable hosted attempt waited forever on a
  <video> that never emits.

E2E:

- ui-modals: asserted exactly one `.bv-modal` per component page, but
  profile-modal.html also bundles #storageCorsModal (d7ad1516). Requires at
  least one and drives the primary, which is what the test's own injected
  querySelector already used.
- coverage-utility-modules: used `author:abc123`, which 9afbc0cb correctly began
  rejecting as undecodable. Uses a real pubkey for the success path and pins the
  rejection separately.
- webseed-playback: asserted the FINAL src equals the CDN URL while also
  tolerating "No playable source found" in its status check -- contradictory,
  since a failed attempt calls resetVideoElement() and clears src. Headless CI
  cannot decode the archive.org H.264 sample. Now records src attachments and
  asserts the URL was attached, which is the behavior under test.
- nostr-runtime-coverage: asserted a legacy plaintext session-actor entry reads
  back as null. Since 8692dac6 added per-account storage, readV1Entry PURGES the
  plaintext entry and the reader falls back to the sole remaining encrypted
  account -- so null no longer describes the security property. Asserts the
  property directly: no plaintext key survives, in the entry or in raw storage.

Also drops the syncAuthUiState share-gate wiring added in d141c316. It was
redundant: Application.updateShareNostrAuthState() already fires on init, login,
logout, pubkey-change and signer-change and forwards to
videoModal.setShareNostrAuthState(). That call short-circuited on its own
`if (!this.videoModal?.setShareNostrAuthState) return;` guard purely because the
method did not exist -- adding it (d141c316) was the whole fix. The tests now
pin that contract instead of a second, competing code path.

All 416 unit files, all three CI shards, both DM suites and lint are green.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 17:37:24 -04:00
25 changed files with 2296 additions and 1758 deletions

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
View File
@@ -3,11 +3,10 @@
## Test Infrastructure
### Missing `jsdom` Dependency
- **Status:** Active
- **Status:** Resolved (2026-07-30)
- **Detected:** 2026-02-26
- **Description:** Multiple unit tests fail with `ERR_MODULE_NOT_FOUND` because `jsdom` is imported but not installed in the project.
- **Impact:** Prevents running unit tests that rely on DOM simulation.
- **Remediation:** `npm install --save-dev jsdom`
- **Description:** Unit tests failed with `ERR_MODULE_NOT_FOUND` because `jsdom` was imported but not installed.
- **Resolution:** `jsdom` has been a devDependency for some time (28.1.0 installed and in `package.json`); the jsdom-based suites run clean.
## 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`.
- **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
- **Description:** Under the full `test:e2e` run (378 tests, 4 workers) these intermittently fail on `not.toBeVisible`/feed-appearance timeouts, but pass deterministically when re-run in isolation (`--workers=1`): `login-flows.spec.ts:149` (upload/profile buttons hide after logout), `video-upload-publish-lifecycle.spec.ts:49` and `:79` (URL-first publish appears / owner delete). Same root cause as the Firefox feed-hydration flakes above — worker resource contention, not an app regression (verified: pass in isolation on the current branch).
- **Impact:** Full-suite e2e is flaky; isolated reruns are green.
- **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 occasionally needs retries; isolated reruns are green.
### `webseed-playback.spec.ts:31` fails on Firefox headless (CDN mode)
- **Status:** Active (pre-existing — reproduced on pre-refactor commit `1b11cb1b`)
- **Description:** "plays directly from HTML link (CDN mode)" fails on `--project=e2e-firefox` with `#modalVideo` `src` empty instead of the test's archive.org URL. The Chromium variant and the WebTorrent-fallback variant (`:63`) both pass. Firefox-headless-specific (external URL reachability/codec), not an app regression.
- **Impact:** One Firefox-only e2e failure; CDN playback works on Chromium and in manual testing.
- **Status:** Resolved (2026-07-28, `f48150ed`)
- **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.
- **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
- **Status:** Active (pre-existing — reproduced on pre-refactor commit `1b11cb1b`)
- **Description:** The jsdom-based UploadModal reset suite intermittently fails with `cancelledByParent` / "Promise resolution is still pending but the event loop has already resolved" (~12s hang). It passes or fails depending on full-suite test ordering/timing (an async-hang flake, likely a torrent-metadata/upload promise not settling under the mocked services). Not a regression from the MediaUploader extraction — it fails identically on `1b11cb1b`.
- **Impact:** One unit suite can report `not ok 1`; the runner still exits 0. Other tests unaffected.
- **Status:** Not reproducible (2026-07-30)
- **Description:** Intermittent `cancelledByParent` / "Promise resolution is still pending" hang, order/timing dependent.
- **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
View File
@@ -9,7 +9,6 @@
* Methods use `this` which is bound to the Application instance.
*/
import { SHORT_TIMEOUT_MS, DEBOUNCE_DELAY_MS } from "../constants.js";
import { getActiveSigner } from "../nostrClientRegistry.js";
export function createUiCoordinator(deps) {
const {
@@ -188,52 +187,11 @@ export function createUiCoordinator(deps) {
},
syncAuthUiState() {
// Auth-dependent chrome has to follow login/logout/account switches, but
// 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) {
if (this.isUserLoggedIn()) {
this.applyAuthenticatedUiState();
} else {
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() {
-46
View File
@@ -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"
+38 -1
View File
@@ -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).");
}
// 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)) {
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" });
@@ -56,7 +93,7 @@ for (const name of distEntries) {
await writeFile(
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 {
+17 -4
View File
@@ -13,12 +13,20 @@ test.describe("runtime utility module coverage", () => {
"/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(
'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(
"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);
@@ -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.kind).toBe(30078);
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) => /Has filter/.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("kind:30078");
expect(result.serialized).toContain("has:magnet");
+20 -2
View File
@@ -491,7 +491,21 @@ test.describe("nostr runtime coverage (deterministic)", () => {
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 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({
pubkey: actor,
@@ -524,7 +538,10 @@ test.describe("nostr runtime coverage (deterministic)", () => {
wrongPassphraseErrorCode,
metadataValid: metadata && metadata.salt === encrypted.salt,
metadataInvalidIsNull: invalidMetadata === null,
migratedLegacyEntryIsNull: migratedLegacyEntry === null,
migratedLegacyEntryHasPlaintextKey:
typeof (migratedLegacyEntry as any)?.privateKey === "string" &&
(migratedLegacyEntry as any).privateKey.length > 0,
legacyRawPlaintextSurvives,
actorMatch,
actorMismatch,
nsecActor,
@@ -541,7 +558,8 @@ test.describe("nostr runtime coverage (deterministic)", () => {
expect(result.wrongPassphraseErrorCode).toBe("decrypt-failed");
expect(result.metadataValid).toBeTruthy();
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.actorMismatch).toBe(false);
expect(result.nsecActor).toBe(false);
+10 -2
View File
@@ -211,8 +211,16 @@ test.describe("component modal pages", () => {
waitUntil: "networkidle",
});
const modal = page.locator(".bv-modal");
await expect(modal).toHaveCount(1);
// A component page may legitimately ship more than one modal root:
// 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.
await page.addScriptTag({
+29 -3
View File
@@ -29,6 +29,31 @@ test.describe("Webseed Stream Playback", () => {
});
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
await page.evaluate(async (url) => {
const { getApplication } = await import("/js/applicationContext.js");
@@ -55,9 +80,10 @@ test.describe("Webseed Stream Playback", () => {
const urlToggle = page.locator('[data-source-toggle="url"]');
await expect(urlToggle).toHaveAttribute("aria-pressed", "true");
// Check if video is attempting to play from source
const video = page.locator("#modalVideo");
await expect(video).toHaveAttribute("src", VIDEO_URL);
// Check the player attached the hosted URL (see the observer above).
await expect
.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 }) => {
+20 -6
View File
@@ -92,18 +92,32 @@ test("trusted report summaries respect personal blocks and admin lists", async (
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);
assert.equal(summary.totalTrusted, 2);
assert.equal(summary.types.nudity.total, 3);
assert.equal(summary.types.nudity.trusted, 2);
assert.equal(summary.types.nudity.latest, 400);
assert.equal(summary.totalTrusted, 1, "only the trusted contact is trusted");
assert.equal(summary.types.nudity.total, 3, "whitelisted still counts as a report");
assert.equal(summary.types.nudity.trusted, 1);
assert.equal(
summary.types.nudity.latest,
400,
"latest still reflects the whitelisted reporter's timestamp"
);
const reporters = service.getTrustedReporters(eventId, "nudity");
assert.deepEqual(
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) => {
+212
View File
@@ -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);
});
test("Forced Source 'torrent': Does NOT fallback to URL even if Torrent fails", async () => {
const probeUrl = mock.fn(async () => ({ outcome: "good" }));
test("Forced Source 'torrent': falls back to the hosted URL when the torrent definitively fails", async () => {
// 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 () => {
throw new Error("Torrent failed");
@@ -283,9 +293,18 @@ describe("PlaybackService Forced Source Logic", () => {
forcedSource: "torrent",
});
const result = await session.start();
const startPromise = session.start();
assert.equal(result.source, null);
assert.equal(probeUrl.mock.callCount(), 0);
// Let the torrent attempt reject and the hosted fallback attach, then
// 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");
});
});
+12 -2
View File
@@ -133,14 +133,24 @@ describe("trustBootstrap", () => {
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();
assert.equal(accessControl.onWhitelistChange.mock.callCount(), 1);
assert.equal(accessControl.onEditorsChange.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 () => {
// Mock waitForAccessControl to timeout (reject)
accessControl.waitForReady.mock.mockImplementation(async () => {
+35 -100
View File
@@ -13,13 +13,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createUiCoordinator } from "../js/app/uiCoordinator.js";
import { VideoModal } from "../js/ui/components/VideoModal.js";
import {
clearActiveSigner,
registerSigner,
setActiveSigner,
} from "../js/nostrClientRegistry.js";
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 }) {
const coordinator = createUiCoordinator({
devLogger: { warn() {}, error() {}, log() {} },
});
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("the app's updateShareNostrAuthState contract is satisfied", async () => {
const { readFile } = await import("node:fs/promises");
const source = await readFile(new URL("../js/app.js", import.meta.url), "utf8");
test("syncAuthUiState pushes the gate into the video modal", () => {
const { app, pushed } = makeApp({ loggedIn: true });
app.syncAuthUiState();
assert.equal(pushed.length, 1);
assert.equal(pushed[0].isLoggedIn, true);
});
// The guard short-circuits when the method is absent, which is exactly how
// this stayed broken silently. If the method is ever renamed, fail loudly.
assert.match(source, /videoModal\?\.setShareNostrAuthState/);
assert.match(source, /videoModal\.setShareNostrAuthState\(\{/);
test("logged out propagates as a closed gate", () => {
const { app, pushed } = makeApp({ loggedIn: false });
app.syncAuthUiState();
assert.deepEqual(pushed[0], { isLoggedIn: false, hasSigner: false });
});
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
// The lifecycle points that must keep the gate in sync.
for (const reason of ["init", "signer-change", "pubkey-change"]) {
assert.ok(
source.includes(`reason: "${reason}"`),
`app.js should refresh the share gate on ${reason}`
);
} 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"'));
});
+19
View File
@@ -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 () => {
// 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 = {
id: "event-trusted-mute",
title: "Trusted mute video",
moderation: {
blurThumbnail: true,
blurReason: "trusted-mute",
trustedMuted: true,
trustedCount: 2,
trustedMuteCount: 2,
trustedMuterDisplayNames: ["Carol", "Dave"],
trustedMuteDisplayNames: ["Carol", "Dave"],
original: { blurThumbnail: true, blurReason: "trusted-mute" },
},
};
+3 -3
View File
@@ -1,6 +1,6 @@
{
"contrast": "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 truncated
"default": "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 truncated
"light": "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 truncated
"contrast": "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 truncated
"default": "iVBORw0KGgoAAAANSUhEUgAABQwAAAXVCAIAAADPSQWyAAAQAElEQVR4nOzdB4AcZf038N279IQWSCEQerPQi1TpXQRCB8GGFaSJgIB/UJqgIoovWGiCIEVAVERQeu+9J6ETIJQQ0tu+z91elsvVLbO7MzufjzFs9vZmd6c883zn98xMnz79F8oAaZQt6dUdXl7iL5MuuUxDiPJrNMgsIWoRNaUtk8lW1Kb3+MJsZE1+MdOJYGPJ5UqaSLEvzrVOOlMprQHUWVNz315f0ycDAAAAtBKSAQAAoE1TBgAAGkdJ48Or92IgqVSSAQAAoI2QDABAtanBAokhJAMAAEAbIRmAiHVXMKrkzifVKEK5EwsA0JkLdwElM2YOSmSjAYDEUEkGAKg9h04AYkolGQAg0aqYtxMZ5R1/ACqjkgzppAdBHcRttWv/eXLd/TiyE5ezToImNuwC6khTAAkgJEMK6R4BAEDXhGSgCNkuH0Lj6LZsHHE9Gaqimi2zVh9IHeckA0AbaQDojvYB0kNIBoDa0MeGWrClARUy3BroUbaHf0EDMrwaAFJOJRkAasaBJhIlyhXWoScgMYRkSBt9dOhdtvO/bTqklHALpI7h1kCxZAQAABqeSjIA1JLDTQAQa0Iy0D23RybFskU9BRQv19tPDe0GYsFwa0iVMvv4ogEAUSjE4GynZ6rxLgDlEJKBbkjG0FlW95uYSWpbbUMC4stwawDomiNFNKiiA6okC6SSSjIAAKnlSADQkUoyAAA9SFiMlHqBCqkkAwDQMGRkoFIqyQBQCmcqEytVjoSJTJxiMlAZIRkAuiURkwQ1CIVyJ5AiQjIAdEsyoEFZtfNKnQ/mG6SCkAwAQDdyciGQOi7cBQClkBggvmyfQASEZAAAGkROTAYqZrg1AEAKFZ0mGzZ2lvbFpG9IDyEZ6IbuAKlnIyApqr2u5gp/xVsSysjaFUgAIRlSpcx9s106QLxV/XbJ8d8R5Iy0BiIiJAPd098gxXIlPBvBhEmfpK0JuVin0Jp8NBsvpIWQDGlTfjFZ74C0sw0QazWJibHcClo/VfWHnGsBIDWEZADoqGqdYb1sqqrUFayMFbKlnByf9bjlw5SZkG2MQLeEZEihUnoGubJ/E5In192giZyB1sRYrS+rFbc1uQafx+nOkC5CMqRT+TkZGlKVTyhwvgKNJUaZ0ZYFRE9IBoqQW+ChLgkNpttrdOUiWeNtMXQn4nWjrMmlc/0s/Q7J0cwnrQEkQ58MQDHCnj3b3b+SqqTeSgN83zrSMYTqqnmjnMvksvVuF2tzcrTmC1JISIbUKr1LFe9kXP0LmxZLnM5LcM8yso+ud00NtTbRZWXXshv3+u4Vys7Ipf+aTRlSRkiGNKsoJ3foM9SloxTPfkuXn6qxk7M8XONJ00AizZltObmMKZazO2iN47mkNf+lD7SOjDYBEkNIhpTL77NL6eIU9vLZrp/uTraISUajXv2QHudiJfMnJkqYr2nvCuoKU2c1ysmV/mLZWs8RLmc7Kyshu7I1pI+QDGTKicql/1KDJOEeVHaKc66E19aCXmHpzDPKU1YLXJOJ9fpeudb3ytbuXWuXW3ML/Cey6QGJICQDBeXWHihDz/OtxCp97VjcXTNfqFyE9diWSdWuvNs67jqTqUFULruAXMkbxmtCQG0IyUB7uTqVLVmQDlUCWEhELhdlPbmc04XL/QC5+X9V6yzlXBQ3YSrxlJGoS8lAggjJQAfdnHMM6C6TGGVfVquyiJubn5fbassV7Udy7aZYMQkZKIGQDHSnfedAYCbNdJSpmUgHXZd5J+MoPkMh4LZNKVvkFHML/hWREqfV+JfQAHohJAPFqOWlYCAmdG2pizicnBzpwO/W/7T9d/5Usx1+nqneBlejm0VVcyJArQnJQPFycjKpoWtLylWnwc+1+2+2BtuZhAyUoykDACxA15aGUcnKXOUNIb4JGUg7IRkoiT4HDc9KTgOp4RWhY6bMTx75+dBAEgnJAFCgc0zjqfDWwkncKOLwmTUmkGDOSQZKlYYzk0vq3DhPu1T6jtCzSJvZ1qt35Yq+vnQ3k8gkp60rv4VpvfmTBgoQkoFyJKLDVLOOToc3kpkzYjBULG45OZOEI6QVtTwSMlAgJANlK3QmatxtinknppiP1xhBWm8SkqM14VYcc+Ock2OVkDWPkGxCMlC5LnsD0XakGqzD0cPXiXN+1u2DWoo6kbbWkqPIyZmYtVRRXJ1MDRloR0gGqkSHozzmG1AQz5yciU1UjqDBzGUib3c145B4rm4NAJAauQhTYa5+gTAXaUI20BpYgEoyAEBsVeE04MjqyW2Tm/+gBoXlyCJoFeIx0DhUkgEA4qwKQa6lnhz5abi5ataWo55yVRKyyA0NQiUZACDmqlFPjuS+UN1NuqDsyVcxcOYKf0U+VaAhCMkAAPFXxZycqeJQ6V4v5l/rbFmda1lLyNBQDLcGAEiEaoy7jvZSXiW/d6aGctVKyECjUUkGAEi31nJydYZex0U147HUDY1GJRkAICmqVn2dHyIbL/DlJGSgRCrJAAC0uy1StqpnKddO7tP/SMhACVSSAQCSpcrZrG5nKUdpfkLOSchAqYRkAIDEqUFOTvAVrtrGV1fxG0jI0MgMtwYASKJcdcdE54Nm68jrBA29rsn1qyVkaHBCMgBAQuWqfu5wIXTGPi3X6vZOEjI0PiEZACC58pmt+uk1roXlXP6vGkVXCRlSQUgGAEi6XI0uR/1pVM7UPS3n5gfk2r0hkA5CMgBAA8jV7rZNhStHz0/LmVq9d27Bv2pIQoYUEZIBABpDrYZed3jDtrsr5x9Xpbz86XDq+mRVCRnSRUgGAKBiHcvL7RWbnLuuEdczoorHkEZCMgBAI6l5PbnL928vW8pVp2MUSyVkSCkhGQCg8dQ7KreXyLApIUN6CckAAI0qF5ecnCTiMaSdkAwA0MDiVFKOO/EYaNGUAQCgwYl/vTKLgDYqyQAAaaCk3B3xGFiAkAwAkB6icgcSMtCRkAwAkDaickY8BrojJAMApFM6o7JsDPRCSAYASLP0RGXxGCiKkAwAQCFANmRaFo+BEgjJAAAUNFhhWTwGSiYkAwDQQdILy7IxUD4hGQCA7iQoLQvGQDTqG5J7aG01cwAA8RHbtKzTCESsBiG5vJa059/SGgIA1EWHbljtM7N+IFBd1QjJNWgrC2+hlQQAqKPOnbFou4I6e0CtRRWS6zXwRloGAIiVnntl2dJ/BaCmKgzJ8TkpJf9JtLAAAHGmtwbEXdkhOZ5XOBSVAQAAKF9JITkpN8oTlQEAAChHn5pG38rfqoTkKyoDAABQmirfAiryAF7yhbqycjIAAABFqkJIzlb4857kOk+l9wispAwAAEBRIgrJ2bJ/WM775Do/lek1BYvKAAAA9CKKkJwt8rnIdJh4rv2zvUdlORkAAICuVRaSs10+rLUF0nFWSRkAAIAylRWSY3krqE+zb7El5YyoDAAAQHulhORsSU/XR7aEknJGVAYAAKC93kJyra7IFaESS8oZJyoDAACQ16eMpBvPbNxB6SVlORkAACDtihpunYhU3Jmh1wAAAJSkT0IDcJFKzMkZJWUAAIA0a8o0umyhEp4tsibe2McNAAAA6Fbjh+S8bBePen65qAwAAJA6aQnJmQ4l5WJ/AwAAgBRJUUjOM/QaAACA7qQuJGfKGXoNAABAKqQxJGc65OTeU7CcDAAAkApF3Se5IZV4W2R3UQYAAGh8Ka0kF5R4ijIAAACNLO0hOVPaKcrCNAAAQCMTklu4lBcAAAAZIblATgYAAEBI/lSJORkAAIBGIyQvoMTzkwEAAGgoQnJHpWRfORkAAKChCMldyC7wn15fKyoDAAA0CCG5R8XmXzkZAACgEfTJ0JWQenMZItH5CIJZCwAAxJSQ3K22nFxsXE5trC6jit7zr4jQMVH5+AiLEgCA5BGSiyAnd61Kg8zbT1bKqqXIF6hFCQBA8gjJPcn38dWT26nx2deFtxOxqqc2y9SiBAAgGYTkoqU3J8fhsmQiVrTquEwtSgAAYq2OIbmMbnp9etWfpt505eR4XrJbxKpcTJasRQkAQBzVOCRX2Duv2ymOpefk5ErK7axErDLE9sCHhQgAQFzUICRX+/JONepel5iTE9rvT0pCbu/TM8fpRvwXq4UIAEBcVC8k16xfXrvudUPn5CTG4/ZUI7uUrMVqdAAAAPUXeUiuV6c8W+uc3CCSno3bk5PbS/SSVVgGAKBuIgzJde+Ux7BjHfO+fiMl5DylyLzGWLKOegAAUAcVhuQY9sVrlUuT3YFvvHjcQZrzldEBAABQvrJDcsw74lWsKJbebY9VPbnh43FBCofsNuTCNfQaAICaKikkJ/TSx1XrXiesypWeeNxeekqRjb18RWUAAGqkqbiXZZPcBY/+w5c1ufrOwHQm5Lw0fPeULN80r8YAANRID5XkBuuPxqESVa+qpmjR2PVky5dIlLciKe8DAI2mT8p62FGHpbiHL/GpoFFzctoWsXHXUYlqzWk/HcsFAGgEkd8nOf7qG5Zq+e4ScgeNl5NTu4jTc6p55Kq6zgjMAEAjSGFIzkRSjIp9J73e8amY96/DHGyYcOUIiJxcvLqsLQIzAJBU6QzJedk65eRqjxdNQjwuvFJOTpaeF26t56tF2auYHEyp4j35AAAil+aQHJF4ddTr1CfOFvlcm1wPL6rFzEx6uKrtUi7pqEdBjWawnNyDGA43cEo5AJAAKQ/JDdbDrnmfOFvS0128INfdz6q+WJK76GuylCt/k9rVDuXkzmI+Gl9hGQCINZXkelU2In/fOlcXy3v7bkuPtVgsSQxX1V/KUSzZmi9K8mKejTuzcgAAcSQkN4ZadY4jysY9TzvX01PRSlZOruZSjnTJdnHso7ppSDE5cfG4PVEZAIgXITnP4NuS36p6b9xFl7mKveikLPraJeRo3ynb+ahHtZZjJq1BK9EJuUBUrqWS1hkLBYDUEZILyglLFLine truncated
"light": "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 truncated
"video-modal-mobile": "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 truncated
}
+9 -3
View File
@@ -1,8 +1,14 @@
# 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`.
- [ ] L274: `this.relayPills.innerHTML = "";` (Clearing content)
- [x] L274: `this.relayPills.innerHTML = "";` (Clearing content)
- 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.
---
**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.
+21 -3
View File
@@ -103,13 +103,31 @@ Likely a shared root cause in session/identity persistence + multi-account handl
`SignerManager.logout` (previousPubkey/hadRemoteClient), and
`authService.switchProfile` (from/to + whether requestLogin resolved the expected
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
unstable.bitvid.network, exercise (1) fresh load, (2) switch profiles, (3) logout,
and capture the `[nip46-diag]` console lines for each — restore succeeded/FAILED,
switchProfile `matchesTarget`, and disconnect `keepStored`/`hadClient`/caller stack.
- [ ] **THEN fix** based on the captured trace (likely: per-profile NIP-46 session keyed
by pubkey; don't tear the shared client on a scoped logout; reconnect on
switch/restore) and REMOVE the temporary `[nip46-diag]` diagnostics.
If all three behave, close this item and REMOVE the `[nip46-diag]` diagnostics;
if not, the trace now points at whatever the unit contract does NOT cover
(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
- [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
View File
@@ -1,26 +1,37 @@
{
"package": "@bitlogin/widget",
"version": "0.1.0",
"source": {
"repository": "https://github.com/PR0M3TH3AN/bitlogin.git",
"commit": "205b0678cc67ecbde18278b8f46e7f8270fb519c",
"commitDate": "2026-07-31T22:04:01-04:00",
"dirty": false,
"provenance": "attributed",
"toolchain": {
"node": "v22.23.1",
"vite": "^6.1.0"
}
},
"files": {
"bitlogin-shared-B2Rc9khL.js": {
"sha256": "6a9df1b27d73499d3bd488182b3134e0173c6b0bf1b5537cddc0e9ec4073d8e7",
"bytes": 288486
},
"bitlogin-shared-DxcpMAo3.js": {
"sha256": "b6cb73f3544a8c378416c50740a91eaea700c4af3e4f6b131bb3694e7c816758",
"bitlogin-shared--Zs4uvXt.js": {
"sha256": "550434b2ed3df975fc1d8896dca02f8622f2819d1fd9fa288049f2c22baa6bc4",
"bytes": 483718
},
"bitlogin-shared-IsyHQNSG.js": {
"sha256": "bd6d38087df246792cd61b82323f2997ecf478d3abcc3885235f45a925614131",
"bitlogin-shared-BxVSgyOf.js": {
"sha256": "d7f157c2de3d4b81de0055df2582306df9d862706d1dad35ff4096d06e1c1c93",
"bytes": 545712
},
"bitlogin-shared-DTz11m56.js": {
"sha256": "c52f485c6390658e8da75d7c31f01167eb4f8c22d836f9d92e22c2b0885bf79e",
"bytes": 289118
},
"bitlogin.js": {
"sha256": "0d118d599efd30d4c0d0c197d463fd64900e803aa1a84c7880e1780bed9f3aba",
"bytes": 85342
"sha256": "56eeb7584b0117e2e360c80d37a5bfddb2e1f7a8ee6d94995c24043c0e87b906",
"bytes": 77303
},
"cryptoWorker.js": {
"sha256": "4210e0d6e7c5fc33e4df2dd3eedbe9434439600d0e60fcb3cad90699c198d452",
"bytes": 104078
"sha256": "4d569dac3b808d118bf432d628f084c8c1d8ba76d94cf101030ace1077c63b36",
"bytes": 110827
}
}
}
@@ -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) {
for (var Ie = 0; Ie < je.length; Ie++) {
const Ae = je[Ie];
@@ -1,4 +1,4 @@
import { a8 as D2 } from "./bitlogin-shared-B2Rc9khL.js";
import { a9 as D2 } from "./bitlogin-shared-DTz11m56.js";
const wl = {
debug() {
}
@@ -7918,7 +7918,7 @@ let ke;
async function l2() {
try {
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;
} catch (e) {
throw console.error(e), new Error("LNC is not available");
File diff suppressed because it is too large. Load diff
+198 -351
View File
@@ -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";
const S = 6, f = 64;
function u(r = S) {
const e = k(), t = [];
for (let s = 0; s < r; s++)
t.push(e[w(e.length)]);
const a = r * Q;
if (a < f)
throw new Error(`Passphrase of ${r} words provides only ~${a.toFixed(1)} bits; must exceed ${f}.`);
return { kind: "passphrase", secret: t.join(" "), entropyBits: a };
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 k = 6, b = 64;
function d(a = k) {
const e = v(), t = [];
for (let r = 0; r < a; r++)
t.push(e[g(e.length)]);
const s = a * m;
if (s < b)
throw new Error(`Passphrase of ${a} words provides only ~${s.toFixed(1)} bits; must exceed ${b}.`);
return { kind: "passphrase", secret: t.join(" "), entropyBits: s };
}
const b = 64, m = 12, P = /* @__PURE__ */ new Set([
"password",
"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 {
const x = 6e4;
class S {
worker;
pending = /* @__PURE__ */ new Map();
counter = 0;
dead = !1;
constructor() {
const e = ["cryptoWorker", ".js"].join(""), t = new URL(e, import.meta.url);
this.worker = new Worker(t, { type: "module" }), this.worker.addEventListener("message", (s) => {
const n = s.data;
if (!n || typeof n != "object" || typeof n.id != "string") return;
const o = this.pending.get(n.id);
this.worker = new Worker(t, { type: "module" }), this.worker.addEventListener("message", (r) => {
const i = r.data;
if (!i || typeof i != "object" || typeof i.id != "string") return;
const o = this.pending.get(i.id);
if (o)
if (this.pending.delete(n.id), clearTimeout(o.timer), n.ok)
o.resolve(n.result);
if (this.pending.delete(i.id), clearTimeout(o.timer), i.ok)
o.resolve(i.result);
else {
const l = new Error(n.error);
l.name = n.errorName ?? "Error", o.reject(l);
const l = new Error(i.error);
l.name = i.errorName ?? "Error", o.reject(l);
}
});
const a = (s) => () => this.failAll(s);
this.worker.addEventListener("error", a("The BitLogin crypto worker stopped unexpectedly.")), this.worker.addEventListener(
const s = (r) => () => this.failAll(r);
this.worker.addEventListener("error", s("The BitLogin crypto worker stopped unexpectedly.")), this.worker.addEventListener(
"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. */
failAll(e) {
const t = [...this.pending.values()];
this.pending.clear();
for (const a of t)
clearTimeout(a.timer), a.reject(new Error(e));
for (const s of t)
clearTimeout(s.timer), s.reject(new Error(e));
}
call(e, t) {
if (this.dead)
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)}`;
return new Promise((s, n) => {
const s = `${Date.now().toString(36)}-${(this.counter++).toString(36)}`;
return new Promise((r, i) => {
const o = setTimeout(() => {
this.pending.delete(a), n(new Error(`BitLogin's ${e} call timed out.`));
}, R);
this.pending.set(a, { resolve: s, reject: n, timer: o });
const l = { id: a, action: e, payload: t };
this.pending.delete(s), i(new Error(`BitLogin's ${e} call timed out.`));
}, x);
this.pending.set(s, { resolve: r, reject: i, timer: o });
const l = { id: s, action: e, payload: t };
this.worker.postMessage(l);
});
}
@@ -202,57 +143,57 @@ class N {
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.";
function c(r) {
return r.catch((e) => {
throw e.message.includes("No identity is unlocked") ? new Error(M) : e;
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(a) {
return a.catch((e) => {
throw e.message.includes("No identity is unlocked") ? new Error(L) : e;
});
}
function B(r, e) {
function C(a, e) {
return {
async getPublicKey() {
const { publicKey: t } = await c(r.getPublicKey());
const { publicKey: t } = await c(a.getPublicKey());
return t;
},
async signEvent(t) {
return c(r.signEvent(t));
return c(a.signEvent(t));
},
async getRelays() {
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;
},
nip44: {
async encrypt(t, a) {
const { ciphertext: s } = await c(r.nip44Encrypt({ peerPublicKey: t, plaintext: a }));
return s;
async encrypt(t, s) {
const { ciphertext: r } = await c(a.nip44Encrypt({ peerPublicKey: t, plaintext: s }));
return r;
},
async decrypt(t, a) {
const { plaintext: s } = await c(r.nip44Decrypt({ peerPublicKey: t, payload: a }));
return s;
async decrypt(t, s) {
const { plaintext: r } = await c(a.nip44Decrypt({ peerPublicKey: t, payload: s }));
return r;
}
},
nip04: {
async encrypt(t, a) {
const { ciphertext: s } = await c(r.nip04Encrypt({ peerPublicKey: t, plaintext: a }));
return s;
async encrypt(t, s) {
const { ciphertext: r } = await c(a.nip04Encrypt({ peerPublicKey: t, plaintext: s }));
return r;
},
async decrypt(t, a) {
const { plaintext: s } = await c(r.nip04Decrypt({ peerPublicKey: t, payload: a }));
return s;
async decrypt(t, s) {
const { plaintext: r } = await c(a.nip04Decrypt({ peerPublicKey: t, payload: s }));
return r;
}
},
_bitlogin: !0
};
}
function T(r) {
const e = r.getAttribute("vault-relays"), t = r.getAttribute("discovery-relays");
function E(a) {
const e = a.getAttribute("vault-relays"), t = a.getAttribute("discovery-relays");
return {
vaultRelayUrls: e ? e.split(",").map((a) => a.trim()).filter(Boolean) : void 0,
discoveryRelayUrls: t ? t.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((s) => s.trim()).filter(Boolean) : void 0
};
}
const q = (
const R = (
/* css */
`
:host {
@@ -493,16 +434,16 @@ button.link {
}
@keyframes bl-spin { to { transform: rotate(360deg); } }
`
), y = "bc:config", U = /^nostr\+walletconnect:\/\//iu;
), y = "bc:config", $ = /^nostr\+walletconnect:\/\//iu;
let h = null;
function F(r) {
function N(a) {
if (!h) {
try {
localStorage.removeItem(y);
} catch {
}
h = import("./bitlogin-shared-IsyHQNSG.js").then((e) => (e.init({
appName: r,
h = import("./bitlogin-shared-BxVSgyOf.js").then((e) => (e.init({
appName: a,
filters: ["nwc"],
showBalance: !1,
autoConnect: !1,
@@ -510,7 +451,7 @@ function F(r) {
providerConfig: {
nwc: {
authorizationUrlOptions: {
name: r,
name: a,
requestMethods: ["pay_invoice"]
}
}
@@ -521,19 +462,19 @@ function F(r) {
}
return h;
}
function I(r) {
const e = r instanceof Error ? r.message : String(r);
function P(a) {
const e = a instanceof Error ? a.message : String(a);
return /closed|cancell?ed|dismissed|aborted/iu.test(e);
}
async function O(r) {
const e = await F(r);
async function B(a) {
const e = await N(a);
try {
const t = await e.requestProvider(), a = e.getConnectorConfig()?.nwcUrl, s = t.client?.nostrWalletConnectUrl, n = typeof a == "string" ? a : typeof s == "string" ? s : "";
if (!U.test(n.trim()))
const t = await e.requestProvider(), s = e.getConnectorConfig()?.nwcUrl, r = t.client?.nostrWalletConnectUrl, i = typeof s == "string" ? s : typeof r == "string" ? r : "";
if (!$.test(i.trim()))
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) {
if (I(t)) return null;
if (P(t)) return null;
throw t;
} finally {
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;
worker;
vaultRelayUrls = [];
@@ -560,16 +513,6 @@ class D extends HTMLElement {
savedCheckbox = !1;
recoveryPhrase = "";
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.
importKey = "";
importPreviewNpub = "";
@@ -608,6 +551,7 @@ class D extends HTMLElement {
vaultUnsaved = !1;
vaultUnsavedReason;
vaultConnections = null;
vaultIntegrityWarnings = [];
/** offerNwcConnection state: the app already holds this URI; the only
* question on screen is whether a copy enters the user's vault. */
/** Claimed synchronously by offerNwcConnection before any await (see there). */
@@ -616,11 +560,11 @@ class D extends HTMLElement {
constructor() {
super(), this.root = this.attachShadow({ mode: "open" });
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() {
const e = T(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)
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("change", (t) => void this.onFileChange(t)), this.render(), this.installedProvider = C(this.worker, () => this.vaultRelayUrls), !window.nostr)
try {
window.nostr = this.installedProvider;
} catch {
@@ -681,17 +625,17 @@ class D extends HTMLElement {
*/
async requestNwcConnection(e = {}) {
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";
return new Promise((s) => {
this.vaultRequest = { appName: a, reason: e.reason, origin: t, stage: "auth", resolve: s }, this.dispatchEvent(new CustomEvent("bitlogin-request-pending")), (async () => {
const t = window.location.origin, s = e.appName?.trim() || window.location.hostname || "This app";
return new Promise((r) => {
this.vaultRequest = { appName: s, reason: e.reason, origin: t, stage: "auth", resolve: r }, this.dispatchEvent(new CustomEvent("bitlogin-request-pending")), (async () => {
try {
if (!(await this.worker.getSessionStatus()).unlocked) {
this.goto("login");
return;
}
await this.continueVaultRequest();
} catch (n) {
this.fail(n);
} catch (i) {
this.fail(i);
}
})();
});
@@ -766,8 +710,8 @@ class D extends HTMLElement {
return;
}
this.vaultUnsaved = !1;
const a = await this.worker.vaultFindForOrigin();
a.connection ? (this.vaultCandidate = a.connection, this.goto("vault-consent")) : this.goto("vault-import");
const s = await this.worker.vaultFindForOrigin();
s.connection ? (this.vaultCandidate = s.connection, this.goto("vault-consent")) : this.goto("vault-import");
}
async approveVaultCandidate() {
const e = this.vaultCandidate;
@@ -786,7 +730,7 @@ class D extends HTMLElement {
if (e) {
this.setBusy(!0);
try {
const t = await O(e.appName);
const t = await B(e.appName);
if (t === null) {
this.setBusy(!1);
return;
@@ -809,20 +753,20 @@ class D extends HTMLElement {
}
}
async saveAndShareVaultUri(e, t) {
const a = this.vaultRequest;
if (!a) return;
const s = this.vaultRequest;
if (!s) return;
if (this.vaultUnsaved) {
this.setBusy(!1), this.finishVaultRequest(e);
return;
}
const s = t.trim() || `${a.appName} wallet`;
await this.worker.vaultSaveNwc({ uri: e, label: s }), this.setBusy(!1), this.finishVaultRequest(e), this.flashSuccess(this.screen, "Wallet connected");
const r = t.trim() || `${s.appName} wallet`;
await this.worker.vaultSaveNwc({ uri: e, label: r }), this.setBusy(!1), this.finishVaultRequest(e), this.flashSuccess(this.screen, "Wallet connected");
}
async loadVaultManage() {
this.vaultConnections = null, this.goto("vault-manage");
this.vaultConnections = null, this.vaultIntegrityWarnings = [], this.goto("vault-manage");
try {
const e = await this.worker.vaultList();
this.vaultConnections = e.connections;
this.vaultConnections = e.connections, this.vaultIntegrityWarnings = T(e);
} catch (e) {
this.errorMessage = e instanceof Error ? e.message : String(e), this.vaultConnections = [];
}
@@ -930,32 +874,6 @@ class D extends HTMLElement {
field(e) {
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
* Management API (Chromium-based browsers only — Firefox and Safari never implemented
@@ -970,10 +888,10 @@ class D extends HTMLElement {
*/
async offerToSaveCredential(e, t) {
try {
const a = window.PasswordCredential;
if (!a || !navigator.credentials?.store) return;
const s = new a({ id: e, password: t, name: e });
await navigator.credentials.store(s);
const s = window.PasswordCredential;
if (!s || !navigator.credentials?.store) return;
const r = new s({ id: e, password: t, name: e });
await navigator.credentials.store(r);
} catch {
}
}
@@ -1032,28 +950,25 @@ class D extends HTMLElement {
this.goto("confirm-phrase");
return;
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;
case "goto-export":
this.goto("export");
return;
case "regenerate-credential":
this.generatedCredential = u().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();
this.generatedCredential = d().secret, this.savedCheckbox = !1, this.render();
return;
case "copy-credential": {
const s = this.root.querySelector("#credential-box"), n = t, o = n.textContent ?? "Copy", l = (v) => {
n.textContent = v, setTimeout(() => {
n.isConnected && (n.textContent = o);
const r = this.root.querySelector("#credential-box"), i = t, o = i.textContent ?? "Copy", l = (f) => {
i.textContent = f, setTimeout(() => {
i.isConnected && (i.textContent = o);
}, 2e3);
};
if (!s || !navigator.clipboard?.writeText) {
if (!r || !navigator.clipboard?.writeText) {
l("Copy not available — select the text manually");
return;
}
navigator.clipboard.writeText(s.textContent ?? "").then(
navigator.clipboard.writeText(r.textContent ?? "").then(
() => l("Copied"),
() => l("Copy failed — select the text manually")
);
@@ -1068,8 +983,8 @@ class D extends HTMLElement {
case "logout":
return this.logout();
case "rollback-retry": {
const s = this.pendingRollback?.kind;
this.pendingRollback = null, this.goto(s === "change-password" ? "change-password" : "login");
const r = this.pendingRollback?.kind;
this.pendingRollback = null, this.goto(r === "change-password" ? "change-password" : "login");
return;
}
case "rollback-continue":
@@ -1080,9 +995,9 @@ class D extends HTMLElement {
}
async onSubmit(e) {
if (e.preventDefault(), !e.isTrusted) return;
const a = e.target.dataset.form;
const s = e.target.dataset.form;
try {
switch (a) {
switch (s) {
case "import-key":
return await this.handlePreviewImport();
case "create-name":
@@ -1104,31 +1019,10 @@ class D extends HTMLElement {
default:
return;
}
} catch (s) {
this.fail(s);
} catch (r) {
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
* 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) {
const t = e.target;
if (!(t instanceof HTMLInputElement) || t.name !== "offlineExportFile" || t.type !== "file") return;
const a = t.files?.[0];
if (!a) {
const s = t.files?.[0];
if (!s) {
this.offlineExportFile = null, this.offlineExportFileNotice = void 0, this.render();
return;
}
try {
const s = await a.text();
this.offlineExportFile = x(JSON.parse(s)), this.offlineExportFileNotice = `Loaded recovery export from ${new Date(this.offlineExportFile.created_at * 1e3).toLocaleString()}.`;
} catch (s) {
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)}`;
const r = await s.text();
this.offlineExportFile = w(JSON.parse(r)), this.offlineExportFileNotice = `Loaded recovery export from ${new Date(this.offlineExportFile.created_at * 1e3).toLocaleString()}.`;
} catch (r) {
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();
}
@@ -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();
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() {
this.savedCheckbox = this.root.querySelector("#saved-check")?.checked ?? !1;
let 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;
}
const e = this.generatedCredential;
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;
}
this.setBusy(!0);
@@ -1201,8 +1087,8 @@ class D extends HTMLElement {
importKey: this.importKey || void 0
});
this.importKey = "", this.importPreviewNpub = "", this.recoveryPhrase = t.recoveryPhrase;
const a = this.recoveryPhrase.split(" "), s = K(a.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({
const s = this.recoveryPhrase.split(" "), r = M(s.length, 3);
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,
generalRelays: this.vaultRelayUrls,
dmRelays: this.vaultRelayUrls
@@ -1237,7 +1123,7 @@ class D extends HTMLElement {
if (!e.restored || !e.everydayPublicKey) return;
this.session = {
publicKey: e.everydayPublicKey,
npub: d(e.everydayPublicKey),
npub: u(e.everydayPublicKey),
accountId: e.accountId
}, this.noteSignerClaim(this.claimSigner()), this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: e.everydayPublicKey } })), this.goto("dashboard");
} catch {
@@ -1248,17 +1134,17 @@ class D extends HTMLElement {
* 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.
*/
async attemptLogin(e, t, a = !1) {
async attemptLogin(e, t, s = !1) {
this.setBusy(!0);
try {
const s = await this.worker.login({ loginName: e, password: t, acknowledgeRollback: a });
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);
} catch (s) {
if (s instanceof Error && s.name === "RollbackDetectedError") {
this.pendingRollback = { kind: "login", loginName: e, password: t }, this.rollbackMessage = s.message, this.busy = !1, this.goto("rollback-confirm");
const r = await this.worker.login({ loginName: e, password: t, acknowledgeRollback: s });
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 (r) {
if (r instanceof Error && r.name === "RollbackDetectedError") {
this.pendingRollback = { kind: "login", loginName: e, password: t }, this.rollbackMessage = r.message, this.busy = !1, this.goto("rollback-confirm");
return;
}
this.fail(s);
this.fail(r);
}
}
async handleRollbackContinue() {
@@ -1277,7 +1163,7 @@ class D extends HTMLElement {
generalRelaysCount: t.generalRelays.length,
dmRelaysCount: t.dmRelays.length,
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() {
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();
return;
}
let t = this.newCredentialAfterRecovery;
if (this.manualPasswordMode) {
const { password: a, error: s } = this.readManualPassword(e);
if (s) {
this.errorMessage = s, this.render();
return;
}
t = a;
}
const t = this.newCredentialAfterRecovery;
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() {
const e = this.field("oldPassword");
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;
}
const e = this.field("oldPassword"), t = this.changePasswordNewCredential;
await this.attemptChangePassword(e, t);
}
/** 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);
try {
await this.worker.changePassword({
loginName: this.loginName,
oldPassword: e,
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);
} catch (s) {
if (s instanceof Error && s.name === "RollbackDetectedError") {
this.pendingRollback = { kind: "change-password", oldPassword: e, newPassword: t }, this.rollbackMessage = s.message, this.busy = !1, this.goto("rollback-confirm");
} catch (r) {
if (r instanceof Error && r.name === "RollbackDetectedError") {
this.pendingRollback = { kind: "change-password", oldPassword: e, newPassword: t }, this.rollbackMessage = r.message, this.busy = !1, this.goto("rollback-confirm");
return;
}
this.fail(s);
this.fail(r);
}
}
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");
s.href = a, s.download = "bitlogin-recovery-export.json", s.click(), URL.revokeObjectURL(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");
r.href = s, r.download = "bitlogin-recovery-export.json", r.click(), URL.revokeObjectURL(s);
}
async handleSignTestEvent() {
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" />
</svg>
</span>
<p class="success-label">${i(e)}</p>
<p class="success-label">${n(e)}</p>
</div>
`;
}
@@ -1376,10 +1245,10 @@ class D extends HTMLElement {
}, 1100);
}
renderError() {
return this.errorMessage ? `<div class="notice error">${i(this.errorMessage)}</div>` : "";
return this.errorMessage ? `<div class="notice error">${n(this.errorMessage)}</div>` : "";
}
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
* relying on the host page having loaded any particular font -- shown once, on the
@@ -1414,7 +1283,7 @@ class D extends HTMLElement {
</button>
</form>
${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="link" data-action="goto-welcome">Back</button>
`;
@@ -1433,36 +1302,21 @@ class D extends HTMLElement {
`;
case "create-credential": {
const e = this.busy ? `<span class="spinner"></span>${this.importKey ? "Importing…" : "Creating account…"}` : this.importKey ? "Import account" : "Create account";
return this.manualPasswordMode ? `
<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>
` : `
return `
<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>
<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="regenerate-credential">Generate a different one</button>
${this.renderError()}
<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">
<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 my own password instead (not recommended)</button>
`;
}
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="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">
${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>
${this.renderError()}
<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>
<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>
${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" : ""}>
${this.busy ? '<span class="spinner"></span>Recovering…' : "Continue"}
</button>
@@ -1528,33 +1382,30 @@ class D extends HTMLElement {
<button class="link" data-action="goto-welcome">Back</button>
`;
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 `
<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>
${e}
<p class="sub">Now set a new login name and password.</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>`}
<p class="sub">Now set a new login name. BitLogin generated a new high-entropy password for this account.</p>
<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()}
<form data-form="recover-new-credentials">
<label for="newLoginName">New login name</label>
<input type="text" name="newLoginName" id="newLoginName" autocomplete="off" required />
${this.manualPasswordMode ? this.renderManualPasswordFields() : ""}
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>${t}</button>
</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":
return `
<h2>Signed in</h2>
${this.renderWarnings()}
<p class="pubkey">${i(this.session?.npub ?? "")}</p>
<p class="pubkey">${n(this.session?.npub ?? "")}</p>
${this.renderError()}
<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>
<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>
@@ -1564,16 +1415,16 @@ class D extends HTMLElement {
case "vault-consent": {
const e = this.vaultRequest, t = this.vaultCandidate;
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 `
<h2>Share a wallet with ${i(e.appName)}?</h2>
${e.reason ? `<p class="sub">Reason given: ${i(e.reason)}</p>` : ""}
<h2>Share a wallet with ${n(e.appName)}?</h2>
${e.reason ? `<p class="sub">Reason given: ${n(e.reason)}</p>` : ""}
${this.renderError()}
<div class="credential-box">
<strong>${i(t.label)}</strong><br />
Connected ${i(a)} · wallet ${i((t.walletPubkey ?? "").slice(0, 10))}…
<strong>${n(t.label)}</strong><br />
Connected ${n(s)} · wallet ${n((t.walletPubkey ?? "").slice(0, 10))}…
</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" : ""}>
${this.busy ? '<span class="spinner"></span>Sharing…' : "Use this wallet"}
</button>
@@ -1586,8 +1437,8 @@ class D extends HTMLElement {
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>` : "";
return `
<h2>Connect a wallet for ${i(e.appName)}</h2>
${e.reason ? `<p class="sub">Reason given: ${i(e.reason)}</p>` : ""}
<h2>Connect a wallet for ${n(e.appName)}</h2>
${e.reason ? `<p class="sub">Reason given: ${n(e.reason)}</p>` : ""}
${t}
${this.renderError()}
<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>
<input type="password" name="vaultUri" id="vaultUri" autocomplete="off" placeholder="nostr+walletconnect://…" />
<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" : ""}>
${this.vaultUnsaved ? "Share without saving" : "Save and share"}
</button>
@@ -1611,26 +1462,26 @@ class D extends HTMLElement {
const e = this.vaultOffer;
return e ? `
<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()}
<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" : ""}>
${this.busy ? '<span class="spinner"></span>Saving…' : "Save to BitLogin"}
</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>';
}
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(
(a) => `
(s) => `
<div class="credential-box">
<strong>${i(a.label)}</strong> <span class="sub">(${i(a.connectionType)})</span><br />
${a.origin ? `Linked to ${i(a.origin)}` : "Not linked to an app"}
<strong>${n(s.label)}</strong> <span class="sub">(${n(s.connectionType)})</span><br />
${s.origin ? `Linked to ${n(s.origin)}` : "Not linked to an app"}
<div>
${a.origin ? `<button class="link" type="button" data-action="vault-unbind" data-id="${i(a.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>
${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="${n(s.connectionId)}" ${this.busy ? "disabled" : ""}>Remove from BitLogin</button>
</div>
</div>`
).join("");
@@ -1638,6 +1489,7 @@ class D extends HTMLElement {
<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>
${this.renderError()}
${this.vaultIntegrityWarnings.map((s) => `<div class="notice warn">${n(s)}</div>`).join("")}
${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>
<button class="link" data-action="goto-dashboard">Back</button>
@@ -1646,7 +1498,7 @@ class D extends HTMLElement {
case "rollback-confirm":
return `
<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>
<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>
@@ -1656,19 +1508,16 @@ class D extends HTMLElement {
return `
<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>
${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()}
<form data-form="change-password">
<label for="oldPassword">Current password</label>
<input type="password" name="oldPassword" id="oldPassword" autocomplete="current-password" required />
${this.manualPasswordMode ? this.renderManualPasswordFields() : ""}
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>
${this.busy ? '<span class="spinner"></span>Rotating…' : "Confirm rotation"}
</button>
</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>
`;
case "export":
@@ -1676,11 +1525,11 @@ class D extends HTMLElement {
<h2>Export identity</h2>
<p class="sub">Your public identity (npub) is safe to share. Your private key (nsec) is not.</p>
<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>
${this.renderError()}
<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
)}</div>` : ""}
<button class="link" data-action="goto-dashboard">Back</button>
@@ -1690,30 +1539,28 @@ class D extends HTMLElement {
}
}
}
function K(r, e) {
const t = Array.from({ length: r }, (s, n) => n), a = [];
for (let s = 0; s < e && t.length > 0; s++) {
const n = Math.floor(Math.random() * t.length);
a.push(t.splice(n, 1)[0]);
}
return a.sort((s, n) => s - n);
function M(a, e) {
const t = Array.from({ length: a }, (r, i) => i), s = [];
for (let r = 0; r < e && t.length > 0; r++)
s.push(t.splice(g(t.length), 1)[0]);
return s.sort((r, i) => r - i);
}
function i(r) {
return r.replace(/[&<>"']/gu, (e) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[e]);
function n(a) {
return a.replace(/[&<>"']/gu, (e) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[e]);
}
customElements.get("bitlogin-auth") || customElements.define("bitlogin-auth", D);
customElements.get("bitlogin-auth") || customElements.define("bitlogin-auth", I);
window.bitlogin = {
version: "0.1.0",
isActiveSigner() {
return window.nostr?._bitlogin === !0;
},
releaseSigner() {
const r = window;
return r.nostr?._bitlogin === !0 ? (delete r.nostr, window.dispatchEvent(new CustomEvent("bitlogin-signer-released")), !0) : !1;
const a = window;
return a.nostr?._bitlogin === !0 ? (delete a.nostr, window.dispatchEvent(new CustomEvent("bitlogin-signer-released")), !0) : !1;
}
};
export {
D as BitLoginAuthElement,
N as WorkerClient,
B as createNip07Provider
I as BitLoginAuthElement,
S as WorkerClient,
C as createNip07Provider
};
+1139 -899
View File
File diff suppressed because it is too large. Load diff