Files
website/docs/nostr.md
mroxso 949a551492 feat: New Web OS design (#12)
* new web os frontend

* add docs

* Add CNAME and restore NIP-05 nostr.json for GitHub Pages

The Pages custom domain (layer.systems) is only stored in repo settings;
a CNAME file in the build output makes it survive Pages reconfiguration.
Restore public/.well-known/nostr.json, which this branch had dropped —
removing it would break the existing NIP-05 identifiers on layer.systems.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YtQoCzkP7Bo8nruhxojPi

* Ignore eslint and tsc build caches

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YtQoCzkP7Bo8nruhxojPi

* Rebrand page title and metadata to LAYER.systems

The site ships on layer.systems, so the document title, meta and OG
description, and the web manifest now carry that name instead of
"Nostr OS". OsShell sets the title at runtime, so it is updated too —
otherwise the tab would fall back to the old branding after hydration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YtQoCzkP7Bo8nruhxojPi

* Rename remaining visible "Nostr OS" strings to LAYER.systems

Covers the About window heading, the mobile shell header and the app
icon's aria-label, so the visible branding matches the page title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YtQoCzkP7Bo8nruhxojPi

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:37:43 +02:00

111 lines
4.7 KiB
Markdown

# Nostr data layer
The template's infrastructure is unchanged: `NostrProvider` owns a single `NPool`, and
data is read through `useNostr()` + TanStack Query. This document covers what was added on
top and the rules that keep relay content safe to render.
## Reading
Every query follows the same shape: a `queryKey` that includes **everything** the result
depends on, an abort signal combined with a timeout, and validation before the data
reaches a component.
```ts
useQuery<NostrEvent[]>({
queryKey: ['nostr', 'feed', scope, authors.length],
queryFn: async ({ signal }) => {
const events = await nostr.query([filter], {
signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]),
relays, // optional hints, see below
});
return events.filter(isRenderable).sort((a, b) => b.created_at - a.created_at);
},
staleTime: 30_000,
});
```
**Validate before rendering.** Relays return blanks, oddities and events that do not match
what their kind promises. Each app defines a small predicate — `isRenderableNote` requires
non-empty content on a kind 1; `isRenderableArticle` additionally requires a `d` tag,
without which an addressable event cannot be addressed at all.
Custom hooks:
| Hook | Purpose |
|---|---|
| `useFollows(pubkey)` / `useMyFollows()` | The pubkeys in a kind 3 contact list |
| `useRelayStatus()` | Live socket state of every configured relay, sampled each second |
| `useRelayHints()` | The first two read relays, for embedding in identifiers we hand out |
## Relay hints
This is the single change that made deep links actually work.
A `nprofile`, `nevent` or `naddr` can carry relay hints, and the event it points at very
often lives on a relay the reader does not subscribe to. Discarding the hints means a
shared link resolves only for people who happen to read the same relays as the sender —
which, in testing, was most of the time a failure.
The flow is symmetric:
- **Incoming** — `NIP19Page` decodes the identifier and passes the hints on as a `relays`
param (comma-separated). Apps read them with `decodeRelayHints(params.relays)` and hand
them to `nostr.query(..., { relays })`.
- **Outgoing** — "Copy link" in `NoteCard` and the Reader embeds `useRelayHints()` into the
identifier it encodes, so links leaving this client carry the same courtesy.
`encodeRelayHints` / `decodeRelayHints` in `src/lib/nostrUtils.ts` accept only `ws://` and
`wss://` URLs.
## Publishing
Through `useNostrPublish()` (kind, content, tags). Two places write events:
- **`Composer`** — kind 1. Replies carry NIP-10 tags: `['e', id, '', 'root']` plus
`['p', authorPubkey]`.
- **Follow button** — kind 3, the complete list, read back before writing.
Failures surface as a toast with the underlying message; they are never swallowed.
## Rendering untrusted content
Everything below arrived from a stranger's relay. The rules are not optional.
**URLs.** `sanitizeUrl()` parses the URL and returns it only if the protocol is `https:`,
`http:`, `mailto:` or `nostr:`. `javascript:` and `data:` never survive it. It guards every
`href` and `src` in the app — avatars, banners, article images, links inside note text.
**Note text.** `NoteContent` tokenises the raw string into text, URLs and NIP-19
references and renders each as an element. Nothing is ever passed to
`dangerouslySetInnerHTML`.
**Markdown.** `react-markdown` builds a React tree and never touches `innerHTML`, so raw
HTML inside an article body is inert by construction — that is why it was chosen over
`marked` or `markdown-it`, which return HTML strings you must remember to sanitize.
Additionally:
- `rehype-sanitize` runs as a second line of defence. Strictly redundant while
`rehype-raw` is absent, but it stops a future change from quietly opening a hole.
- A custom `urlTransform` routes every link and image through `sanitizeUrl`.
- **`rehype-raw` is deliberately not installed.** Adding it means overturning this
decision on purpose.
**External links** get `target="_blank"` with `rel="noopener noreferrer nofollow"`.
## Nostr references open windows
`components.a` in the Markdown renderer and `RefToken` in `NoteContent` intercept
`nostr:` URIs and bare `npub`/`nprofile`/`note`/`nevent`/`naddr` strings, and turn them
into **window openers** rather than navigations:
```
npub / nprofile → openApp('profile', { pubkey })
note / nevent → openApp('notes', { id })
naddr → openApp('articles', { pubkey, kind, identifier })
```
Clicking a mention raises a Profile window next to the note you were reading instead of
taking the page away from you. This is the point at which the app stops feeling like a
website — and it is only cleanly possible because the Markdown renderer hands us
components instead of an HTML string.