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

4.7 KiB

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.

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:

  • IncomingNIP19Page 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.