> **Nostr Agent Onboarding** · [start here](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/start.md) · [index](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/llms.txt) · source: `nostr-dev/docs/patterns/signer-login.md` · snapshot 2026-10-10
>
> Paths such as `~/Documents/…`, `~/Production Environment/…`, `repos/…` and services on `localhost` refer to the author's workstation and are **not available to you** — read them as worked examples of a setup you can recreate.

# Pattern: signer login — NIP-07, NIP-46 (bunker / nostrconnect), NIP-55 (Amber)

> **Read this before writing any login code.** Every app built on this machine
> got signer login working only after several broken attempts (Facebook-Museum,
> Testimony, Kubo, In-Your-Face, GRASPCONTROL, Idle StarFighter, Nostr-Agenda,
> OTSuite …). The failures were almost always the same dozen mistakes. They are
> collected here with the fix, so the next app gets it right in one go.
>
> Verified against the NIP texts on `nostr-protocol/nips` master, Amber source,
> and the library sources on **2026-09-19**. Spec dates are in §9. If you are
> reading this much later, re-check §9 first — NIP-46 and NIP-55 both changed
> in 2026.
>
> **Test with the Signer Login Tester** (§8, <https://npub138kdqjl645xz6rvuw92m2v4us0shhpxezgqqz9zrrj2jdstakvus86qzyh.nsite.lol/>): a static page that exercises every
> method below and logs every wire message. If your signer works there and not
> in your app, the bug is in your app.

---

## 0. Which methods to offer

| Platform | Offer | Notes |
|---|---|---|
| Web app / nsite | **NIP-07** + **NIP-46 paste `bunker://`** + **NIP-46 QR `nostrconnect://`** | NIP-55-over-web is optional and fragile (§5). The NIP-55 spec itself says web apps should prefer NIP-46. |
| Android native | **NIP-55** (Amber etc.) + **NIP-46** | Use Quartz's `nip55AndroidSigner`, don't hand-roll (§4). |
| Go desktop / daemon / CLI | **NIP-46** | Use the patched library from Testimony, not stock `fiatjaf.com/nostr` (§3.7). |
| Go→wasm in the page | NIP-07 via a JS bridge + NIP-46 | Extensions don't inject `window.nostr` into workers — bridge from the page (Facebook-Museum `cmd/wasm/extsigner.go`). |

Web clients must never ask for an nsec (Nostr-exp [`core/03-how-to-develop.md`](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/core/03-how-to-develop.md)).
Local-key onboarding is acceptable only in native introductory apps.

---

## 1. The three keys (the #1 conceptual bug)

NIP-46 has three keypairs. Mixing them up causes most "logged in as the wrong
person" and "can't reconnect" bugs:

| Key | Who holds it | Where you see it |
|---|---|---|
| **client key** | your app, generated once, **persisted** | host part of `nostrconnect://<client-pubkey>` |
| **remote-signer key** | the signer | host part of `bunker://<remote-signer-pubkey>`; author of every response |
| **user key** | the signer | only via `get_public_key` |

- The remote-signer key **may** equal the user key (Amber) but usually doesn't
  (nsec.app, nsecbunker, nak with separate keys). **Always call
  `get_public_key` after `connect`** and use its result as the session pubkey.
- Persist all three plus the relays. Example bug: nostrify 0.50.2
  `NUser.fromBunkerLogin` restores with the *user* pubkey as the signer
  pubkey. It works with Amber and times out with everything else.

---

## 2. Rules that apply to every method

1. **Verify every signed event you get back.** Check that `verifyEvent(ev)` is
   true, that `ev.pubkey === sessionPubkey`, and that `kind`, `content`, `tags`
   and `created_at` equal what you asked for. Recompute the id yourself.
   Extensions can switch accounts under you; bunkers can return anything.
2. **Every request has a timeout, and every timeout is visible in the UI.**
   Humans approve on phones, so use about 120 s for connect and QR pairing and
   about 60 s per sign. Libraries default to *no* timeout: nostr-tools
   `sendRequest`, NDK `signer.timeout`, and applesauce all wait forever.
3. **Kind-scoped permissions.** Request `sign_event:<kind>` for each kind you
   will sign. Amber silently discards bare `sign_event`
   (`removeIf { kind == null && type == "sign_event" }`), for both NIP-46 and
   NIP-55.
4. **Log the wire.** Keep a debug view of each request, response, relay OK and
   error. Every multi-day debugging session here ended once someone looked at
   the raw 24133 traffic.
5. **Say the Amber caveats in the UI:**
   - Amber drops *all* incoming NIP-46 requests when its Android
     notification permission is off (the Android 13+ default).
   - Since Amber 6.5.0 it silently rejects requests whose `created_at` is more
     than 5 minutes off its clock.
6. **Never print the secret.** `bunker://…&secret=` and client keys have
   leaked into logs and error strings here several times, for example
   EarthTreeMedia `rip/*.log` and nostrkit `Redact`. Redact `secret=` before
   logging a URI, and don't wrap raw user input into errors with `%w`.

---

## 3. NIP-46 — remote signing (bunker and nostrconnect)

### 3.1 Wire format (current)

- Kind **24133** (ephemeral). `content` = **NIP-44** ciphertext of JSON. Requests
  are p-tagged with the remote-signer pubkey; responses are p-tagged with the
  client pubkey.
- Request: `{"id": "<random>", "method": "<m>", "params": ["<string>", …]}`. All
  params are strings.
- Response: `{"id": "<same>", "result": "<string>", "error"?: "<string>"}`. If
  `error` is present, the request failed. `sign_event`'s result is a JSON
  *string*, not an object.

| method | params | result |
|---|---|---|
| `connect` | `[remote_signer_pubkey, secret_or_"", perms_or_"", client_metadata_json]` | `"ack"` or the secret |
| `get_public_key` | `[]` | user pubkey, hex |
| `sign_event` | `[json({kind, content, tags, created_at})]` | `json(signed event)` |
| `nip44_encrypt` / `nip44_decrypt` / `nip04_encrypt` / `nip04_decrypt` | `[third_party_pubkey, text]` | text |
| `ping` | `[]` | `"pong"` |
| `switch_relays` | `[]` | JSON string `["wss://…"]` or `null` |
| `logout` | `[]` | `"ack"` (courtesy; you must still delete the client key) |

- `client_metadata_json` is `{"name":…,"url":…,"image":…}`. It was added
  2026-06-23. To send metadata without perms, pass `""` in the perms slot.
- **auth challenge:** a response of `{"id", "result":"auth_url", "error":"<URL>"}`
  means "open this URL". Keep listening for a **second response with the same
  id**, which is the real result.

### 3.2 URIs

**bunker://** (the signer creates it; the user pastes it into your app):
```
bunker://<remote-signer-pubkey>?relay=wss://a&relay=wss://b&secret=<single-use>
```

**nostrconnect://** (your app creates it; shown as a QR, or as a link on the
same phone):
```
nostrconnect://<client-pubkey>?relay=wss%3A%2F%2Fa&relay=wss%3A%2F%2Fb&secret=<hex>&perms=sign_event%3A1%2Cnip44_encrypt&name=MyApp&url=https%3A%2F%2Fmyapp.example
```
- Both `relay` and `secret` are required.
- `metadata=<json>` is the **old** form (before 2024-11). Use `name`/`url`/`image`.
- **Amber URL-decodes the whole query and then splits on `&` and `=`.** Any
  value containing `&`, `=`, `+` or a space breaks even when percent-encoded.
  So keep `name` to one word, make `secret` hex, and leave out `image` unless
  its URL is simple.
- Use a hex secret from a CSPRNG (`crypto.getRandomValues`), never
  `Math.random`.

### 3.3 Checklist: the bunker:// flow (paste)

1. Parse the URI: signer pubkey plus relays plus secret. Tolerate a missing
   secret.
2. **Load the persisted client key, or create one and save it now,** before
   the first `connect`.
   - Amber auto-acks a repeat connect from a client it already knows.
   - A fresh key per attempt looks like a stranger reusing a consumed
     single-use secret, and **no prompt ever appears**. This is the #1 cause
     of "Amber doesn't work".
3. Connect to the relays and **drop the dead ones**. Relay health changes
   daily; see §3.6.
4. Open the reply subscription first:
   `{"kinds":[24133], "#p":[client_pubkey], "since": now-60}` with **no
   `limit`**.
   - Don't use `since: now`: Amber stamps replies about 5 s in the future.
   - Don't use `limit: 0` alone: that relies on relays live-streaming
     correctly.
   - Don't wait for EOSE from *all* relays before sending. One dead relay then
     hangs login forever; that is a stock `fiatjaf.com/nostr` and NDK bug. Wait
     at most about 2 s for the first EOSE, then send.
   - Don't send before the REQ is live either. Under wasm, the request beat the
     REQ on a shared socket and the ephemeral reply was never replayed.
5. **Always send `connect`**, even without a secret:
   `[signerPk, secret||"", perms||"", metadataJSON]`. Some signers refuse
   everything from a client that never connected.
6. Accept `"ack"` **or** the secret as a successful connect result.
7. Call `get_public_key`. That is the user.
8. Send `switch_relays`. If it returns a list, move the session to those
   relays (resubscribe first, then drop the old ones). This has been a spec
   SHOULD since 2026-01-26. Amber 6.3+ changed its default relays, so this
   matters.
9. Persist `{clientSecret, signerPubkey, userPubkey, relays}`. Reconnect later
   with `connect` without the secret (the secret was single-use).
10. On logout: send `logout` as a courtesy, then **delete the client key**.

### 3.4 Checklist: the nostrconnect:// flow (QR)

1. Create the client key and persist it. Create a hex secret.
2. Choose relays the signer is likely to use (§3.6). **Open and arm the
   subscription before showing the QR.** A fast phone replies within a second;
   if you're not listening yet, the reply is lost, because 24133 is ephemeral.
3. Show the QR, a copy button, and a plain `nostrconnect://` link. On the same
   phone, tapping the link opens Amber directly.
4. Accept **either** of these as the pairing reply:
   - a *response* whose `result === secret` (what current signers send), or
   - a legacy `connect` *request* whose `params[1] === secret`.
5. **Never accept a bare `"ack"` in this flow.** The client pubkey is printed
   in the QR, so anyone can reply `"ack"` and become "your signer". The spec
   says the client MUST validate the secret. nostr-tools enforces this since
   2026-08-19. applesauce, rust-nostr, nostr-login and Archon-Web still
   accept `"ack"`, so don't copy them.
6. The **author of the pairing reply is the remote-signer pubkey**. Then
   continue with §3.3 steps 7–10.

### 3.5 Encryption dialect

- **Send NIP-44 only.** The spec dropped NIP-04 for the 24133 envelope on
  2024-12-05.
- **Decrypt with NIP-44 first, then fall back to NIP-04** (the ciphertext
  contains `?iv=`). Old signers still reply in NIP-04, and Amber still accepts
  both.
- **Never dual-send** (the same request in both encryptions). Amber dedupes by
  event id and consumes the single-use secret on the first copy. The second
  copy then errors and **no prompt appears**.
- NIP-44 was extended on 2026-06-28 to allow plaintexts over 64 KiB, using a
  6-byte length prefix. Old decoders, including paulmillr's reference
  `nip44`, reject those. Large `sign_event` payloads can hit this.

### 3.6 Relays for NIP-46 (probed 2026-09-19, fresh keys, publish and receive)

| Relay | 24133 relayed? | Note |
|---|---|---|
| `wss://relay.nip46.com` | ✅ | dedicated NIP-46 relay; in Amber 6.6 defaults |
| `wss://bucket.coracle.social` | ✅ | in Amber 6.6 defaults |
| `wss://nrs.primal.net` | ✅ | in Amber 6.6 defaults |
| `wss://relay.damus.io` | ✅ | **removed** from Amber defaults in 6.3; intermittent 503s |
| `wss://relay.primal.net`, `wss://nostr.oxtr.dev` | ✅ | |
| `wss://auth.nostr1.com` | ⚠️ AUTH required | in Amber defaults; you must answer NIP-42 AUTH (sign kind 22242 with the **client key**) or publishes fail with `auth-required` |
| `wss://relay.nsec.app` | ❌ unreachable | still the default in many libraries and in apps on this machine; don't rely on it |
| `wss://relay.nos.social` | ❌ `blocked: kind not allowed` | |
| `wss://nos.lol` | ❌ timed out that day | |
| local ngit-relay `ws://localhost:8081`, nostr-rs-relay | ❌ / never OKs | don't use for NIP-46 tests; use a public relay or an in-process khatru |

- **Default for new apps:** `relay.nip46.com` + `bucket.coracle.social` +
  `nrs.primal.net` + `relay.damus.io`.
- Let the user edit the list, and probe it at login.
- Re-probe with the tester (§8) or with this, from a script file (nak stalls in
  inline compound shell):
  `nak req -k 24133 -p <pk> --stream wss://R` plus
  `nak event -k 24133 -p <pk> -c x --sec <fresh> wss://R`.
- **Don't gate on relay OK** for 24133. Some relays never OK ephemeral events.
  Start waiting for the reply immediately.

### 3.7 Library status (2026-09-19): what to use and what to patch

| Library | Verdict | Bugs you must work around |
|---|---|---|
| **nostr-tools** `BunkerSigner` (≥2.25) | best JS base | No per-request timeout (wrap it). A reply with an empty `result` never resolves. `switch_relays` compares a sorted list against an unsorted one. `queryBunkerProfile` reads the old NIP-05 `nip46` shape. Pass a persisted `clientSecretKey`. |
| **applesauce-signers** `NostrConnectSigner` | good (grimoire uses it) | Accepts bare `"ack"` in nostrconnect. `switchRelays` checks `Array.isArray` on a string, so it never switches. No timeout. `onAuth` defaults to `window.open` outside a click, which popup blockers stop. |
| **NDK** `NDKNip46Signer` (3.0.x) | avoid for NIP-46 | Sends `connect` with `[userPubkey??"", secret]` (the wrong first param). Only `"ack"` counts as success. Waits for EOSE before sending. Sticks to NIP-04 after seeing one NIP-04 reply. The nostrconnect URI has a single relay and a `Math.random` secret. No timeout by default. |
| **nostrify** (MKStack) | ok for NIP-07; bunker restore buggy | Bug in §1. No `auth_url` handling. |
| **fiatjaf.com/nostr** `nip46` (Go, stock) | **don't use stock** | Waits for EOSE from all relays (a dead relay hangs forever). Drops NIP-04 replies. 30 s hard-coded sign timeout (`BunkerSignTimeout` is ignored). One context bounds both the connect and the listener (the second sign fails with "context canceled"). Broken 1-in-10 pre-connect `switch_relays`. Waits for the relay OK before reading the reply. **Use the patched copy in `Production Environment/Testimony/third_party/nostr/` (see its `FORK.md`) plus `internal/signer/`.** |
| **rust-nostr** `nostr-connect` | ok | Accepts `"ack"` in nostrconnect. No `switch_relays`/`logout`. |
| **nostr-login** | unmaintained since 2025-03 | Don't adopt. |

**Go lifetime rule.** The bunker client's subscription lives as long as the
context you created it with. Build it with an **app-lifetime context** and use
per-request `context.WithTimeout` only around individual calls.
In-Your-Face-try2 and icdib both `defer cancel()` the login context, so the
session dies the moment login returns.

---

## 4. NIP-55 — Android native (Amber)

Use **Quartz** (`com.vitorpamplona.quartz`, `nip55AndroidSigner`), as
Nostr-Agenda does. It already does content-resolver-first with intent fallback,
id matching, rejection handling, signature verification and timeouts. If you
must hand-roll, implement all of this:

1. **Manifest `<queries>`**, or Android 11+ can't see the signer (neither the
   intent nor the ContentResolver):
   ```xml
   <queries><intent><action android:name="android.intent.action.VIEW"/>
     <category android:name="android.intent.category.BROWSABLE"/>
     <data android:scheme="nostrsigner"/></intent></queries>
   ```
   Detect with `queryIntentActivities(Intent(ACTION_VIEW, Uri.parse("nostrsigner:")))`.
   Show "Sign in with Amber" only if something is installed.
2. **Login = one `get_public_key` intent** with *no* package set, plus a
   `permissions` extra that lists every kind you'll sign:
   `[{"type":"sign_event","kind":31922},{"type":"nip44_decrypt"}]`.
   - Quartz's default covers only kind 22242, which means a prompt on every
     sign.
   - Store `result` (the pubkey) **and `package`**. Accept both npub and hex.
   - Don't call `get_public_key` again while logged in.
3. **Later requests:**
   - First try the ContentResolver:
     `content://<package>.SIGN_EVENT`, with
     `selectionArgs = [payload, pubkey_hex, current_user_hex]`.
   - A `null` cursor or empty row means manual approval is needed, so fall back
     to the intent with `setPackage(package)`.
   - A `rejected` column means the user chose "always reject". **Stop; don't
     fall back.**
   - Use `getColumnIndex` safely: a missing column must not crash.
4. **Read `result`**, not `signature`. Amber sets both; other signers may not.
   `event` is also returned for `sign_event`.
5. **Rejection is `RESULT_OK` + `rejected=true`** (spec rewrite 2026-06-03).
   `resultCode != RESULT_OK` means the signer failed. Code that only checks
   `resultCode` turns a rejection into "success with an empty signature" (the
   OTSuite bug).
6. Match results by `id`. Queue requests: one Amber prompt at a time
   (OTSuite `SignerIntentBridge` mutex). Deliver a cancel to the waiting
   caller; don't let it time out silently.
7. **Compose pitfall.** Activity results arrive while the screen is STARTED,
   not RESUMED, so navigating directly from the callback is silently dropped.
   Set a flag and navigate in a `LaunchedEffect`.

---

## 5. NIP-55 over the web (`nostrsigner:` URLs), optional and fragile

The spec recommends NIP-46 for web apps. If you still offer it (it's the only
no-relay option for a phone browser):

```
nostrsigner:<urlencoded payload>?compressionType=none&returnType=signature&type=<method>&callbackUrl=<url>
```

1. **Callback must be fragment-based**: `callbackUrl=https://host/path#nip55=`.
   - Amber splits the decoded data on `?`, so a `?x=` callback gets mangled
     (Idle StarFighter `d98801c`).
   - Amber opens `callbackUrl + Uri.encode(result)` in a new VIEW intent.
   - Listen for **`hashchange`** as well as page load, because a fragment-only
     navigation doesn't reload the page.
2. **Pending state in `localStorage`** with an expiry (not
   `sessionStorage`). The result often lands in a *new tab*.
3. **Request `returnType=signature`**. Rebuild the event from the **exact
   stored unsigned event**, including its `created_at`, recompute the id, then
   verify.
   - Rebuilding with a new `created_at` breaks the signature. This is the
     Idle StarFighter bug.
   - For `sign_event`, include `pubkey` in the unsigned JSON.
   - Encode the payload with `encodeURIComponent`. An unescaped `?` inside the
     JSON breaks Amber's parser.
4. **With no `callbackUrl`**, Amber copies the result to the clipboard and shows
   a toast. Offer a "paste result" box as the fallback.
5. **Chrome (reported, not independently confirmed).**
   - Since about August 2026, Chrome reportedly no longer sends
     `EXTRA_APPLICATION_ID`, so Amber treats a bare `nostrsigner:` link as an
     app intent and fails with "malformed nostrsigner request".
   - Amber's app-intent branch reads `type`, `callbackUrl`, `returnType`,
     `pubkey` and `id` from **extras** (verified in Amber source,
     `IntentUtils.getIntentDataFromIntent`). So an Android `intent:` URL
     works as a fallback:
     `intent:<payload>#Intent;scheme=nostrsigner;S.type=sign_event;S.returnType=signature;S.callbackUrl=<enc>;end`.
   - Offer both links.
6. A `nostrsigner:` link with no signer installed does nothing, and this can't
   be detected. Say so in the UI.
7. When an installed PWA opens the callback, it opens in a browser tab, not the
   PWA window.

---

## 6. NIP-07 — browser extension

```
await window.nostr.getPublicKey()            // hex
await window.nostr.signEvent({created_at, kind, tags, content})  // → full event
window.nostr.nip44?.encrypt/decrypt(pk, text) // optional — feature-detect
window.nostr.nip04?.encrypt/decrypt(pk, text) // deprecated
```

1. **Injection is late.** Extensions inject after your script runs (nos2x adds
   an async `<script>`). Don't decide "no extension" at render time. Poll
   `window.nostr` (truthy, not `'nostr' in window`) every 100 ms for about
   1–2 s, *and* re-check when the button is clicked. nostr-archive checks at
   click time and never had the bug; nostr-archive-app, ETM and grimoire check
   at render and show "install an extension" wrongly.
2. **Feature-detect `nip44`** before relying on it. NDK's check is buggy (an
   empty array is truthy).
3. **Serialize encrypt and decrypt calls.** Extensions throw
   "call already executing" on parallel prompts (NDK queues and retries).
4. **Verify** per §2.1. The user can switch accounts in the extension
   mid-session, so compare `signed.pubkey` with the session.
5. `getRelays()` was removed from the spec (2025-02). Don't depend on it.
6. **Workers and wasm workers don't get `window.nostr`.** Bridge the call from
   the page.
7. Some extensions reject an unsigned event whose `pubkey` is the zero key.
   Omit `pubkey` or set it to the real one.

---

## 7. Definition of done for a login feature

- [ ] Each offered method logs in, **signs a test event that you verify**
      (§2.1), and survives a page reload or app restart **without a new
      prompt** (session restored from persisted state).
- [ ] NIP-46 tested against **`nak bunker`** (fast, hermetic) *and* **real
      Amber**. nak speaks only NIP-44 and has no kind-scoped permissions, so
      a nak-only test proves neither the NIP-04 fallback nor the permissions
      (Nostr-Agenda's unscoped `sign_event` passed with nak).
- [ ] Both NIP-46 directions tested: pasted `bunker://` and scanned QR.
- [ ] Killing one relay in the list doesn't hang login.
- [ ] Rejecting on the phone shows "rejected", not a hang or fake success.
- [ ] Timeouts show a message that names the likely cause (Amber offline,
      notifications off, wrong relay).
- [ ] Logout deletes the client key; logging in again prompts again.
- [ ] No secret or nsec appears in any log, error, URL or analytics.

**nak recipes** (run from a script file; add `</dev/null` in loops):
```bash
# a bunker that signs for key K, on two relays, prints bunker:// URI (+ QR)
nak bunker --sec <hex-or-nsec> --qrcode wss://relay.nip46.com wss://bucket.coracle.social
# the other direction (app shows a nostrconnect:// QR): `connect` only hands the URI to
# an ALREADY RUNNING `nak bunker` over a unix socket — start the bunker first:
nak bunker --sec <hex> wss://relay.nip46.com &      # terminal 1
nak bunker connect 'nostrconnect://…'               # terminal 2 (fails "connection refused" if no bunker runs)
# drive a bunker as a client with a persisted client key
nak event -k 1 -c test --sec 'bunker://…' --connect-as <client-hex> wss://relay.damus.io
```

---

## 8. Reference implementations on this machine

| Need | Copy from | Why |
|---|---|---|
| **Test any signer** | **Signer Login Tester**, live at <https://npub138kdqjl645xz6rvuw92m2v4us0shhpxezgqqz9zrrj2jdstakvus86qzyh.nsite.lol/>; source at `~/Production Environment/Signer-Login-Tester/` (`tools/e2e.mjs` = 19 automated checks with `nak bunker`) | One static page. All methods, full wire log, written to this document. |
| NIP-46 in JS, hand-rolled | the tester's `src/nip46.js` + `src/relay.js` (about 250 lines total) | Implements §3 exactly, including NIP-42 AUTH for `auth.nostr1.com` |
| NIP-46 in JS, library | grimoire `src/components/nostr/LoginDialog.tsx` + applesauce | Opens the subscription before the QR, supports abort, persists accounts. Add timeouts and the secret-only check. |
| NIP-46 in Go | `~/Production Environment/Testimony/internal/signer/` + `third_party/nostr/` | Patched lib; verified against real Amber 2026-08-22. The reasoning is in `Facebook-Museum/app/third_party/nostr/FORK.md` Deltas 2–4. Don't copy Facebook-Museum's own `ConnectPerms`, which are bare (known-issues §6). |
| NIP-46 Go daemon lifetime | `Sessions/GRASPCONTROL/internal/identity/manager.go` | App-lifetime context, persisted `session.json` |
| NIP-55 Android | `~/Production Environment/Nostr-Agenda` (Quartz 1.11) | Verified with real Amber 6.2.2 |
| NIP-55 web | `Idle StarFighter/web/nip55.js` + `docs/NOSTR.md` §NIP-55 | Fragment callback, localStorage pending state. **But** fix its `created_at` rebuild bug (§5.3). |
| NIP-07 | NDK `NDKNip07Signer` pattern: poll, queue, check at click time | |
| Signer side (what Amber really does) | `Sessions/bunker-android/awareness/amber-upstream/` (`IntentUtils.kt`, `SignerProvider.kt`, `NostrConnectUtils.kt`) | Read the source when the spec and behaviour disagree |

Known-bad code on this machine (don't copy): see [`known-issues.md`](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/design/known-issues.md) §6.

---

## 9. Spec changelog you must know (dates = commit on nips master)

| Date | Change |
|---|---|
| 2024-10-29 | NIP-46: remote-signer pubkey ≠ user pubkey |
| 2024-11-12 | NIP-46: `get_public_key` required after connect; nostrconnect `secret` required; NIP-05 login and `create_account` removed |
| 2024-11-22 | nostrconnect `metadata=` → `name`/`url`/`image`/`perms` params |
| 2024-12-05 | NIP-46 envelope NIP-44 only |
| 2025-02-14 | `get_relays` / `getRelays` removed from NIP-07/46/55 |
| 2025-10-13 | NIP-55: store the pubkey, don't re-call `get_public_key`; resolver arg = hex pubkey |
| 2025-12-18 | NIP-55: content-resolver `GET_PUBLIC_KEY` removed (intent only) |
| 2026-01-26 | NIP-46: `switch_relays` added (client SHOULD call after connect) |
| 2026-06-03 | NIP-55 rewritten: rejection = `RESULT_OK`+`rejected`; `selectionArgs` order `[payload, pubkey, current_user]` |
| 2026-06-14 | NIP-46: `logout` |
| 2026-06-23 | NIP-46: 4th `connect` param `optional_client_metadata` |
| 2026-06-28 | NIP-44: plaintexts > 64 KiB allowed (6-byte length prefix) |
| 2026-07-15 | NIP-46: unknown methods MUST get an error reply |
| Amber 6.3.0 | default relays drop relay.damus.io |
| Amber 6.5.0 (2026-08-14) | rejects 24133 older or newer than ±5 min; tracks consumed ids |

Primary sources: <https://github.com/nostr-protocol/nips/blob/master/07.md>,
[`46.md`](https://github.com/nostr-protocol/nips/blob/master/46.md),
[`55.md`](https://github.com/nostr-protocol/nips/blob/master/55.md),
<https://github.com/greenart7c3/Amber/releases>. Refresh this table when you
touch login code: `https://github.com/nostr-protocol/nips/commits/master/46.md.atom`.
