Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ jobs:
- name: Install
run: npm ci

# public/js/vendor/* is generated by scripts/build-vendor.mjs but
# COMMITTED (deploy never builds). Rebuild and fail on any drift between
# the entry/deps and the committed artifact. `git diff` catches modified
# tracked files but silently ignores NEW untracked output, so also
# require `git status --porcelain` to be empty for the directory.
- name: Vendor bundle drift check
run: |
npm run build:vendor
git diff --exit-code public/js/vendor/
test -z "$(git status --porcelain public/js/vendor/)"

- name: Typecheck
run: npm run typecheck

Expand Down
127 changes: 127 additions & 0 deletions docs/manual-signer-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Manual signer test checklist

Real-device acceptance checks for the signer expansion (NIP-46 remote
signers, NIP-55 Amber, pasted local key) plus the NIP-07 regression. Run
against a deployed instance (workers.dev preview or nbread.lol) — the
NIP-55 flows need a real Android device with Amber installed, and the
NIP-46 flows need live wss:// relays. Automated coverage (unit +
integration) already gates CI; this list covers only what a browser and a
phone can prove.

Conventions used below:

- "editor" = `/dashboard/posts/new` or `/dashboard/editor?slug=…`
- Signer state lives under `localStorage` keys prefixed `nbread:` — clear
them (or use "Forget this signer" on `/login`) to reset between sections.

## 1. Amber via bunker:// (NIP-46) — issue #2 acceptance

End-to-end on any browser (desktop is fine; Amber runs on the phone and
talks over relays).

- [ ] In Amber, create a bunker:// connection URI and copy it.
- [ ] On `/login`, choose "Remote signer — Amber, nsec.app (NIP-46)",
paste the `bunker://…` URI, press Connect; approve the connect
request in Amber.
- [ ] Sign-in completes: approving the kind 22242 challenge in Amber lands
you on `/dashboard` with your handle/npub shown.
- [ ] Publish: write a post in the editor, press "Sign & publish", approve
the kind 30023 in Amber → redirected to `/dashboard`, post renders at
`https://<handle>.nbread.lol/<slug>`.
- [ ] Edit: open the same post, change the body, "Sign & republish",
approve in Amber → the blog page shows the new content (same slug).
- [ ] Delete: "Delete post", confirm the dialog, approve the kind 5 in
Amber → post disappears from the blog and the dashboard list.

## 2. nsec.app remote signer (NIP-46, incl. auth_url)

- [ ] Pair via `bunker://` from nsec.app (or "Generate nostrconnect://
link" and paste it into nsec.app) on `/login`.
- [ ] When nsec.app answers with an auth_url, the login page surfaces the
approval link as a link/prompt — it must NOT auto-navigate. Open it,
approve, and confirm the original request then completes.
- [ ] Sign in, publish a post, and delete it — each sign request appears
in nsec.app for approval and completes after approving.

## 3. NIP-55 — Amber on Android Chrome

All on the phone; the flow is full-page redirects into the Amber app and
back.

- [ ] Sign in: `/login` → "Amber on this device (Android)" → "Sign in with
Amber" → Amber opens (get_public_key), approve → returned to
`/login`, then approve the challenge signature → session established.
- [ ] Publish: editor → "Sign & publish" → page hands off to Amber →
approve → returned to the editor, which resumes ("Resuming…"),
mirrors, broadcasts, and lands on `/dashboard`; post is live.
- [ ] Delete: open a post → "Delete post" → confirm → approve the kind 5
in Amber → resumed on return, post gone.
- [ ] Cancel mid-sign: start a publish, but REJECT (or back out of) the
request in Amber. Back in the editor the status says signing was
cancelled, nothing was published, and the draft-notice offers to
restore your text — restoring brings the full draft back.
- [ ] Oversized post: paste content past the 256 KiB publish cap (the
counter turns red: "too large to publish") and attempt "Sign &
publish" with Amber. The flow must refuse cleanly — either the
intent hand-off fails or `/api/mirror` rejects the event on resume —
with an error message, no partial publish, and the draft intact.
- [ ] Reload safety: after any completed Amber round-trip, reloading the
page must not replay the signature (the callback params are stripped
from the URL).

## 4. Pasted secret key (local) — desktop + Android

- [ ] Desktop: `/login` → "Paste secret key (not recommended)" → the red
unencrypted-localStorage warning is visible → paste an `nsec1…` →
the derived npub is shown for confirmation → "Sign in as this key"
completes login.
- [ ] Publish and delete a post from the editor — signing happens in-page
with no prompts and no redirects.
- [ ] "Forget this signer" on `/login` clears the stored key
(`nbread:signer:nsec` gone from localStorage) and returns you to the
method picker.
- [ ] Re-import: paste the same nsec again and sign in — works identically
(and hex-form input works too).
- [ ] Repeat sign-in + publish on Android Chrome.

## 5. NIP-07 regression (Alby or nos2x)

Behavior must be identical to before the signer expansion.

- [ ] `/login` → "Browser extension (NIP-07)" → "Sign in with extension" →
extension approval prompt → `/dashboard`.
- [ ] Publish, edit, and delete a post from the editor; each action
prompts the extension once and completes.
- [ ] With the extension disabled, the panel reports that no NIP-07
extension (window.nostr) was found instead of failing silently.

## 6. Negative checks (any signer unless noted)

- [ ] Expired challenge: fetch `/login`, wait more than 5 minutes (the
nonce TTL is 300 s), then complete the sign-in — the server rejects
the stale challenge and the page shows the error; retrying fresh
succeeds.
- [ ] Dead bunker relay (NIP-46): pair using a `bunker://` URI whose relay
is unreachable — the connect times out with a clear error (60 s
request / 120 s pairing budget), no hang, no crash; the page stays
usable.
- [ ] Wrong-identity signer vs session: sign in as key A, then on `/login`
reconfigure the browser's signer to key B WITHOUT logging out. In the
editor, publishing must refuse with "This browser's signer is a
different Nostr identity than the one signed in…" and publish
nothing.
- [ ] NIP-55 expired pending record: start an Amber sign, leave the phone
idle past the pending TTL, then complete the callback — the editor
reports the request expired, nothing publishes, the draft survives.

## 7. Devtools network audit (secret never leaves the page)

With the pasted-key (local) signer configured:

- [ ] Open devtools → Network, sign in, publish, and delete a post.
- [ ] Inspect EVERY request (fetches, WebSocket frames, and navigations,
including `intent:`/callback URLs on Android): no request body, URL,
or header ever contains the nsec or the 64-hex secret key — only
signed events (pubkey + sig) and the public key ever leave the page.
- [ ] Repeat the sweep once for a NIP-46 session: WebSocket frames to the
bunker relay carry only encrypted kind 24133 envelopes.
8 changes: 8 additions & 0 deletions docs/ops.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,3 +320,11 @@ see the manual-check notes at the bottom of `scripts/smoke.sh`).
Rollback: `wrangler deployments list` + `wrangler rollback` restore the
previous Worker version; DNS records can stay (the guard 404s anything it
does not recognize).

## 7. Vendor crypto bundle

`public/js/vendor/nostr-crypto.js` is generated from
`scripts/vendor/crypto-entry.js` by `npm run build:vendor` (esbuild,
devDependency only — deploy never builds) and committed unminified. Never
edit the artifact by hand: edit the entry, rebuild, commit both. CI rebuilds
and fails on any drift between the entry and the committed bundle.
2 changes: 2 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"test": "vitest run",
"test:watch": "vitest",
"fixtures": "node --experimental-strip-types scripts/gen-fixtures.ts",
"build:vendor": "node scripts/build-vendor.mjs",
"migrate:local": "wrangler d1 migrations apply nbread --local",
"smoke": "bash scripts/smoke.sh local",
"cf-typegen": "wrangler types"
Expand All @@ -30,8 +31,10 @@
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.18.4",
"@noble/ciphers": "2.1.1",
"@types/markdown-it": "^14.1.2",
"@types/node": "^26.1.1",
"esbuild": "0.28.1",
"fast-xml-parser": "^5.10.0",
"nostr-tools": "^2.23.9",
"typescript": "^7.0.2",
Expand Down
38 changes: 38 additions & 0 deletions public/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -532,3 +532,41 @@ body.editor-wide {
border-left: 3px solid var(--border);
color: var(--muted);
}

/* --- Login: signer method picker + panels ---------------------------------
The picker is a vertical stack of method buttons; each panel is a small
form block. The .danger paragraph follows the existing danger color
convention (see .editor-meta.danger above). */

.signer-picker button {
display: block;
width: 100%;
max-width: 28rem;
margin: 0.5rem 0;
text-align: left;
}

.signer-panel {
margin: 1rem 0;
}

.signer-panel input[type="text"],
.signer-panel input[type="password"] {
max-width: 28rem;
}

.signer-npub {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.85rem;
word-break: break-all;
}

.signer-panel p.danger {
color: #c62828;
}

@media (prefers-color-scheme: dark) {
.signer-panel p.danger {
color: #ef5350;
}
}
50 changes: 42 additions & 8 deletions public/js/README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
# public/js

Client-side NIP-07 glue plus the hand-rolled editor (no build step, no
dependencies — every file is a plain IIFE served as-is):
Client-side signer glue plus the hand-rolled editor (no build step at serve
time, no runtime dependencies — every file is a plain IIFE served as-is):

- `signer-core.js` — DOM-free pure helpers (`globalThis.NbreadSignerCore`):
nsec decoding, pubkey normalization, the NIP-55 (Amber) intent URL
builder + callback parser, and the pending-record make/validate pair the
redirect flow persists across page unloads. Unit-tested directly.
- `signer.js` — the dispatcher (`globalThis.NbreadSigner`): one signing
seam (`ready`/`getPublicKey`/`signEvent`) over four backends — NIP-07
extension, pasted local key (signs in-page, key never leaves the browser),
NIP-55 Amber redirects (incl. `resumePending()` after the callback), and
a `register()`ed NIP-46 backend. Owns the `nbread:signer:*` localStorage
keys and `forget()`.
- `signer-nip46.js` — NIP-46 remote-signer client
(`globalThis.NbreadNip46`): bunker:// / nostrconnect:// pairing, the
NIP-44 (with legacy NIP-04 fallback) request envelope, auth_url
surfacing, and schnorr verification of every returned event; registers
itself as the dispatcher's "nip46" backend on load.
- `login.js` (P4) — fetches a one-time challenge, signs the kind 22242 auth
event via `window.nostr`, POSTs it to `/login`. Keys never leave the
extension.
- `editor.js` (P5) — builds kind 30023 / kind 5 events, signs via the
extension, broadcasts to relays, POSTs to `/api/mirror`. Also owns the
event via `NbreadSigner`, POSTs it to `/login`. Also owns the login-page
method picker and per-method panels.
- `editor.js` (P5) — builds kind 30023 / kind 5 events, signs via
`NbreadSigner`, broadcasts to relays, POSTs to `/api/mirror`. Also owns the
server-rendered preview fetch: it listens for the
`nbread:preview-requested` event (dispatched by the Preview tab),
caches the last previewed value, and calls
Expand All @@ -24,5 +39,24 @@ dependencies — every file is a plain IIFE served as-is):
draft autosave (`window.NbreadDraft`). All textarea mutations go
through one `execCommand("insertText")` seam so native undo survives.

Load order on the editor page matters: `editor-md.js` →
`editor-toolbar.js` → `editor.js`.
Load order matters (classic `<script src>` tags, no modules). The signer
stack always loads first: `vendor/nostr-crypto.js` → `signer-core.js` →
`signer.js` → `signer-nip46.js`. The login page then adds `login.js`; the
editor page adds `editor-md.js` → `editor-toolbar.js` → `editor.js`.

## vendor/

- `vendor/nostr-crypto.js` — GENERATED-BUT-COMMITTED crypto bundle
(`globalThis.NbreadCrypto`): hex/utf-8/base64 utils, bech32 + npub/nsec
codecs (same BIP-173 implementation as `src/nostr/nip19.ts`), NIP-01
event ids byte-identical to `src/nostr/event.ts`, BIP-340 schnorr
sign/verify, NIP-44 v2, and legacy NIP-04. Built from
`scripts/vendor/crypto-entry.js` by `npm run build:vendor`
(esbuild, devDependency only — deploy never builds) and committed
unminified for auditability. Do NOT edit the artifact by hand: edit the
entry, rebuild, and commit both. CI rebuilds and fails on drift
(`git diff --exit-code` plus a `git status --porcelain` check so new
untracked build output also fails). All randomness comes from
`crypto.getRandomValues`. Unit-tested against the server primitives,
nostr-tools, and the official NIP-44 v2 vectors in
`test/unit/vendor-crypto.spec.ts`.
Loading
Loading