Skip to content

Configurable Nostr profile (kind 0): edit & publish metadata from the dashboard #11

Description

@sovITxyz

Summary

Let a signed-in user edit and publish their Nostr kind 0 profile metadata (name, display name, about, picture, banner, website, NIP-05, and the lud16 lightning address) directly from the nbread dashboard. The form builds a kind 0 event (content = a JSON metadata object per NIP-01/NIP-24), signs it client-side through the existing NbreadSigner abstraction (exactly like the editor's publish flow), then publishes it: broadcast to the user's relays + the first-party relay, and POST it to a mirror endpoint so nbread's profiles table updates immediately. The form pre-fills from the currently stored profile.

This is the last piece that makes an nbread identity self-contained: today a user can claim a handle, write posts, and set a blog theme, but they cannot set their own name/avatar/nip05/lightning-address anywhere in the product — those only appear if some other client already published a kind 0 that our cron happened to ingest.

Why

Current state (cited)

Profiles are ingested, never authored:

  • src/cron/refresh.ts fetches kinds: [0, 5, 30023] from the configured relays (line 125) and mirrors them; it is the only writer of profile data today.
  • src/services/profiles.tsupsertProfile(env, ev) parses a kind 0 event's content JSON into the profiles table. Columns: pubkey, name, picture, about, nip05, raw, updated_at. name falls back to display_name; field caps MAX_NAME=200, MAX_PICTURE=1000, MAX_ABOUT=2000, MAX_NIP05=320. The upsert guard is excluded.updated_at >= profiles.updated_at (newest created_at wins). raw stores JSON.stringify(pickEventFields(ev)), which includes content (src/nostr/event.ts pickEventFields) — so every non-column field (banner/website/lud16/lud06/display_name) is recoverable from raw for prefill even though it has no dedicated column.
  • getProfile(env, pubkey) reads a row back.

The dashboard edits site settings, NOT the kind-0 event:

  • src/routes/dashboard.ts POST /dashboard/settings handles only css, about, relays and writes them to the site settings JSON blob via updateBlogSettings (src/services/users.ts, json_set on $.css/$.about/$.relays). It never constructs, signs, or broadcasts a Nostr event. readBlogSettings / BlogSettings cover the same three keys.
  • src/views/main/dashboard.tsx renders that settings form (DashboardPage, uses props.settings.about etc.).

The mirror endpoint currently rejects kind 0:

  • src/routes/api.ts POST /api/mirror is session-gated, enforces ev.pubkey === sess.pubkey (tenant isolation, line 104), rate-limits (MIRROR_MAX=30/5min), caps the body (MAX_MIRROR_BODY_BYTES=2_000_000), and blocks blocked keys — but explicitly refuses any kind other than 30023/5 (line 110: "only kind 30023 (post) and kind 5 (delete) are accepted"). Profiles cannot flow through it as-is.

But the relay and mirror pipeline already accept kind 0:

  • src/relay/do.ts ALLOWED_EVENT_KINDS = new Set([0, 5, 30023]) (line 65); handleEvent accepts kind 0 from a claimed, non-blocked handle after NIP-42 AUTH (lines 280/302/310/317). So wss://<MAIN_HOST>/relay already stores our users' profiles.
  • src/services/mirror.ts mirrorEvent calls upsertProfile(env, ev) for kind 0 (line 208); slotDTag forces kind 0 into the single (pubkey, 0, '') replaceable slot (lines 55–57); newest created_at wins with bumpGen invalidating the edge cache.

The signer + uploader are apex-only client JS, ready to reuse:

  • public/js/signer.js exposes globalThis.NbreadSignerready(), getPublicKey(), signEvent(unsigned) (any kind), isRedirectSigner(); backends NIP-07 / NIP-46 / NIP-55 / nsec.
  • public/js/editor.js is the reference flow: ensureSigner() (signer matches the session pubkey), nextCreatedAt() (strictly beats the stored version to win the replaceable slot), signEventpostMirror('/api/mirror')broadcast() (best-effort per-relay with NIP-42 AUTH), plus the full NIP-55 (Amber) redirect/resume dance.
  • public/js/blossom.js exposes globalThis.NbreadBlossom (uploadBlob) for direct-from-browser image upload.
  • CSP (src/middleware/headers.ts): APEX_CSP allows script-src 'self', connect-src 'self' wss: <blossom hosts> — the dashboard is apex, so JS + signer + relay broadcast + Blossom uploads all work there. BLOG_CSP has no script-src (blog pages stay JS-free by policy). This feature lives entirely on the apex.

Proposed integration

1. Dashboard "Profile" form (apex). Add a Profile section to src/routes/dashboard.ts / src/views/main/dashboard.tsx (separate from the existing site-settings form) with fields:

  • v1 core: name, display_name, about, picture, nip05, lud16 (lightning address)
  • v1 also: website, banner
  • stretch: lud06 (LNURL), arbitrary custom fields

Pre-fill from the stored profile: getProfile(pubkey) for the columns (name/picture/about/nip05), and parse profiles.rawcontent JSON for the fields with no column (display_name/banner/website/lud16/lud06). Pass the stored event's created_at into the page config as prevCreatedAt so the client can guarantee the edit wins the replaceable slot.

2. Build + sign client-side. New public/js/profile.js (same shape as editor.js): assemble the metadata object, content = JSON.stringify({name, display_name, about, picture, banner, website, nip05, lud16, lud06}) (omit empty keys), event {kind:0, created_at: nextCreatedAt(), tags:[], content}, and sign via NbreadSigner.signEvent after ensureSigner(). Reuse the editor's NIP-42 broadcast() and the NIP-55 redirect/resume flow verbatim.

3. Publish + mirror. Broadcast the signed kind 0 to editorRelays(env, user) (first-party relay + the user's configured relays — the same helper the editor uses) and POST it to a mirror endpoint. Two options for the endpoint:

  • (a) Relax the src/routes/api.ts kind guard to also accept kind 0 (keeping ev.pubkey === sess.pubkey, the rate limit, the blocked check; the MAX_POSTS_PER_PUBKEY cap is 30023-only and doesn't apply). mirrorEvent already does the right thing for kind 0.
  • (b) A dedicated POST /api/profile route with the same guards. Cleaner separation, but duplicates plumbing.

Recommend (a) with a comment, since mirrorEventupsertProfile is already the intended kind-0 path.

4. Picture/banner via Blossom. Wire the picture and banner inputs to NbreadBlossom.uploadBlob (already CSP-allowed on the apex), same as the editor's image button — user uploads an image, gets a URL, drops it in the field.

Where the profile is shown (what the edit changes)

Server-rendered views that read the profiles table today (so they reflect a published edit after upsertProfile runs):

  • Blog headersrc/views/tenant/layout.tsx BlogLayout renders name, picture (through safeHttpUrl), and about. Caveat: the dashboard $.about setting overrides the kind-0 about on the blog header (layout.tsx lines 45–46: props.about?.trim() || props.profile?.about?.trim()). So a user who set a dashboard "about" will not see their kind-0 about in the header — worth surfacing in the UI (see Open questions).
  • Subdomain blog (<handle>.MAIN_HOST home + post) — src/routes/tenant.ts getProfileBlogProfile {name, picture, about}.
  • Apex /npub1… viewssrc/routes/main.ts npubProfile → same BlogProfile.
  • RSS/Atom — feed description uses profile.about (src/routes/tenant.ts, src/routes/main.ts).
  • Not the discover feedsrc/views/main/discover.tsx renders posts by handle/title and does not read profiles, so profile edits won't change it (only relevant if we later want author avatars there).

Note BlogProfile only carries name/picture/about today; showing nip05/website/lud16 server-side would need those threaded through (a stretch, not v1).

Interplay / clarifications

  • NIP-05 has two distinct meanings here. The nip05 field on the user's kind 0 is user-authored and can point anywhere. It is separate from nbread's own /.well-known/nostr.json (src/routes/wellknown.ts), which verifies <handle>@<MAIN_HOST> → pubkey for claimed handles. Proposal: when the user has a claimed handle, pre-fill / suggest nip05 = <handle>@<MAIN_HOST> so their published profile verifies against nbread's own well-known out of the box. Keep it editable (some users verify elsewhere).
  • lud16 unblocks zaps. This field is the prerequisite for NIP-57 zaps; that issue depends on this one landing first.
  • Replaceable semantics. kind 0 is replaceable; mirrorEvent/upsertProfile already enforce newest-wins on (pubkey, 0, ''). The client must set created_at strictly greater than the stored version (Math.max(now, prevCreatedAt + 1), exactly like editor.js nextCreatedAt), or an edit made in the same second could tie-break and lose.

Constraints / security

  • Apex-only. JS + NbreadSigner + wss:/Blossom connect-src all live under APEX_CSP. Blog pages are JS-free (BLOG_CSP) and never host this.
  • No secrets server-side. Signing is client-side; the server only ever sees the signed kind 0. Do not add any nsec handling.
  • XSS / sanitization — these fields render in server views:
    • picture, banner, website → must pass safeHttpUrl (src/markdown/sanitize.ts) before landing in any src/href (picture already does in BlogLayout).
    • name, display_name, about, nip05 → text, escaped by hono/jsx auto-escaping on output; still trim + length-cap on the way in (mirror the existing MAX_* caps in profiles.ts; add caps for the new fields).
    • about is plain text in the header today (not markdown) — keep it plain to avoid opening a markdown/HTML surface on the profile.
  • Size caps — enforce per-field caps client-side (UX) and re-enforce server-side; keep total content well under the mirror body cap. Extend upsertProfile's cap set to cover any new persisted field.
  • Rate limiting — the reused /api/mirror path already carries MIRROR_MAX + the KV-write-budget rationale; profile saves count against it, which is fine.

Scope

v1

  • Dashboard "Profile" form (apex) — fields: name, display_name, about, picture, banner, website, nip05, lud16
  • Pre-fill from stored profile: getProfile columns + parse profiles.raw.content for non-column fields; pass prevCreatedAt
  • public/js/profile.js — build kind 0, sign via NbreadSigner, nextCreatedAt semantics, reuse editor broadcast() + NIP-42 + NIP-55 resume
  • Mirror path: accept kind 0 in POST /api/mirror (keep ev.pubkey === sess.pubkey, rate limit, blocked check) — or dedicated /api/profile
  • Broadcast to editorRelays() (first-party relay + user relays)
  • Blossom upload wired to picture + banner
  • nip05 pre-fill/suggest <handle>@<MAIN_HOST> for claimed handles
  • Server-side validation/caps: safeHttpUrl for picture/banner/website; trim + length-cap text fields; extend upsertProfile caps
  • Surface the "dashboard about overrides kind-0 about on the blog header" relationship in the UI
  • Tests: form render/prefill, mirror accepts kind 0 and rejects cross-key, upsertProfile field handling, URL sanitization, replaceable created_at bump

Stretch

  • lud06 (LNURL) field
  • Dedicated profiles columns for lud16/website/banner if server views need to display them (and thread through BlogProfile)
  • Arbitrary custom kind 0 fields
  • Author avatars/names on the discover feed

Open questions

  1. Mirror endpoint: relax /api/mirror's kind guard to include kind 0, or add /api/profile? (Recommend the former.)
  2. about override: the dashboard $.about setting shadows the kind-0 about in the blog header. Keep both (and explain in the UI), unify them (kind-0 becomes the single source), or migrate the setting into the kind-0 profile? Migration touches readBlogSettings/BlogLayout and the RSS/Atom descriptions.
  3. nip05 auto-set: silently default to <handle>@<MAIN_HOST>, or just suggest it? (Suggest, editable — some users verify elsewhere.)
  4. Persist extra fields as columns? v1 keeps lud16/website/banner only in the kind 0 event + profiles.raw. Zaps will need to read lud16 server-side — decide whether that comes from a new column or by parsing raw.
  5. First-publish UX: prompt new handle-claimers to fill in their profile right after claiming?
  6. Size caps for the new fields (banner/website/lud16/lud06/display_name) — pick values consistent with profiles.ts MAX_*.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions