* 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>
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:
- Incoming —
NIP19Pagedecodes the identifier and passes the hints on as arelaysparam (comma-separated). Apps read them withdecodeRelayHints(params.relays)and hand them tonostr.query(..., { relays }). - Outgoing — "Copy link" in
NoteCardand the Reader embedsuseRelayHints()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-sanitizeruns as a second line of defence. Strictly redundant whilerehype-rawis absent, but it stops a future change from quietly opening a hole.- A custom
urlTransformroutes every link and image throughsanitizeUrl. rehype-rawis 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.