Add NIP-17 DM docs and fix test imports

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-07 21:38:00 +00:00
committed by GitHub
parent d82adbfe0a
commit 087a2df3a3
4 changed files with 58 additions and 9 deletions

22
NIP.md
View File

@@ -18,6 +18,23 @@ protocols:
NIP-86 authorization (with the NIP-86-required `payload` tag) and for Blossom uploads.
- [NIP-11: Relay Information Document](https://github.com/nostr-protocol/nips/blob/master/11.md)
— read at connect time to show relay identity in Relay Admin.
- [NIP-17: Private Direct Messages](https://github.com/nostr-protocol/nips/blob/master/17.md)
(draft, optional) — the Messages app (`src/apps/messages/`, protocol layer in
`src/lib/dm.ts`). Chat messages are unsigned kind `14` rumors, sealed by the sender
into kind `13` with [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md)
and gift-wrapped (kind `1059`, [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md))
with a throwaway key — one wrap for the recipient and one sender copy, so history is
recoverable on any client holding the keys. Seal/wrap timestamps are randomized up to
two days into the past. The recipient copy is published to the relays from the
recipient's kind `10050` inbox list; a sender without that list gets the message on
their own relays plus a warning that the recipient may not be reachable. The
seal↔rumor pubkey check mandated by the NIP is enforced on unwrap, and rumors whose
room (author + `p` tags) does not include the viewer are dropped.
- [NIP-04: Encrypted Direct Messages](https://github.com/nostr-protocol/nips/blob/master/04.md)
(deprecated) — kind `4` legacy DMs are read for backwards compatibility and used for
sending only when the signer offers no NIP-44 encryption; the UI labels that fallback
visibly (weaker crypto, public sender/recipient metadata) and never downgrades a
NIP-44-capable signer to it.
## Adopted third-party kinds
@@ -25,5 +42,6 @@ protocols:
[Grimoire](https://github.com/purrgrammer/grimoire) client, adopted as-is for interop.
See `docs/apps.md` ("Spells are a third-party kind") and `src/hooks/useSpells.ts`.
Anything else (kinds 0, 1, 3, 5, 6, 16, 9802, 10002, 10003, 22242, 30023, 30311, 31337,
39701, …) follows the official NIPs as implemented in `src/hooks/` and `src/lib/`.
Anything else (kinds 0, 1, 3, 4, 5, 6, 13, 14, 16, 9802, 10002, 10003, 10050, 22242,
30023, 30311, 31337, 39701, …) follows the official NIPs as implemented in `src/hooks/`
and `src/lib/`.

View File

@@ -89,7 +89,7 @@ export default function ExampleApp({ setTitle }: AppProps) {
}
```
## The eleven apps
## The twelve apps
| App | `id` | Params | Notes |
|---|---|---|---|
@@ -100,6 +100,7 @@ export default function ExampleApp({ setTitle }: AppProps) {
| Bookmarks | `bookmarks` | — | NIP-51 kind 10003 list — bookmarked notes and articles |
| Web Bookmarks | `web-bookmarks` | — | NIP-B0 kind 39701 — one addressable event per saved URL |
| Live | `live` | `pubkey?`, `identifier?` | NIP-53 kind 30311 live events + kind 1311 chat |
| Messages | `messages` | `peer?` | NIP-17 gift-wrapped DMs (kinds 14/13/1059), legacy NIP-04 fallback |
| Spells | `spells` | `id?` | Saved/shareable REQ filters — kind 777, a third-party draft NIP |
| Relays | `relays` | — | Connection state, subscription count, measured latency |
| Relay Admin | `relay-admin` | `relay?` | NIP-86 management console for relays you operate |
@@ -168,6 +169,35 @@ whether the relay offers a reverse operation. Every operation is written to a pe
audit log (method, target, result, operator-safe error) that never contains the
authorization header or any key material; signing secrets stay inside the user's signer.
### Messages keep plaintext and metadata off the wire — and out of storage
The Messages app implements NIP-17 (`src/lib/dm.ts` + `src/hooks/useDirectMessages.ts`):
plaintext only ever exists inside an unsigned kind 14 rumor, which is NIP-44-sealed by
the sender and gift-wrapped with a throwaway key. One wrap goes to the recipient's kind
10050 inbox relays, one sender copy to the sender's own relays — the sender copy is what
makes history recoverable on another device, so it is always written. Decryption happens
on demand and plaintexts live in memory only; nothing message-shaped is persisted to
localStorage. The conversation list shows no received plaintext either ("New message",
not a snippet), because a lock-screen-glance at the window title bar or an over-the-shoulder
view should not leak content.
Two compatibility decisions are deliberately visible rather than silent:
- **Legacy NIP-04 (kind 4)** is read for old messages and used for sending only when the
signer has no NIP-44 (some extensions). A banner and a "legacy" label on the affected
bubbles say exactly what that costs: sender/recipient metadata is public. A
NIP-44-capable signer is never downgraded.
- **No kind 10050 inbox list** means NIP-17 cannot say where the recipient receives DMs.
The message is still stored (on the sender's relays, so the sender keeps their
history) but the send reports a `DmRecipientUnreachableError` with recovery guidance
instead of claiming delivery. Bubble states are honest for the same reason:
pending → sent means "a relay accepted the gift wrap", never "the recipient read it".
Hiding a conversation publishes a NIP-09 kind 5 deletion request for the sender's own
copies (relays may refuse; nothing recalls a message the recipient already fetched) and
hides the peer locally. Received gift wraps are signed by throwaway keys and cannot be
deletion-requested by design.
### Relay latency is a real round trip
A WebSocket gives the browser no ping, so the Relays app times an actual `REQ`/`EOSE`

View File

@@ -5,9 +5,9 @@ import { NSecSigner, type NostrEvent } from '@nostrify/nostrify';
import { useNostr } from '@nostrify/react';
import { TestApp } from '@/test/TestApp';
import { buildGiftWraps, DM_GIFT_WRAP_KIND, LEGACY_DM_KIND } from '@/lib/dm';
import { buildGiftWraps, DM_GIFT_WRAP_KIND, LEGACY_DM_KIND, type DmMessage } from '@/lib/dm';
import { useLoginActions } from './useLoginActions';
import { groupIntoConversations, useDmConversations, type DmMessage } from './useDirectMessages';
import { groupIntoConversations, useDmConversations } from './useDirectMessages';
const aliceSecret = generateSecretKey();
const alice = getPublicKey(aliceSecret);

View File

@@ -81,10 +81,11 @@ describe('parseRecipient', () => {
});
it('rejects note/nevent/naddr identifiers — they are not people', () => {
const note = nip19.noteEncode('a'.repeat(64));
expect(parseRecipient(note).type).toBe('invalid');
const nevent = nip19.neventEncode({ id: 'a'.repeat(64) });
expect(parseRecipient(nevent.type ? nevent : '').type).toBe('invalid');
expect(parseRecipient(nip19.noteEncode('a'.repeat(64))).type).toBe('invalid');
expect(parseRecipient(nip19.neventEncode({ id: 'a'.repeat(64) })).type).toBe('invalid');
expect(
parseRecipient(nip19.naddrEncode({ pubkey: alice, kind: 30023, identifier: 'x' })).type,
).toBe('invalid');
});
it('rejects an nsec — a secret must never be typed into a recipient field', () => {