Changelog

What's new in each release

v0.0.48

  • [Improvement] Opening an Essayist-exclusive article as a non-member now renders a dedicated Essayist access-required page with a direct Become a member CTA instead of the generic not-found page.
  • [Bug] Made POST /api/fetch-chapter idempotent for duplicate event saves: unique-constraint races are now treated as success, and the magazine chapters frame cache is invalidated after fetch completion so missing-chapter placeholders do not persist.
  • [Feature] Added a new authenticated Expressions personal workspace (/expressions/workspace) with Reading Nook/Newsroom shell styling, local sidebar navigation, and integrated expression/spell feed-testing tabs; expression create/edit screens now render inside the same section shell.
  • [Bug] Removed editor-side NIP-70 protected tag emission for article publishes; Essayist-only publishes now rely on local essayist_exclusive marking and Essayist relay context instead.
  • [Bug] Essayist-exclusive article visibility now includes the article author in addition to current Essayist members/admins.
  • [Feature] Added an Essayist exclusive flag on article pages so members/authors can clearly see when an article is restricted to Essayist access.
  • [Bug] Moved relay public URL settings to relay.auth.serviceUrl in both docker/strfry/strfry.conf and docker/strfry-essayist/strfry.conf so NIP-42 AUTH uses the correct namespace.
  • [Bug] Fixed strfry/strfry-essayist startup command ordering in compose files to use ./strfry --config /tmp/strfry.conf relay, ensuring the generated templated config file is actually loaded.
  • [Bug] Applied the same startup serviceUrl templating flow to strfry-essayist in compose.prod.yaml, so ESSAYIST_RELAY_PUBLIC_URL is re-injected on every restart.
  • [Bug] Fixed Essayist protected-event relay rejects (Protected event and no serviceUrl configured) by wiring strfry-essayist serviceUrl from ESSAYIST_RELAY_PUBLIC_URL at container startup.
  • [Bug] Wired the default strfry relay serviceUrl from RELAY_PUBLIC_URL at container startup so protected-event handling has a canonical relay URL.
  • [Bug] Fixed editor event signing to avoid duplicate NIP-70 protected tags (["-"]) when Publish ONLY to Essayist is selected on already-protected drafts/articles.
  • [Bug] Improved publish reliability for users on AUTH-only write relays: when publish results include AUTH-required relays, the app now replays the user's pending relay AUTH challenges to Mercure so signer prompts are resent immediately.
  • [Bug] Hardened dn:graph:audit current-record freshness checks against PostgreSQL statement_timeout cancellations: replaced offset-based scanning with keyset batching and added adaptive timeout fallback that recursively splits timed-out batches, so --fix can progress on large datasets instead of failing at offset 0.
  • [Feature] Added publication relay broadcasting from magazine action menus: logged-in users can now rebroadcast kind 30040 publication index events to their write relays via the new POST /api/broadcast-publication endpoint.
  • [Bug] Markdown conversion now preserves single newlines as <br /> soft line breaks within the same paragraph instead of collapsing them.
  • [Feature] Added authenticated My Books support in /bookshelf using signed kind 30045 directory events (d=my-book-collection), including add/remove actions on search and reader pages, a new /bookshelf/my-books inventory route, and relay-backed persistence through /api/bookshelf/directory.
  • [Improvement] Restyle search bar in /bookshelf, preserve query, update translations.
  • [Bug] Fixed /bookshelf thumbnails to use the image tag only; when missing or invalid, the UI now consistently falls back to the existing placeholder monogram.

v0.0.47

  • [Feature] Added a standalone Mercury-backed /bookshelf with remote kind 30040 metadata search, replaceable-event deduplication, batched kind 30041 chapter retrieval in index order, and a continuous AsciiDoc book reader that does not fall back to the local relay catalogue.
  • [Bug] Restored the mobile navigation drawer on full-width guest pages, including the anonymous home page.
  • [Improvement] Moved reading lists and curation sets out of /my-content into a Newsroom-style searchable /reading-list inventory, leaving My Content focused on authored articles and drafts.
  • [Bug] Reading Nook now labels the default kind 10003 bookmark list as Bookmarks.
  • [Improvement] Reworked /reading-nook from grouped collection cards into a Newsroom-style searchable inventory table with section tabs, compact metadata columns, and row action menus.
  • [Improvement] Restyled /follow-packs with the shared flat article-card row layout, including curator metadata, pack counts, descriptions, and cover images.
  • [Improvement] Restyled the public /lists curation directory as flat article-card rows with metadata, summaries, cover images, and translated item counts.
  • [Bug] Fixed anchor and heading permalink jumps so targets are not hidden beneath the fixed page header.
  • [Bug] Rendered trusted https://*.nostria.app article links as images, including Markdown links whose label points at the same hosted media URL.
  • [Improvement] Reworked article engagement into a compact social action strip with comment, like, bookmark, share, tip/payment-target, and overflow actions, plus signed NIP-25 article likes.
  • [Improvement] Moved article highlight visibility to a single top-of-article toggle beside reading-list actions, removed the redundant Turbo-frame proxy/dropdown toggle, and fixed disabled inline highlights rendering with a transparent background.
  • [Bug] Added the cron daemon package to the shared FrankenPHP image so the cron service can install its schedule and run jobs.
  • [Bug] Fixed the interest-set editor so existing kind 30015 d tags populate the Identifier field and are preserved when publishing replacements.
  • [Bug] Reading Nook topic lists now label untitled kind 10015 lists as My interests, and kind 30015 interest-set Manage actions open the specific set editor instead of the generic interests page.
  • [Bug] Removed the redundant standalone remove control from article-coordinate bookmarks on /my-bookmarks.
  • [Improvement] Clarified beside the reading-list editor’s Save draft action that session-only drafts can be lost and should be signed and published to preserve changes.
  • [Bug] Contained the reading-list Nostr event preview within the editor width so long event values scroll inside the preview instead of stretching the page layout.
  • [Improvement] Reading Nook author subscription cards now resolve npub/hex sources to the profile metadata display name, with profile name and shortened identifier fallbacks.
  • [Improvement] Standardized buffered and live Relay Feed entries on the shared Discover article-card layout, including responsive cover treatment, metadata, links, and authenticated bookmark controls.
  • [Bug] Fixed bookmark removal invoking a nonexistent BookmarkItemResolver::removeBookmark() LiveAction; removals now preserve the list, request a user signature in the browser, publish the replacement NIP-51 event, and keep standard bookmark state synchronized.
  • [Improvement] Standardized locally resolved article references, including articles shown below Discover highlights, on the same shared article card component used by the Discover Recent and Featured Writers tabs.
  • [Improvement] Consolidated reading-list article editing and review into one modern Newsroom workspace with resolved article titles and coordinates, immediate add/remove controls, live event synchronization, draft saving, and in-place signing/publishing; the former review URL now redirects compatibly.
  • [Bug] Fixed magazine category and chapter links loading their destination pages inside lazy Turbo Frames by applying target="_top" to the source frames and explicit top-level link targets.
  • [Bug] Deduplicated authored follow packs on profile Editorial tabs by newest d-tag revision before applying the ten-pack display limit, across both synchronous and cached profile data paths.
  • [Bug] Restored full-width bookmarked article cards by applying the width contract to the previously classless ArticleFromCoordinate wrapper while retaining the existing resolver flex layout.
  • [Improvement] Standardized Newsstand magazine covers in centered 16:9 frames with consistent cropping and added a full-width bottom rule between magazine entries.
  • [Bug] Fixed /my-content sorting when Nostr event update dates are Unix timestamps instead of DateTimeInterface objects.
  • [Bug] Added dedicated high-contrast highlight foreground, background, and border tokens for dark and light themes and applied them consistently to feed marks, inline highlights, compact cards, popovers, and active highlight controls.
  • [Bug] Repaired article-card HTML by replacing the nested full-card anchor with a valid stretched title link, normalizing enum and integer event kinds in coordinates, and removing double URL encoding from article slugs.
  • [Improvement] Simplified homepage calls to action: renamed Search to Discover and linked Start a magazine directly to the magazine setup wizard instead of the redundant journey landing step.
  • [Bug] Fixed the magazine setup wizard crashing when isLoggedIn was not supplied by using Twig's authenticated user context directly.
  • [Improvement] Rebuilt /my-content as a responsive publishing inventory with compact authored-content counts, server-backed tabs/search/sorting/pagination, explicit Iconoir action menus, Reading Nook bookmark separation, an accessible translated NIP-09 confirmation dialog, and database-side latest-revision selection.
  • [Bug] Increased light-theme informational text contrast with a dedicated deep blue-teal foreground, matching tint, and stronger border tokens.
  • [Improvement] Removed the redundant Reading Nook dashboard stat cards on desktop and replaced them below the contextual-aside breakpoint with a compact three-value summary that becomes an aside-style key/value list on mobile.
  • [Improvement] Removed the duplicate theme selector from the Reading Nook sidebar now that theme switching is available globally in the masthead.
  • [Bug] Repaired the article editor workspace after the shared-shell redesign: aligned its viewport with the header token, retained compact vertical desktop sidebar tabs, constrained grid columns, contained long Essayist relay URLs, and explicitly rendered conditional form leftovers inside the sidebar so Symfony no longer appends Essayist checkboxes outside the Relays panel.
  • [Bug] Restored an opaque themed surface for Zap and Tip dialogs and replaced the oversized text close action with a compact icon control.
  • [Bug] Removed the duplicate anonymous login action from the sidebar so authentication has one consistent entry point in the masthead.
  • [Bug] Changed Settings tabs from an overflowing scroll container to a wrapping tab row, preventing the unwanted vertical scrollbar.
  • [Bug] Corrected the desktop masthead grid from four columns to three balanced regions so the brand, search, and actions align predictably.
  • [Improvement] Introduced a flat editorial design system across the shared application shell: consolidated dark/light/space tokens, removed rounded and elevated shared surfaces, added a search/action masthead, unified global/Reading Nook/Newsroom navigation with responsive and active-route behavior, redesigned public and authenticated home hierarchy, modernized Discover, and converted article feeds to compact responsive story rows.

0.0.46

  • [Feature] Scaffolded an in-repo DecentNewsroom\NostrKernelBundle with Symfony bundle wiring, protocol contracts, immutable domain value objects, application-level coordinate/reference services, isolated Innis adapters (stubbed for next-pass API wiring), and focused PHPUnit coverage for event-kind/tag/coordinate/reference behavior.
  • [Bug] Reduced duplicate GET /api/bookmarks/current traffic on article-list pages: bookmark state fetches are now deduplicated per controller module with a shared in-flight request/cache so dozens of card/dropdown instances no longer trigger identical parallel requests.
  • [Feature] Added PWA IndexedDB caching for article lists on home page: article lists are now automatically cached in the browser's IndexedDB on first load and served instantly on subsequent visits, with background fetching of new items to keep content fresh. Improves perceived performance for repeat visitors and enables offline browsing of previously cached tabs.
  • [Feature] Added IndexedDB-backed bookmark reliability to both article-card shortcut (ui--card-bookmark) and article actions dropdown (ui--article-actions-dropdown): bookmark updates now build new kind 10003 events from the last cached snapshot, work across repeated no-reload interactions, and persist failed publishes as pending for automatic backoff retries using the same signed payload; success/failure metadata is tracked in IndexedDB for idempotent recovery.
  • [Bug] Fixed article broadcast hanging when publishing to relays from the article dropdown: the frontend template was passing the public project relay URL (wss://relay.decentnewsroom.com) in the data-relays attribute, which the API endpoint attempted to connect to from inside Docker, causing hangs. The broadcast controller now filters out project relay URLs when the local relay is present, preventing external connection attempts from within the Docker network.
  • [Bug] Made POST /api/index/publish idempotent on duplicate-key persistence races: if the signed magazine index event already exists locally, the endpoint now treats it as success and still republishes to relays for propagation instead of returning a 500.
  • [Performance] Added server-side cache for magazine manifest endpoints: /magazines/manifest.json now caches the full catalog payload for 10 minutes, and /mag/{mag}/manifest.json caches per-magazine payloads for 5 minutes (keyed by index event id + created_at), reducing on-the-fly DB and JSON assembly cost.
  • [Performance] Converted magazine article reading-list prev/next navigation to lazy loading only: DefaultController::magArticle() no longer runs synchronous ReadingListNavigationService::findNavigation() on initial response, relying on the existing article-list-nav Turbo Frame endpoint for deferred rendering.
  • [Performance] Accelerated magazine article page loads (/mag/{mag}/cat/{cat}/d/{slug}): replaced per-request slug-scan/category SQL resolution with a single batched category-coordinate lookup from the magazine index, then fetch only the latest matching article revision via findOneBy(..., ['createdAt' => 'DESC']) instead of loading and sorting all revisions in PHP.
  • [Performance] Optimized admin analytics dashboard query count: consolidated getArticlePublishStats() and getZapInvoiceStats() from 5 queries each to single SQL queries using conditional aggregation; consolidated getBotVsHumanStats() from 3 separate time-windowed queries to a single UNION query. Reduces admin analytics page query count by ~80% for these high-impact methods, eliminating timeout issues.
  • [Bug] Hardened Event tag metadata getters (getTitle(), getSummary(), getSlug()) to ignore malformed/short tags instead of reading missing offsets, fixing Undefined array key 1 warnings in production logs.
  • [Performance] Split admin analytics dashboard into three focused pages: main /admin/analytics (core metrics: page views, unique visitors, referrers, articles, publish activity), bot analytics /admin/analytics/bot (bot traffic trends and user-agents), and subdomain analytics /admin/analytics/subdomains (Unfold site visits). Reduces initial page load from ~30 queries to ~12 queries on the main dashboard; detailed reports available via dedicated routes.
  • [Performance] Further reduced /admin/analytics load time: removed all-time full-table scans (totalVisits, totalUniqueVisitors, totalReferredVisits), bounce rate subquery, average visits/session, sessions table, and route breakdown table from the main page. All deferred to new /admin/analytics/detail. Replaced 7-query PHP loop in getDailyUniqueVisitors() with a single aggregated SQL query. Main dashboard now runs ~6 fast, time-bounded queries.
  • [Performance] Discover page /discover Recent tab now loads lazily via a dedicated Turbo Frame endpoint (/discover/tab/recent) backed by fetchLatestArticles() from the Redis view store (view:articles:latest), matching the caching strategy used by /latest-articles; the initial page response no longer fetches articles, reducing first-byte latency.
  • [Bug] Fixed /my-content delete action wiring by aligning the Stimulus controller filename/identifier mapping with data-controller="content--my-content-delete", so the NIP-09 delete button now initializes and handles clicks correctly.
  • [Improvement] My Bookmarks and My Interests pages now render inside the Reading Nook layout shell (including the local Reading Nook sidebar) for consistent navigation in the read-side workspace.
  • [Improvement] Reading Nook now excludes authored sections (My Content and My Magazines) so /reading-nook focuses on read-side collections only; authored management remains in the Newsroom workspace.
  • [Bug] Removed runtime dependency on api.iconify.design during icon cache warmup/asset compilation by switching UX Icons to local iconoir SVG assets and disabling remote Iconify fetches.
  • [Improvement] Restyled /reading-nook into a cleaner personal-library dashboard: added overview stat cards, a clearer filter workbench, grouped section anchors, richer collection cards, and a dedicated right sidebar with quick jumps and popular-tag shortcuts.
  • [Bug] Fixed AssetMapper TypeScript compilation by removing an unused re-export from assets/typescript/app.ts; shared helpers continue to be imported directly from assets/typescript/nostr-utils.ts by the controllers that use them.
  • [Performance] Article pages now lazy-load related articles through a dedicated article-related-frame Turbo Frame endpoint; the frame renders the existing RelatedArticles Twig component and resolves fallback suggestions via ContentSearchService::findRelatedArticles().
  • [Improvement] Temporarily hid the Editorial tab/section on /discover.
  • [Improvement] Updated /discover tabs: renamed Articles to Recent, temporarily hid the Activity tab, and added a Highlights tab that now reuses the same shared highlight feed loader (Redis view + DB fallback) and the same Twig feed/card layout partial as /highlights.
  • [Bug] Fixed /highlights empty-page cache misses by aligning controller DB fallback with the same Highlight source used by app:cache-latest-highlights (instead of querying kind 9802 from event), so highlights remain visible when Redis view cache is cold or expired.
  • [Performance] Optimized GET /forum/main/{topic} for near-instant loads: removed full-list overfetch/paginate-in-PHP behavior, switched to offset window fetch (limit = perPage + 1) with lightweight hasPrev/hasMore navigation, and added short per-topic/page response caching (forum.main_topic.v3.*, 30s TTL); main-topic rendering now uses discover-style search + topic pills + article stream without the deprecated subcategory grid.
  • [Improvement] Main sidebar navigation simplified: replaced the Discover section entries with a single Discover menu item pointing to /discover (route discover), and removed Topics and Highlights from the global menu.
  • [Refactor] Forum deprecation Phase 1: Migrated all direct ArticleSearchInterface usages in ForumController, HomeFeedController, ArticleSearchController, MagazineEditorController, DefaultController, RelatedArticles component, and AuthorController to ContentSearchService. Removed boilerplate deduplication/normalization helpers from controllers. Removed unused $articleSearch parameter from four AuthorController private methods (which already bypassed search for DB-first accuracy). Forum routes (/forum, /forum/topic/*, /forum/tag/*, /forum/main/*) now display a soft-deprecation notice in all 5 locales linking users to the interests feed.
  • [Feature] Internal Search API: Introduced ContentSearchService (formal high-level API) wrapping low-level ArticleSearchInterface for site-wide content discovery. Provides semantic methods: searchByTopics(), findRelatedArticles(), getTopicsMetadata(), searchByAuthor(), getLatest(), buildTaxonomyWithCounts(), findBySlugs(), and more. Transparent Elasticsearch/Database backend switching. Handles tag normalization, deduplication, and error handling.
  • [Feature] Navigation Refactor Phase 1 rollout: migrated /reading-nook, /my-content, /reading-list, /my-magazines, and /media-manager to the new Reading Nook/Newsroom layouts with local sidebar data from NavigationBuilderTrait; added missing nav translation keys in messages.{en,de,es,fr,sl}.yaml; magazine/reading-list wizard flows remain on the standard global layout as focused step-by-step flows.
  • [Infrastructure] Introduced three-layout navigation architecture to support the upcoming Navigation Refactor Plan:
    • SidebarNav reusable Twig component for consistent nav rendering across layouts
    • NavigationBuilderTrait helper providing buildMainNav(), buildReadingNookNav(), buildNewsroomNav() methods
    • Three layouts: main global (layout.html.twig), Reading Nook (reading-nook-layout.html.twig), Newsroom (newsroom-layout.html.twig)
    • Simplified main sidebar removes redundant "my ..." links (these will be in the Reading Nook and Newsroom sub-hubs)
    • Full implementation guide at documentation/Newsroom/navigation-layouts-implementation.md
  • [Bug] Fixed article-page timeouts in ReadingListNavigationService: list-membership discovery now uses the indexed parsed_reference.target_coord path first (joining to source events) with a legacy event.tags @> fallback only when graph references are unavailable.
  • [Bug] Fixed nested magazine category pages failing to load: FeaturedList now resolves category/chapter events by full coordinate (kind + pubkey + d_tag) instead of slug-only lookups, and recursive subcategory rendering is depth-guarded to prevent cyclic/self-referential loops.
  • [Bug] Fixed mag/... page timeouts caused by CategoryLink Twig component DB lookups: replaced broad tags @> scans with indexed (kind, pubkey, d_tag) resolution (legacy fallback kept) and added per-request coordinate title memoization to avoid repeated duplicate queries during one render.
  • [Bug] Fixed slow magazine category/front follow-up loads by replacing broad event.tags slug scans with indexed (kind, d_tag) lookups (with legacy fallback) and by batching chapter coordinate resolution in DefaultController::magCategory() instead of running per-chapter SQL queries.
  • [Performance] Magazine front pages now choose a lighter rendering strategy: when a top-level featured article is present, the page shows only that article view and defers article resolution/body rendering to a lazy Turbo Frame; when only categories are present, the page renders one cached preview article per category (10-minute cache) via the categories Turbo Frame.
  • [Performance] Article engagement sections are now lazy-loaded via Turbo Frames: comments and highlights moved out of the initial article response and fetched from dedicated /p/{npub}/d/{slug}/comments and /p/{npub}/d/{slug}/highlights frame endpoints after the page is visible.
  • [Performance] Magazine endpoints now avoid full event-table scans and per-chapter N+1 SQL lookups: front/read/manifest routes resolve the latest index via indexed d_tag lookup and batch chapter coordinates in one DB query, reducing first-byte latency on /mag/* pages.
  • [Performance] Article pages now load faster: the synchronous Nostr relay fetch (blocking on unknown articles) is replaced by an async dispatch + Mercure-powered loading page; publications (sidebar) and reading-list prev/next navigation are lazy-loaded via Turbo Frames so the article body renders immediately.
  • [Improvement] Reduced strfry LMDB read/write pressure: cut router relay connections (9 → 5), dropped kind 3 (follow lists) and kind 10000 (mute lists) from the user_data stream, eliminated duplicate 10002 ingestion, removed non-standard kind 777, reduced LMDB mmap from 10 GiB → 3 GiB, dropped maxreaders 256 → 64, tightened queryTimesliceBudgetMicroseconds to 4 ms, maxFilterLimit to 200, maxSubsPerConnection to 15, and tightened the max event age from 3 years → 6 months.
  • [Improvement] Reworked the FrankenPHP Docker healthcheck to use a local readiness marker written by frankenphp/docker-entrypoint.sh instead of curling /up, eliminating HTTP probe traffic while keeping service_healthy startup ordering.
  • [Deprecated] Removed the legacy ChatBundle wiring from Symfony config (services.yaml, security.yaml, messenger.yaml, doctrine.yaml, twig.yaml, config/packages/chat.yaml, and config/routes/chat.yaml) and neutralized the old admin chat controllers so the bundle no longer participates in container compilation.

v0.0.45

  • [Feature] Expanded /mag/{mag}/manifest.json to include full text content for category article entries (content) and publication chapter entries (content), enabling complete offline/per-client perusal directly from the manifest payload.
  • [Performance] Refactored /mag/{mag}/manifest.json build path to use indexed coordinate resolvers (EventRepository::findByCoordinates, ArticleRepository::findByCoordinates) for category/chapter/article hydration, replacing per-item raw event.tags scans and N+1 article lookups.
  • [Improvement] Reading Nook subscription cards now resolve friendly publication/NIP-51 set titles using the same coordinate lookup approach as /updates/subscriptions, and the Reading Nook right aside now includes the theme switcher controls from user preferences.
  • [Improvement] Reading Nook now includes a Subscriptions content section in the main grid, populated from active update subscriptions (authors/publications/NIP-51 sets) with direct open/manage actions.
  • [Improvement] Added Subscriptions to the Reading Nook local navigation and moved /updates/subscriptions onto the Reading Nook layout so subscription management stays in the read-side workspace.
  • [Bug] Added a Postgres healthcheck to compose.yaml so service_healthy dependencies no longer fail with newsroom-database-1 has no healthcheck configured during startup.
  • [Bug] Hardened events:replay-deletions for large backfills: the command now streams kind 5 request ids in configurable batches, resets Doctrine state between requests/chunks, and EventDeletionService flushes tombstones in small chunks while deduplicating repeated refs inside one deletion request, preventing uniq_deleted_event_target_ref failures.
  • [Improvement] Replaced deprecated doctrine:query:sql command references in project documentation with dbal:run-sql to align with current DoctrineBundle guidance and reduce console deprecation noise.
  • [Bug] POST /api/broadcast-article now returns a clear verification error (422) when an article's stored raw event cannot be verified/reconstructed, explicitly stating the event was not broadcast for that reason instead of surfacing a raw 500.
  • [Feature] Added admin:delete-old-media-events command to clean up old media events (kinds 20, 21, 22, 34235, 34236) that exceed a retention threshold (default: 60 days); useful for managing database size when media events are not heavily surfaced in feeds.
  • [Feature] Added a delete action in my-content article rows that builds a kind 5 (NIP-09) delete request for the selected article (e + a tags), asks the signer to sign it, and publishes it via the user-context event endpoint.
  • [Bug] Fixed /e/naddr1... handling for long-form coordinates (kind 30023): these links now canonicalize directly to the article route (/p/{npub}/d/{slug}) instead of sometimes rendering the generic /e/... event page.
  • [Bug] Fixed dn:graph:audit orphan detection crashing on newer Doctrine DBAL versions by replacing removed Connection::PARAM_STR_ARRAY usages in GraphAuditCommand with ArrayParameterType::STRING for IN (?) array bindings.
  • [Bug] Expression result cards now surface event summary metadata, or alt when content is empty, so index events like magazine categories and reading lists no longer render as blank cards.
  • [Improvement] Docker/Compose/FrankenPHP infrastructure improvements: fixed Composer layer caching by reordering Dockerfile (composer.* copied before source), added --no-dev --no-scripts --no-autoloader flags to reduce build iterations, use composer dump-env prod --empty to avoid baking secrets, pinned strfry images to v1.30.1, enabled Caddy metrics endpoint for observability.
  • [Improvement] Compose configuration hardening: confirmed Redis is environment-specific (local service in compose.override.yaml for dev, external IP-based in production only), made production secrets required (:?VAR must be set pattern), fixed PostgreSQL version consistency, reset strfry port exposure in production (internal-only), fixed Mercure URL configuration (internal vs. public), hardened cron service with restart policy and explicit prod environment.
  • [Improvement] Created safe deployment script scripts/deploy-php.sh that rebuilds and redeploys only PHP-related services while preserving infrastructure (database, Redis, strfry), enabling fast deployments with minimal downtime.
  • [Improvement] Added comprehensive production deployment validation guide (documentation/Admin/deployment-validation.md, 25-point checklist) covering configuration integrity, security, health checks, functional validation, performance monitoring, and troubleshooting reference.
  • [Improvement] Caddyfile now reads FrankenPHP worker configuration from environment variables (FRANKENPHP_NUM_THREADS, FRANKENPHP_MAX_THREADS, PHP_MEMORY_LIMIT, GOMEMLIMIT) with conservative defaults (4 workers, 12 max threads) for explicit tunability without rebuilding images.
  • [Improvement] Enhanced .dockerignore with organized sections (Git, IDE, Env, Docker, Documentation, Artifacts) to reduce Docker build context transfer and accelerate builds.
  • [Improvement] Updated compose.prod.yaml: made all worker services (worker, worker-relay, worker-profiles, relay-gateway) depend on redis healthcheck, set Essayist relay internal URL (ESSAYIST_RELAY_INTERNAL_URL: ws://strfry-essayist:7779), fixed SERVER_NAME to include php:80 for internal Mercure routing.
  • [Improvement] Created quick-reference documentation (documentation/Admin/docker-quick-reference.md) with common Docker Compose commands organized by role (Frontend, Backend, DevOps, Database, API).
  • [Improvement] Added comprehensive Docker infrastructure improvement summary (documentation/Admin/docker-compose-improvements.md) documenting all changes, rationale, benefits, risk mitigation, and future optimization opportunities.
  • [Improvement] Reduced duplicate runtime-context work in ExpressionService: cached expression and spell evaluation paths now build RuntimeContext once and reuse it for both cache-key generation and evaluation execution.
  • [Bug] Fixed ExpressionBundle PHPStan issues by tightening iterable/value PHPDoc types across parser/model/source/cache services, removing dead/unreachable runner branches, making address resolver matches exhaustive, fixing non-null coalesce/strict checks in spell resolution, and replacing Bech32::$type access in feed API naddr decoding with payload type validation.
  • [Bug] Fixed the Essayist members page crashing on /essayist/members by correcting the claim-section Twig translation calls and adding the missing essayist.claim.* strings.
  • [Feature] Added Essayist zap claim/verification system for membership payments to other members. Claims support payer proof (payment preimage + BOLT11), classic zap receipt proof (kind 9735), and a new recipient-confirmation fallback inspired by Payment Superchats: recipients can confirm pending claims directly on /essayist/members (optionally storing a kind 9741 attestation event id), which grants/extends the payer membership without requiring relay-wide receipt scraping. New Bolt11PaymentVerifier utility, EssayistZapClaim entity + repository, EssayistZapClaimService verification/attestation flow, EssayistClaimZapButton Live Component, and claim endpoints/UI updates.
  • [Improvement] Essayist members-page zap flow now auto-creates a pending membership claim when the payer closes the Zap dialog after invoice generation (ZapButton with enableMembershipClaim=true), shows an inline "pending recipient confirmation" state to the payer, and adds a payer-side "Discard" action that voids/deletes the pending claim if payment failed or the user changed their mind.
  • [Improvement] Updated templates/static/essayist.html.twig for post-launch operation: removed early-access/"relay goes live" messaging, removed early-bird claim UI, restored visible member-count status, and kept access-request CTAs always available.
  • [Improvement] Added Reading Nook roadmap notes in documentation/Reader/reading-nook.md for next steps: full locale translations, pagination, source chips, private/encrypted bookmarks support, and own-highlights integration.
  • [Feature] Added GET /reading-nook (Reading Nook): a unified personal reading workspace that aggregates owned bookmarks, topic lists (kinds 10015/30015), follow packs (39089), reading lists/curation sets, magazines, and authored content; includes dedicated sidebar navigation plus server-side filtering by section, timespan, tags, and free-text search scoped to the owned-event pool.
  • [Feature] XML sitemap at /sitemap.xml listing static pages, published articles (up to 1 000), and magazines; linked from the site footer and referenced in robots.txt for crawler discovery
  • [Deprecated] Remove search credits system: deleted src/Credits/ namespace (CreditTransaction entity, CreditsManager service, RedisCreditStore), CreditTransactionController, GetCreditsComponent, admin transactions template, credits.cache pool, and deprecated getTransactionStats() from AdminDashboardService
  • [Improvement] Subdomain subscription pricing and feature lists now state that a NIP-05 vanity name is included for the duration of the subscription; the settings page links active subscribers to the vanity name registration page
  • [Feature] Vendor Iconoir SVG icon set via Symfony UX Icons (assets/icons/iconoir/); replace inline SVG checkmarks in pricing page and external-link icons in highlight templates with ux_icon() calls using valid iconoir:... namespaced icon syntax
  • [Bug] Fix pricing page horizontal overflow by adding overflow-x: hidden on the grid container and min-width: 0; overflow: hidden on cards
  • [Improvement] Rename "Total Visits" → "Page Views" and "Unique Visitors" → "Unique Sessions / Users" in admin analytics; add clarifying notes
  • [Improvement] Add wss://spatia-arcana.com to content relay list
  • [Improvement] Highlights feed now surfaces kind:1 text note references (e-tags): controller extracts e/E tags and renders them via NostrEmbed; deduplication key updated to cover all source types
  • [Bug] Fixed mentionify in profile/about rendering to also detect and link nostr:npub1... (and plain npub1...) mentions, not only @npub1... tokens.

v0.0.44

  • [Improvement] Added explicit NIP-11 icon metadata to the default relay config (docker/strfry/strfry.conf), so both primary strfry relay configs now expose an icon for relay review UIs.
  • [Feature] Added pubkey blocklist write policy to the default strfry relay.
  • [Bug] Fixed Essayist relay NIP-11 browser discovery issues: essayist-gateway now adds NIP-11 CORS headers (Access-Control-Allow-Origin/Headers/Methods), answers OPTIONS / preflight, and defaults missing or */* Accept to application/nostr+json when proxying metadata requests.
  • [Improvement] Essayist gateway now intercepts non-30023 client EVENT writes and responds with OK false + NOTICE (blocked: ...) without closing the websocket, so policy rejections do not look like relay/session failure.
  • [Improvement] Restored docker/strfry-essayist/write-policy.sh longform-only gating (kind 30023) and changed rejection messages to a policy-style blocked: reason so non-matching writes are handled as expected relay policy instead of relay/internal error behavior in clients.
  • [Improvement] Increased Essayist gateway AUTH wait time by configuring AUTH_TIMEOUT_SECONDS to default to 30s in Compose (ESSAYIST_GATEWAY_AUTH_TIMEOUT_SECONDS override), reducing premature AUTH timeout disconnects for slower signers.
  • [Improvement] Added explicit NIP-11 relay icon metadata (icon) to docker/strfry-essayist/strfry.conf, so relay review clients can render a branded card/name for the Essayist relay.
  • [Improvement] Relaxed docker/strfry-essayist/write-policy.sh to accept all event kinds from authenticated Essayist members; membership/auth remains enforced at essayist-gateway (NIP-42 + role checks), while the policy script now only validates plugin input shape and echoes event ids for strfry protocol compliance.
  • [Bug] Fixed duplicate follow packs shown in Settings → Events tab (Kind 39089 card): the pack list now deduplicates by d-tag, keeping only the most recent version of each pack.
  • [Bug] Fixed settings/payment-targets often opening with an empty editor when the latest kind 10133 payment-targets event had not yet been persisted locally. The page now loads the freshest known event like the relay tab does, then pre-fills the form from its existing payto targets.
  • [Bug] Fixed profile-page crashes on PostgreSQL caused by filtering JSONB event.tags with LIKE in follow-pack lookups. The editorial/follow-pack queries now fetch recent candidates and apply p-tag membership checks in PHP, avoiding operator does not exist: jsonb ~~ unknown.
  • [Bug] Fixed PostgreSQL profile/editorial crashes caused by querying magazine.contributors (JSON column) with LIKE. Contributor lookups now use JSONB containment (contributors::jsonb @> ...) on PostgreSQL, avoiding operator does not exist: json ~~ unknown errors.
  • [Bug] Fixed Essayist relay (and any future server-initiated WebSocket) clients timing out without ever receiving the NIP-42 AUTH challenge: removed encode zstd gzip from the relay.localhost and essayist.localhost Caddy reverse-proxy routes. Caddy's encoder wraps the response writer in a way that can buffer the first server-pushed frame after a WS upgrade, which manifested as AUTH timeout, closing in the essayist-gateway logs because the gateway pushes AUTH immediately on connect (strfry was unaffected since it is purely client-initiated).
  • [Improvement] essayist-gateway now logs the AUTH challenge only after the frame is written, surfaces write failures as outcome=write_failed, and applies a 5s write deadline so a stalled socket cannot silently hide a delivery problem.
  • [Bug] Fixed /essayist/members template crash (Unknown "m" test) by correcting the self-zap guard comparison in templates/essayist/members.html.twig from an invalid Twig test form to a standard inequality check.
  • [Feature] Extended profile Editorial tab to include not only magazines and follow packs authored by the profile user, but also magazines where they are listed as a contributor and follow packs where their pubkey appears in p tags. Added dedicated UI sections for "Featured in" content and aligned overview cache/revalidation payloads with the new data shape.
  • [Improvement] Editor article publishing now bypasses the Essayist NIP-42 AUTH gateway by remapping the public Essayist relay URL to the internal Docker address (ws://strfry-essayist:7779) for users with ROLE_ESSAYIST_MEMBER or ROLE_ADMIN. This applies both when the user ticks the "Also publish to Essayist" / "Publish ONLY to Essayist" checkboxes and when the Essayist relay is already present in their NIP-65 write list.
  • [Improvement] Changed Essayist gateway HTTP passthrough to forward any unauthenticated GET request to strfry-essayist and return strfry's native response (instead of returning 404 for non-application/nostr+json browser requests).
  • [Bug] Fixed Essayist relay 404 errors in production: compose.prod.yaml now automatically enables the essayist profile (strfry-essayist and essayist-gateway) instead of requiring manual --profile essayist flag.
  • [Bug] Fixed Essayist relay routing when behind a reverse proxy: Caddyfile now matches on both Host and X-Forwarded-Host headers so requests work regardless of whether the proxy rewrites Host headers. This fixes 404s when a reverse proxy changes the Host header to something different.
  • [Bug] Simplified essayist-gateway Redis configuration in production: now uses the same REDIS_HOST, REDIS_PORT, and REDIS_PASSWORD variables as other services instead of requiring a separate ESSAYIST_GATEWAY_REDIS_URL.
  • [Improvement] Made the Essayist home link visible in the user menu for ROLE_ESSAYIST_MEMBER users.
  • [Improvement] Added a 5% stepwise font-size toggle to the Quill editor toolbar so writers can zoom the editing surface up/down without affecting stored output.
  • [Improvement] Added a view-only font selector to the Quill editor toolbar (Sans/Serif/Mono) so writers can change editing readability without emitting font markup/classes into saved HTML/Markdown output.
  • [Bug] Fixed events:replay-deletions failing on PostgreSQL with SQLSTATE[42803] by removing the ORDER BY clause from its internal COUNT() query before batching kind 5 deletion requests.
  • [Improvement] Refined the mobile editor overlay behavior: library/settings now default to hidden, opening either panel overlays the editor content full-height, and the compact mobile header hides the article title text to preserve space for actions.
  • [Improvement] Reworked the mobile article editor layout: mode tabs are now available on mobile again, both sidebars (library and settings) render in the mobile flow, and new show/hide toggle controls let users collapse either sidebar without losing access to full editor functionality.
  • [Bug] Fixed article editor lockups on problematic Markdown->Quill conversions. The editor now wraps both conversion directions in guarded fallbacks, shows a visible warning notification when conversion fails, and keeps the Markdown source loaded so the page remains usable instead of hanging.
  • [Bug] Hardened assets/controllers/editor/conversion.js inline Markdown parser with bounded-iteration and forward-progress guards so special-character edge cases can no longer trap the Markdown->Delta conversion in an infinite loop.
  • [Bug] Fixed pasted Nostr npub lookups from crashing on long or nostr:-prefixed identifiers by switching NostrKeyUtil::npubToHex() to the long-input nostriphant/nip-19 decoder instead of the length-limited swentel/nostr-php Bech32 converter.
  • [Bug] Fixed wiki events (kind 30817) not appearing in magazine category pages by queueing targeted async naddr fetches for missing wiki coordinates referenced by the category. This hydrates only the wikis actually included in the magazine instead of subscribing to all global wiki events.
  • [Feature] Improved kind 30040 resolution in /e/* routes: publication index events now redirect to magazines when they reference nested 30040 indices, and redirect to reading lists when they reference longforms (30023/30024), chapters (30041), or wiki entries (30817). Reading list rendering now resolves 30041 and 30817 a-references into card items that open via /e/naddr....
  • [Feature] Magazine category pages now support wiki coordinates (kind:30817) alongside longform article coordinates. Wiki items render in the same card list UI and open via the new per-category route /mag/{mag}/cat/{cat}/wiki/{slug}.
  • [Improvement] Unfold restyle.
  • [Bug] Fixed chat subdomain routing: DefaultController::index() now checks !request.attributes.has('_chat_community') in its route condition, so requests to chat community subdomains no longer fall through to the main-app home page, allowing the chat UI routes to match correctly.
  • [Bug] Fixed chat group creation error for self-sovereign users. ChatEventSigner now requires client-side signing for self-sovereign users (non-custodial accounts linked to main-app Users). ChatGroupService::createGroup() returns unsigned kind-40 events for self-sovereign users instead of failing. ChatGroupAdminController handles the response: for custodial users, groups are created server-side immediately; for self-sovereign users, client-side signing via Nostr extension (NIP-07/NIP-46) is required. A new endpoint /admin/chat/communities/{communityId}/groups/{groupId}/publish-channel-event accepts signed events from self-sovereign users. Updated chat groups Stimulus controller to intercept form submission, detect self-sovereign responses, guide signing, and post back the signed event. This aligns the group creation flow with the existing message sending pattern that already supported both user types.
  • [Bug] Fixed relay-auth template crashes when the logged-in security user has no npub value (or is a ChatUser). templates/base.html.twig now renders the relay-auth controller only when a usable npub exists, App\Twig\NostrExtension now treats null/empty inputs as empty strings, and App\ChatBundle\Entity\ChatUser::eraseCredentials() is annotated to satisfy the Symfony 7.3 deprecation.
  • [Bug] Fixed private chat infrastructure in Docker Compose. The strfry-chat relay is now started by default again, its data volume is persisted, and its write-policy no longer depends on missing jq or host executable bits, so chat subdomains on production deployments are backed by a working internal relay.
  • [Feature] Added a follow pack identity card at the top of the Follow Pack home feed tab, showing the pack's title (linked to its full page), optional cover image, description, member count, and curator attribution via UserFromNpub. The card is only shown when a pack is configured and its event is found in the database.
  • [Bug] Fixed UnfoldBundle default theme width inconsistency: the home (index.hbs) and category (category.hbs) templates now use the same 800px max width as post pages instead of rendering extra wide.

v0.0.43

  • [Improvement] essayist-gateway now enriches NIP-11 JSON when upstream strfry omits optional fields: sets icon to {public-base-url}/favicon.ico and fills limitation.auth_required=true plus limitation.restricted_writes=true by default.
  • [Bug] Fixed Quill view-font controls leaking outside the active editor instance by removing global #editor fallback lookup and scoping font/font-size style application strictly to the current controller element.
  • [Bug] Fixed mobile editor empty space at bottom by ensuring action buttons don't flex-shrink and Quill editor container properly fills available vertical space.
  • [Bug] Fixed mobile editor triggering unwanted browser auto-zoom by enforcing 16px minimum font size on all inputs, textareas, selects, and CodeMirror editor on mobile devices, preventing layout shifts.
  • [Bug] Fixed mobile editor overlay stacking where the Quill toolbar/tooltip could render above the Library/Settings sidebars; mobile z-index layering now keeps sidebars on top.
  • [Improvement] Updated mobile editor footer actions to a compact two-button row with responsive wrapping, plus an ultra-narrow fallback to stacked buttons for readability.
  • [Bug] Fixed chat admin group creation signer resolution. The controller now prefers an active custodial user in the community for kind-40 signing/publish (avoiding the self-sovereign relay-AUTH roundtrip when a custodial signer exists), and only falls back to the currently authenticated self-sovereign chat user when needed. The signed publish endpoint now authorizes against the current linked self-sovereign user instead of the first community user row, preventing stuck/unauthorized publish flows.
  • [Feature] Added a Follow Pack tab, an Activity tab, and an Updates tab to the logged-in home page. The Follow Pack tab shows articles from the pubkeys in the user's own featured follow pack; if none is configured a notice links to the setup page. The Activity tab mirrors the Discover page Activity tab but is personalised: it shows kind 9802 highlights and kind 1111 long-form article comments only from pubkeys the current user follows, sorted newest first (up to 60 items). The Updates tab reuses the same collapsed item feed as /updates without marking items as read; if the user has no subscriptions yet, a notice explains they can use the Subscribe button on author profile pages and links to the subscriptions management page. Extracted shared partials partial/_activity_items.html.twig and partial/_update_items.html.twig so Discover and the Updates page reuse them without duplication. New home_feed.tab.activity, home_feed.tab.updates, home_feed.activity.*, and home_feed.updates.* translation keys added to all five locales.
  • [Feature] Renamed the "Articles" home feed tab to "For you" across all five locales (en, de, es, fr, sl).
  • [Bug] Fixed Activity-tab comment cards being height-clipped without an expand control by wiring them to the same show-more/show-less toggle pattern used by highlight cards.
  • [Bug] Fixed fatal error when app.user.npub is null: UserFromNpub::mount() now accepts a nullable $ident and returns early instead of crashing.
  • [Feature] Added editor role management to admin roles (/admin/role): a new Editors section now lists users with ROLE_EDITOR and provides add/remove controls (same pattern as featured writers), including auto-creating user rows when adding by npub.
  • [Feature] Replaced Overview tab with Editorial tab in user profiles that displays only magazines and follow packs. The Editorial tab provides a focused view of the user's curated editorial content without mixed media/articles/highlights.
  • [Feature] Discover page now has three tabbed sections: Articles (existing article stream), Highlights (community highlights from kind 9802 events), and Editorial (magazines, follow packs, and curated collections combined). Users can toggle between tabs, and the selection is persisted to localStorage. All editorial content (kind 30040 magazines, kind 39089 follow packs, kind 30004/30005/30006 curation sets) is sorted by creation date in reverse chronological order. New Stimulus controller content--discover-tabs handles tab switching with smooth UI transitions. [Documentation: documentation/Reader/discover-tabs.md]
  • [Feature] Added a fourth Discover tab, Featured writers, which reuses the same featured-writer feed logic as /featured-articles (ROLE_FEATURED_WRITER filter + latest 50 articles + author metadata mapping) so both views stay in sync.
  • [Feature] Renamed Discover's "Highlights" tab to Activity and extended it to include both kind:9802 highlights and kind:1111 comments that reference long-form articles (kinds 30023/30024). Both item types are sorted by creation_at descending (newest first) and displayed in a unified feed with type-aware rendering: highlights show the highlighted text + article context, while comments display the comment text + article reference link.
  • [Improvement] Activity-tab comment links now show the referenced article title when available (fallback remains the generic "View article" label).
  • [Improvement] Limited the Discover Editorial tab to only show collections from users who have the ROLE_EDITOR role, ensuring curated content comes from designated editors.
  • [Improvement] Updated the Essayist landing hero heading copy across all locales from value-for-value wording to community-curated wording.
  • [Bug] Fixed Discover tabs sticky offset on mobile so the tab bar now anchors below the mobile header height instead of overlapping the fixed header.

v0.0.42

  • [Bug] Fixed mixed-case Nostr key handling: The swentel/nostr-php library's Key class requires strictly lowercase bech32 strings. Added defensive normalization to strtolower(trim()) at all key conversion points — in NostrKeyUtil, Twig filters, and all services/commands that call convertToHex() or convertPublicKeyToBech32(). This prevents "Data contains mixture of higher/lower case characters" and "Invalid bech32 checksum" exceptions from user-provided or display-layer key input.
  • [Bug] Fixed ChatBundle: ChatMessageService was missing ChatWebPushService injection, causing a fatal PHP error when self-sovereign users sent messages. Push notifications are now dispatched for both custodial and self-sovereign message paths.
  • [Bug] Fixed ChatBundle real-time message delivery: layout.html.twig was missing the <meta name="mercure-hub"> tag required by the Stimulus messages controller to set up the Mercure SSE subscription. Messages now arrive in real-time without a page reload.
  • [Improvement] Excluded more partials from visit counts in the visitor analytics.
  • [Feature] Added an Activity tab to /essayist/home that shows recent member activity from the current Essayist membership pool. A new EssayistMemberActivityService resolves current ROLE_ESSAYIST_MEMBER pubkeys, fetches recent events from local storage, and emits a mixed feed of highlights (kind 9802), reposts (kind 16), and comments (kind 1111). EssayistController::homeFeedTab() now supports activity, and the new tab partial renders each item via the existing bookmark/event card pipeline. Documentation: documentation/essayist-home-activity-tab.md.
  • [Improvement] Reused the same highlight card template as the /highlights feed for /essayist/home Activity highlights by extracting templates/partial/_highlight_feed_card.html.twig and rendering both pages through that shared partial.
  • [Improvement] Updated the /e/{ident} event page to render kind 9802 highlights through the same shared highlight card partial (templates/partial/_highlight_feed_card.html.twig) used by /highlights and Essayist Activity.
  • [Improvement] Essayist Activity highlight items now resolve a/A article references into preview cards (via generated naddr + parsed preview data), matching /highlights feed behavior when referenced content is available.
  • [Improvement] Reduced Updates Pro pricing.

v0.0.41

Essayist UI.

  • [Bug] Fixed stale update badges on author profile tabs. Mercure payloads for /author/{pubkey}/{contentType} now include recentCount and newestCreatedAt, and the profile tabs Stimulus controller ignores updates whose newest item is outside a 6-hour freshness window. Background profile revalidation now fetches author content with since=now-6h instead of all-time, so historical backfills no longer show up as fake "new" tab counts.
  • [Bug] Fixed the TipButton payment picker so targets are grouped by payment type, selected via a stable target key instead of a fragile list index, and Geyser targets open the corresponding https://geyser.fund/project/{target} page directly.
  • [Improvement] Added a visible loading indicator to the TipButton trigger while the modal is fetching the latest payment targets event, so clicking the button no longer feels unresponsive.
  • [Improvement] Author profiles now rely on TipButton only: kind 0 zap targets (lud16/lud06) are merged into the tip payment list with deduplication against existing kind 10133 lightning targets, and the separate profile ZapButton action has been removed.
  • [Improvement] TipButton now performs a targeted relay lookup for only kind 10133 when the tip modal opens, then renders the payment picker from that fresh event data.
  • [Improvement] Settings payment-target management now uses a dedicated page (/settings/payment-targets) instead of an inline Settings tab, matching the follow-pack flow. The Events tab now includes kind 10133 (Payment Targets) directly after kind 10002 (Relay List), with create/manage actions that deep-link to the new page.
  • [Improvement] Added an admin-only debug preview to the TipButton modal that shows the latest kind 10133 event payload used to derive payment targets so target parsing issues can be diagnosed directly in UI without exposing internals to regular users.
  • [Bug] Fixed the TipButton Live Component crashing with requires the "$index" argument when selecting a payment target. selectTarget() now reads the clicked target index as an explicit LiveArg, treats missing/invalid values as a recoverable component error instead of a 500, and has regression coverage in tests/Unit/Twig/Components/Molecules/TipButtonTest.php.
  • [Bug] Fixed profile pages showing only one website from kind:0 metadata. Website tags are now parsed as multi-value metadata, and the author section renders all distinct websites (including comma-separated legacy values) instead of only the first entry.
  • [Feature] Added NIP-A3 (payto:) payment targets — kind 10133 is now a first-class user-context event alongside relay lists, mute lists, and follows. A new App\Service\Nostr\PaymentTargetService parses each kind 10133 event's payto tags into typed PaymentTarget DTOs, normalizing the type to lowercase, validating it against ^[a-z0-9-]+$, deduplicating (type, authority) pairs, and attaching display metadata (label, short label, symbol) for the eight recognized RFC-8905 types (bitcoin, cashme, ethereum, lightning, monero, nano, revolut, venmo); unknown types still flow through with a generic label so clients can render them per NIP-A3. A new Twig\Components\Molecules\TipButton Live Component renders a "Tip" button next to the existing ZapButton on every article page (header + bottom) and on the author section in _author-section.html.twig, but only when at least one payment target exists for the author. The modal first lets the reader pick a payment method, then either runs the existing NIP-57 zap pipeline (LNURLResolver + NostrSigner::buildZapRequest) for the lightning type — producing a BOLT11 invoice + QR — or shows the raw payto://type/authority URI as a clickable link plus a scannable QR for everything else, with copy-to-clipboard for both the address and the full URI. When the author has a lud16 in their kind 0 but no kind 10133 yet, a synthetic lightning target is prepended so tipping works out of the box. A new Payments tab in /settings (templates/settings/tabs/_payments.html.twig) provides a dynamic editor with add/remove rows, validates and deduplicates entries client-side via the new nostr--nostr-settings-payment-targets Stimulus controller, and publishes the signed kind 10133 event through the existing api_settings_event_publish endpoint (kind 10133 was added to KindBundles::USER_CONTEXT so the endpoint accepts it). Persistence is wired into the existing pipelines: KindsEnum::PAYMENT_TARGETS = 10133, KindBundles::USER_CONTEXT, SyncUserEventsHandler::SYNC_KINDS (login sync from NIP-65 relays), and SubscribeLocalUserContextCommand::SUBSCRIBE_KINDS (local-relay user-context worker). Documentation in documentation/payment-targets.md.
  • [Bug] Fixed production AssetMapper startup silently continuing with broken or stale CSS/JS when the TypeScript bundle timed out downloading SWC from GitHub. The prod Docker image now installs a pinned SWC binary with retry-aware curl, configures sensiolabs/typescript-bundle to use /usr/local/bin/swc, compiles asset-map:compile at image build time, and the runtime entrypoint reuses the baked public/assets/manifest.json instead of redownloading compilers on every start. If runtime compilation is ever required and fails, the container now exits instead of serving a half-built frontend.
  • [Improvement] Production Compose now enables the relay gateway by default. compose.prod.yaml sets RELAY_GATEWAY_ENABLED to true unless explicitly overridden and clears the inherited gateway profile from relay-gateway, so docker compose -f compose.yaml -f compose.prod.yaml --env-file .env.prod.local up -d starts the gateway without --profile gateway.
  • [Bug] Fixed pasted Nostr identifier lookups timing out when the target event was not already cached locally. The synchronous /e/{nevent} path no longer performs blocking NIP-65 relay-list network discovery before trying event relay hints, and NostrClient::getEventById() now sends a single bounded relay query instead of serial gateway calls per relay. The HTTP request uses cache/DB author relay data plus relay hints for a fast first attempt, then falls back to the existing async relay search for broader discovery.
  • [Feature] NIP-70 protected article broadcast enforcement. Articles carrying the ["-"] tag (NIP-70) now surface a 🔒 Protected (NIP-70) badge on the article page (below the byline, with a tooltip explaining the meaning). The broadcast and "Broadcast to Essayist" actions in the article actions dropdown are hidden for all users except the article's author when the tag is present — the author still sees the broadcast button with a small 🔒 indicator to signal the restriction is active. Server-side, POST /api/broadcast-article enforces the same rule: unauthenticated requests receive HTTP 401 and authenticated non-author requests receive HTTP 403 with a descriptive message, so the protection cannot be bypassed directly. Article::isNip70Protected() encapsulates the tag scan so both the template and the controller use a single canonical source of truth. ArticleActionsDropdown::isAuthorOfArticle() performs the pubkey comparison (npub → hex, constant-time hash_equals) for the template layer. New articleActions.protectedBadge and articleActions.protectedNote translations.
  • [Feature] Article cards now show an Essayist source badge whenever the underlying article carries the essayist_exclusive flag. The badge is rendered directly inside templates/components/Molecules/Card.html.twig (next to the existing discussed / follows / interests / category badges) by reading article.essayistExclusive defensively via Twig's |default(false), so it lights up across every feed the Card is used in — for-you, latest, discussed, magazine, author, and search — without changes to each controller. New home_feed.source.essayist / home_feed.source.essayist_title translation keys added to all five locales (en, de, es, fr, sl) and a new .source-badge--essayist styling rule in assets/styles/03-components/source-badge.css keeps it visually distinct from the other badges.
  • [Feature] External clients publishing kind 30023/30024 articles directly to strfry-essayist now actually land in the local PostgreSQL article table. Added SubscribeEssayistRelayCommand (essayist:subscribe-relay) — a long-lived worker that connects to ESSAYIST_RELAY_INTERNAL_URL (defaults to ws://strfry-essayist:7779), subscribes to kinds 30023 and 30024, and pipes every incoming event through ArticleEventProjector. NostrRelayPool::subscribeLocal() gained an optional fifth argument ?string $relayUrlOverride so a single subscription loop can target any relay, not just the local default. RunRelayWorkersCommand (the worker-relay manager) auto-spawns the new worker whenever ESSAYIST_RELAY_INTERNAL_URL is set in the environment; it can be disabled with --without-essayist. When the env var is empty (the essayist compose profile is not active) the subscribe command exits cleanly with a warning instead of crash-looping; when the relay URL is set but the relay is unreachable the command waits 30 seconds and reconnects, so booting the essayist profile after worker-relay is already running just works. compose.yaml now passes ESSAYIST_RELAY_INTERNAL_URL through to the worker-relay service (empty by default).
  • [Feature] Essayist-exclusive auto-flagging now uses relay source + NIP-70 tag together as the discriminator, not the tag alone. ArticleEventProjector::projectArticleFromEvent() accepts a new bool $markEssayistExclusive = false argument; only the essayist:subscribe-relay worker passes true, and only when the incoming event actually carries the NIP-70 ["-"] (protected) tag. Every other ingestion path — local strfry subscriber, gateway persistence, author refresh, backfill, RSS, API fetch, embed prefetch, sync article fetch — defaults to false. ArticleFactory::createFromLongFormContentEvent() deliberately does not derive the flag from the - tag because NIP-70 is a generic "please do not re-broadcast" marker that any author on any relay can use for unrelated reasons (privacy, draft circulation, etc.); flagging on tag alone would shadow-hide unrelated authors' protected events that arrive via the local strfry router or NIP-65 outbox fetches. Combining the tag with the source-relay context is what makes the marker mean "Essayist-exclusive" specifically. The editor's "Publish ONLY to Essayist" toggle in EditorController::publishNostrEvent keeps its explicit setEssayistExclusive(true) call, and the per-article admin action (EditorController::toggleEssayistExclusive) remains authoritative for manual flips.
  • [Feature] Added an essayist_exclusive boolean flag on the article table so individual articles can be marked as Essayist-exclusive at the database level. All public listing/search paths in ArticleRepository (findLatestArticles, searchByQuery, findByTopics, findLatestByPubkeys, findByPubkey, advancedSearch + advancedSearchWithTags, findArticlesWithComments) now exclude flagged rows by default and accept an opt-in bool $includeEssayistExclusive = false parameter for member-only feeds. The single-article view (/p/{npub}/d/{slug} and the vanity variant) renders the standard "article not found" template for non-members so the existence of the exclusive is not disclosed. A new CLI command essayist:mark-exclusive <author> <slug> [--unmark] (accepts npub, hex pubkey, or full 30023:pubkey:slug coordinate) flips the flag across every revision row via ArticleRepository::setEssayistExclusiveByCoordinate(). Migration Version20260523120000 adds the column (default FALSE) plus a partial index on the TRUE subset. The editor's "Publish ONLY to Essayist" toggle wires the flag from EditorController::publishNostrEvent (after a server-side ROLE_ESSAYIST_MEMBER / ROLE_ESSAYIST_EARLY_BIRD / ROLE_ADMIN check on the caller), and the per-article actions dropdown can flip it later via EditorController::toggleEssayistExclusive.
  • [Feature] Added the Essayist members directory at GET /essayist/members (EssayistController::members). Access is gated: visitors must be logged in and hold one of ROLE_ESSAYIST_MEMBER, ROLE_ESSAYIST_CANDIDATE, or ROLE_ADMIN — anons are redirected to /essayist?join_status=login_required, logged-in users without the gating roles to /essayist?join_status=access_denied. The page lists every user holding ROLE_ESSAYIST_MEMBER who exposes a Lightning address (User::$lud16 or the Redis-cached metadata lud16) and renders the existing ZapButton Live Component per row, prefilled with recipientPubkey and recipientLud16 so contributors go through the standard NIP-57 modal (amount + comment → LNURL invoice → QR). New translations under essayist.members.* and new styles in assets/styles/04-pages/essayist.css under .essayist-members__*. The landing-page /essayist join section links to the directory from the member and pending-candidate branches (the only roles that can actually open the page).
  • [Feature] Added zap-receipt-backed automation for Essayist membership grants. New essayist_membership ledger table. New EssayistMembership entity + EssayistMembershipRepository. New EssayistMembershipService::recordGrant() is the only writer — it rejects payments below essayist.membership.minimum_sats (default 1000), short-circuits on duplicate receipt ids, finds-or-creates the payer's User row by npub, and computes expires_at using the calendar-month rule (a zap in month M activates access through the last second UTC of month M+1; multiple zaps in the same month converge on the same target, while a zap in a later month bumps the window forward). It then ensures ROLE_ESSAYIST_MEMBER is present and pre-warms the gateway membership cache via EssayistMembershipCacheService::markApproved(). A companion expireLapsed() revokes the role from users whose latest row has expired (skipping ROLE_ESSAYIST_EARLY_BIRD holders) and publishes a revocation via markRevoked().
  • [Feature] Added two cron commands. essayist:check-zap-receipts (EssayistZapReceiptWorkerCommand) runs every 5 minutes; it scans kind:9735 events from the last hour, parses the payer pubkey from the embedded zap request in the description tag, confirms the recipient (p tag) currently holds ROLE_ESSAYIST_MEMBER, extracts the amount from the receipt's amount tag (or the request's amount tag as fallback, both in millisats), and calls recordGrant() — idempotent via the receipt-id unique constraint. essayist:expire-memberships runs monthly on the 1st at 00:05 UTC (the calendar-month model means expirations only happen at month rollovers, so daily runs were wasted work) and is a thin wrapper over expireLapsed(). Both are wired into docker/cron/crontab.
  • [Feature] Added "Also publish to Essayist" and "Publish ONLY to Essayist" options to the article editor relays panel, visible to ROLE_ESSAYIST_MEMBER and admins (admin preview). Two new non-mapped EditorType checkboxes are rendered inside templates/editor/panels/_relays.html.twig alongside the regular outbox relay list, with a new ui--essayist-publish-options Stimulus controller that disables and clears the "also" checkbox while ONLY-mode is active and reveals a NIP-70 warning callout. The JS publish controller (nostr--nostr-publish) reads both flags from the form and, when ONLY-mode is active, adds the ["-"] (NIP-70 protected) tag to the event so cooperating relays do not re-broadcast it. The backend (EditorController::publishNostrEvent) ignores the flags unless the caller actually holds ROLE_ESSAYIST_MEMBER or ROLE_ADMIN; for ALSO-mode it appends %essayist.relay_public_url% to the user's NIP-65 outbox relays (deduped), and for ONLY-mode it replaces the relay list entirely with [essayistRelayPublicUrl] and passes ensureLocalRelay: false to NostrClient::publishEvent so the event is not mirrored to the local strfry relay.
  • [Bug] Fixed NIP-84 highlights not appearing on the referenced article page even though they were visible on the global /highlights listing. Highlight events ingested through generic paths (relay gateway, follow/author content fetches, relay subscription workers) were only being written to the event table — only the dedicated app:fetch-highlights cron and the in-app publish endpoint populated the highlight table the article page queries. Added App\Service\HighlightProjector and a hook in GenericEventProjector::projectEventFromNostrEvent so every kind:9802 event is mirrored into the highlight table idempotently (by event id), with the article coordinate extracted from a/A tags. Coordinate extraction now accepts 30023:, 30024:, 30040:, and 30041: prefixes (previously only 30023: in most ingestion paths), so highlights of drafts and publication content are mapped too. The projector also invalidates the per-article Redis highlight cache on insert so the next article render picks up the new row immediately.
  • [Improvement] Updated the Essayist prelaunch promo banner copy to emphasize readers, thoughtful writing, community, independent writers, and direct value-for-value support.
  • [Improvement] Private zaps in article comments now show Private instead of the generic Unknown label when the zapper pubkey is not available.
  • [Bug] Fixed "Broadcast to Essayist" reporting 1/2 relays even though the user only picked Essayist. NostrClient::publishEvent unconditionally augmented any non-empty relay list with the local strfry relay via ensureLocalRelayInList, so a single-target publish always ended up addressing two relays. Added an ensureLocalRelay parameter (default true for backwards compatibility) and ArticleBroadcastController now passes false because the caller already chose its targets explicitly. The Essayist broadcast now hits only strfry-essayist and the toast reads 1/1.
  • [Bug] Fixed strfry-essayist rejecting every incoming event with Plugin error: JSON object key "id" not found. strfry's plugin protocol requires each response line to echo the event's id ({"id":"<event-id>","action":"accept"}), but docker/strfry-essayist/write-policy.sh was emitting {"action":"accept"} with no id, so strfry refused to ingest the event and surfaced a generic error: internal error to the client. The script now extracts the event id from the strfry input JSON using POSIX parameter expansion (no jq / sed / grep needed) and includes it in every accept/reject response.
  • [Bug] Fixed strfry-essayist rejecting every incoming event with jq: not found followed by Plugin error: JSON object key "id" not found. The dockurr/strfry Alpine image does not ship jq, so the write-policy script crashed on its first line. Rewrote docker/strfry-essayist/write-policy.sh as pure POSIX shell using case pattern matching against strfry's minified plugin JSON ("kind":30023 is unambiguous because kind is numeric in the plugin input). No new package install required.
  • [Bug] Fixed strfry-essayist rejecting every incoming event with write policy blocked event … error: internal error caused by Permission denied when execing /app/write-policy.sh. The script is bind-mounted read-only and on hosts that did not preserve the executable bit (Windows checkouts, git mode loss) strfry could not run it, the plugin pipe closed, and strfry surfaced a generic internal error to the client. The compose entrypoint for strfry-essayist now copies the script to /usr/local/bin/essayist-write-policy.sh, chmods it +x, and strfry.conf points writePolicy.plugin at that path — independent of host file mode.
  • [Improvement] "Broadcast to Essayist" now sidesteps the NIP-42 AUTH gateway for logged-in ROLE_ESSAYIST_MEMBER users. ArticleBroadcastController rewrites any target relay URL that matches essayist.relay_public_url to essayist.relay_internal_url (default ws://strfry-essayist:7779) before publishing, so the PHP container publishes directly to the strfry-essayist relay on the compose network. Membership is already authenticated by the Symfony session and the strfry write-policy still enforces the kind:30023 filter as defence-in-depth; gateway AUTH is only needed for clients we don't already trust. URL matching is scheme/host/port normalised (default ports stripped, trailing slashes ignored).
  • [Bug] Fixed POST /api/broadcast-article returning success: true even when every relay rejected the event, which caused the article-actions dropdown to show a green "Broadcast: 0/1 relays" toast for failures. The endpoint now returns HTTP 502 with success: false and an error message when all relays fail, so the Stimulus controller surfaces a danger toast.
  • [Feature] Added "Broadcast to Essayist" action to the article actions dropdown for ROLE_ESSAYIST_MEMBER and ROLE_ADMIN. The item re-publishes the stored signed event (unchanged id/sig) to the Essayist members-only relay by calling the existing POST /api/broadcast-article endpoint with an explicit relays payload containing only the Essayist WSS URL. The relay URL is configured via new parameter essayist.relay_public_url (env ESSAYIST_RELAY_PUBLIC_URL, default wss://${ESSAYIST_RELAY_DOMAIN}) and exposed to Twig as the global essayist_relay_url. Admins see an (admin) preview badge so they can verify the flow without holding the membership role. A dedicated broadcastEssayist Stimulus action on ui--article-actions-dropdown reuses the broadcast machinery and emits an Essayist-specific toast.
  • [Bug] Fixed nostr: URI strings inside fenced code blocks and inline code spans being processed as embed cards on article pages. Converter::replaceBareTextNostr now tracks <code>/<pre> element depth while walking the HTML parts and skips all text nodes inside those elements.
  • [Bug] Fixed the article editor failing to load articles whose content contains \`` (backslash-escaped backtick) in inline code spans, e.g. `` `nostr:npub1...`` . `inlineMarkdownToOps` in `conversion.js` now handles backslash escapes (`\, \\, \*, etc.) before the inline-code check, so the nostr: token is never mistakenly promoted to a mention blot, and a lone unrecognised backslash no longer causes an infinite loop.
  • [Improvement] The "Latest from Essayist" sidebar widget on /essayist/home now uses Mercure SSE instead of polling. EssayistController::home() activates a StartRelayFeedMessage subscription for the strfry-essayist relay (reusing the existing relay-feed infrastructure), derives the Mercure topic /relay-feed/{key}, and passes it to the template. The content--essayist-latest Stimulus controller opens an EventSource on that topic, prepends new article cards as they arrive, and trims the list to 2 items. A new POST /essayist/home/keepalive endpoint renews the Redis active flag every 5 minutes so the async worker keeps its WebSocket subscription open. The Turbo Frame polling endpoint (/essayist/sidebar/latest) is retained for compatibility but is no longer used by the home page.
  • [Feature] Added personalized Essayist home page (GET /essayist/home) for members (ROLE_ESSAYIST_MEMBER) and admins. The page uses the existing content--home-tabs Stimulus controller with Turbo Frame loading across three tabs: For You (merged articles from followed pubkeys + topic interests, sourced from strfry-essayist), Follows (articles from your follows on the Essayist relay), and Topics (articles matching your kind:10015 interest tags). Extended EssayistFeedService with fetchByPubkeys(array $pubkeys) and fetchByTopics(array $hashtags) relay filter methods, and enhanced buildCard to expose t tag values as $card->topics. The aside features the configured ESSAYIST_WRITERS follow pack (up to 8 members with avatars and names, plus a link to the full pack). A live "Latest from Essayist" widget in the aside uses the new Atoms:LatestEssayistArticles Twig component and a content--essayist-latest Stimulus controller that reloads the Turbo Frame every 30 seconds — new essays slide in from the top and the oldest drop off automatically. Landing page member CTAs updated to link to /essayist/home instead of the flat feed. Added essayist.home.* translation keys in all six locale files.
  • [Feature] Implemented Essayist gated feed page (GET /essayist/feed). Added EssayistFeedService that connects directly to strfry-essayist:7779 via WebSocket, fetches kind:30023 articles until EOSE, and converts events to stdClass cards compatible with CardList/Card. The feed route performs manual role-checking: non-members/anons are redirected to the landing page with join_status=access_denied; ROLE_ADMIN bypasses the membership gate and sees an admin-preview banner. Added essayist.relay_internal_url parameter (env ESSAYIST_RELAY_INTERNAL_URL, default ws://strfry-essayist:7779) and wired EssayistFeedService in services.yaml. Landing page "Visit front page" CTA now links to /essayist/feed for members; admins get an "Admin preview" button. Added essayist.feed.* translation keys across all six locale files.
  • [Bug] Fixed Essayist launch date comparison using server local time. LAUNCH_DATE and EARLY_BIRD_DEADLINE are now full RFC 3339 datetime strings anchored to midnight UTC (2026-06-01T00:00:00+00:00 / 2026-05-31T00:00:00+00:00), and isLaunched is evaluated against a UTC now, so the relay opens precisely at 00:00 UTC on 1 June regardless of server timezone.

v0.0.40

Essayist membership gateway.

  • [Feature] Implemented essayist-gateway (Go, in docker/essayist-gateway/): NIP-42 challenge generation, kind:22242 verification with strict relay-URL normalisation and ±60s created_at tolerance, two-tier membership lookup (Redis fast-path + PHP slow-path with circuit breaker), push-based revocation via Redis pub/sub on essayist_member_revoked that forcibly closes live authenticated sockets, NIP-11 passthrough (whitelisted to GET / with Accept: application/nostr+json), per-IP and global connection caps, pre-auth frame buffer with size cap, /health probe (process + upstream TCP + Redis PING) and Prometheus /metrics. Two-stage Alpine Dockerfile with embedded go test step.
  • [Improvement] Added essayist-gateway to compose.yaml under the essayist profile; Caddy @essayistRelay now routes to essayist-gateway:7780 instead of strfry-essayist:7779. Removed the host-port mapping from strfry-essayist — the relay is reachable only from the gateway on the internal Docker network. ESSAYIST_POLICY_TOKEN and REDIS_PASSWORD are now required (:? interpolation) instead of falling back to insecure defaults.
  • [Improvement] Reduced docker/strfry-essayist/write-policy.sh to a kind-only filter. Membership enforcement happens upstream at the gateway, so the relay no longer needs curl, jq -e, or the ESSAYIST_POLICY_TOKEN secret to call back into the Symfony app.
  • [Feature] Added App\Service\Essayist\EssayistMembershipCacheService that pre-warms the gateway's Redis fast-path on grant (SETEX essayist_member:{pubkey} 600 1) and publishes a revocation message on revoke (DEL + PUBLISH essayist_member_revoked {pubkey}). Wired into EssayistAdminController::grantMember/revokeMember/approveCandidate, the claimEarlyBird flow on EssayistController, and the user:elevate CLI command. Redis failures are logged but never block the underlying role change.
  • [Bug] Fixed unhandled Invalid bech32 checksum crash in ArticleController when a stored pubkey or user identifier is malformed — all Key::convertToHex / Key::convertPublicKeyToBech32 calls in the disambiguation and draft actions are now wrapped in try/catch and return a proper 404/403 response instead of a 500.
  • [Bug] Fixed UserFromNpub component crash on the article disambiguation page — the component was passed :pubkey but its mount() method expects :ident.
  • [Bug] Fixed admin/publication_subdomain/index.html.twig throwing a Twig syntax error ("content outside Twig blocks") caused by a UTF-8 BOM character at the start of the file.
  • [Improvement] Summaries and descriptions in collection list views (magazines, reading lists, follow packs) are now clamped to 5 lines. Uses the existing .line-clamp-5 utility class applied to the summary paragraph in ZineList, ReadingListList, FollowPackList, and the reading-list management page. when edited through the reading list wizard. ReadingListManager::loadPublishedListIntoDraft() now reads the event's type tag and stores it in the read_wizard_type session key, so the review step preserves the original type instead of defaulting to reading-list.
  • [Bug] Fixed my-content page routing all kind 30040 items to the reading list editor. Items with rawType=magazine now link to the magazine wizard (mag_wizard_edit). All other kind 30040 items (reading lists, categories) show an additional "Edit as magazine" icon (⊞) alongside the existing reading list edit icon, making the magazine wizard accessible even for mistyped events.
  • [Feature] Optional author field on magazines and reading lists: an author input is now available in the magazine setup wizard and the reading list setup wizard. When filled in, the value is stored as an author tag on the published event. Wherever a byline is displayed (magazine hero, reading list detail page, reading list card component), the plain-text author value supersedes the publishing npub and is shown as unlinked text. If no author override is set, the existing UserFromNpub profile link is shown as before.
  • [Feature] Reading list cover images: cover images stored in the image tag of reading list events (kind 30040) and curation sets are now displayed on the public lists page, the individual reading list detail page, and the "My Reading Lists" management page — consistent with the existing magazine and follow pack card patterns.
  • [Feature] "Included in" sidebar on article pages now links to the reading-list route (instead of the magazine-index route) when the containing kind 30040 event has a type: reading-list tag, giving readers a direct link back to the reading list.
  • [Improvement] Fixed Symfony validator/form deprecations: converted array-style constraint options (NotBlank, Regex, PositiveOrZero) to named arguments in MediaAttachmentType and ZapSplitType; added default_protocol: null to all UrlType fields in MediaAttachmentType, EditorType, and AdvancedMetadataType.
  • [Bug] Fixed reading list wizard loading the previous reading list draft when starting a new one. Same pattern as the magazine wizard fix: added a read_wizard_new route that clears the session draft; all external "Create reading list" entry points (reading list index, dropdown component, my-content toolbar) now point to this route. A warning notice is shown on the setup page when an existing draft is detected.
  • [Bug] Fixed magazine wizard always loading the previous magazine draft when navigating to the setup page. Added a mag_wizard_new route that clears the session draft before starting the wizard; all external "Create magazine" entry points (nav, newsstand, my-magazines, subscription pages) now point to this route. When a session draft is present and the user arrives at the setup page directly, a warning notice is shown with a "Start a new magazine instead" link to the new route.
  • [Bug] Fixed "Your Subdomain" section appearing on the magazine launched page after skipping the subdomain step. The launched() action was showing any subscription regardless of status, causing a previous cancelled/inactive subscription (from a different magazine) to render with a dangling and no badge. Fixed by applying the same active/pending status filter already used in the subdomain step, and moved the separator inside the status badge conditions in the template.
  • [Bug] Fixed "View my magazine" button on the magazine launched page linking to the magazine list instead of the newly created magazine. The launched() action now reads the slug from the session draft and passes it as magazineSlug to the template; the button links directly to /mag/{slug}. The "Edit magazine" bottom button now also links to mag_wizard_edit/{slug}.
  • [Bug] Fixed newly published magazines not appearing in the "My Magazines" list. Two root causes: (1) The mag_wizard_launched page now proactively calls EventIngestionListener::processRawEvent() for the just-published magazine index and all new category events before the page renders, ensuring current_record is always up to date when ZineList queries it — regardless of whether the silent non-fatal processEvent() call in api-index-publish succeeded. (2) The nostr_index_sign_controller.js was stripping ALL a tags from the magazine event skeleton before rebuilding, losing existing-list category references — fixed to preserve any a tags that are not being re-signed in the current session.
  • [Feature] Designed essayist-gateway: a standalone Go WebSocket proxy that sits between Caddy and strfry-essayist. Every inbound connection must complete a NIP-42 AUTH handshake; the gateway verifies the kind:22242 event (signature, relay URL, challenge, timestamp) and checks active ROLE_ESSAYIST_MEMBER via a two-tier Redis cache / PHP API lookup before forwarding to the relay. Documented in documentation/essayist-gateway.md.

v0.0.39

Essayist.

  • [Bug] Fixed mobile library/settings pane scrolling so the last items in the list are now accessible instead of being cut off by the fixed overlay sizing.

  • [Bug] Fixed media_post_cache.kind column overflowing for Nostr event kinds above 32 767 (e.g. kind 34235). Column type changed from SMALLINT to INTEGER.

  • [Bug] Fixed ArticleInPublication Doctrine mapping generating column name container_dtag instead of the actual DB column container_d_tag. Added explicit name: 'container_d_tag' to the ORM column attribute, resolving "could not look up publications for article" errors.

  • [Improvement] Extracted Essayist public routes (/essayist, /essayist/early-bird, /essayist/request-access) from StaticController into a dedicated EssayistController. Route names and all templates are unchanged.

  • [Bug] Fixed Essayist join button being permanently disabled. The controller now passes isLaunched (true when current date ≥ 2026-06-01). The logged-in submit button and the anonymous CTA both enable automatically on launch day; the "opens on" hint is hidden once the relay is live, and the anonymous CTA becomes a login link instead of a dead button.

  • [Improvement] Removed ROLE_ESSAYIST_AUTHOR and ROLE_ESSAYIST_SUPPORTER from RolesEnum and all admin tooling. approveCandidate() now grants ROLE_ESSAYIST_MEMBER directly instead of ROLE_ESSAYIST_AUTHOR. The Approved Authors and Supporters sections have been removed from the admin template.

  • [Bug] Fixed Essayist write-policy API endpoint checking ROLE_ESSAYIST_AUTHOR instead of ROLE_ESSAYIST_MEMBER. Early-bird members (and future pool contributors) could not publish to the relay despite holding the correct role. Updated EssayistWriterPolicyController and the write-policy.sh rejection message to use ROLE_ESSAYIST_MEMBER.

  • [Feature] Relay feeds now respect the logged-in user's NIP-51 mute list (kind 10000). Articles from muted pubkeys are filtered out on page load (server-side) and also suppressed as they arrive via live Mercure updates (client-side in the Stimulus controller).

  • [Bug] Fixed the "Sign & Publish" button in the media manager's Create Picture/Video modal doing nothing. The signAndPublish() method was only dispatching a Stimulus event with no listener. It now uses getSigner() to obtain the user's pubkey, injects it into the draft, signs the event, and POSTs the signed event to a new /api/media/publish/event endpoint which verifies the signature and publishes to the user's write relays. Added a status indicator in the draft section for feedback.

  • [Improvement] Deprecated article-count and Lightning address eligibility requirements for ROLE_ESSAYIST_CANDIDATE. The regular join path is disabled until 1.6.2026 and will be gated solely on the one-time 100-sat relay initialization fee going forward. No code changes were needed — requirements had already been removed from the controller; docs and changelog updated to reflect the new policy.

  • [Feature] Added Early Bird section to the Essayist landing page. The relay goes live on 1 June 2026; logged-in users can claim free access for the whole of June with a single click. Sign-up assigns ROLE_ESSAYIST_EARLY_BIRD and ROLE_ESSAYIST_MEMBER with no payment required. Offer available until 31 May 2026. Translated into all six supported locales.

  • [Bug] Fixed published_at / created_at semantics in the editor. published_at was always overwritten with the current timestamp on every save, which broke the "first published" date shown on articles. It is now preserved from the existing article record and only set to the current time on the very first publish. created_at (the Nostr event field) continues to always reflect the current revision timestamp.

  • [Bug] Fixed dark mode in the editor: the dark theme CSS overrides were scoped to .editor-shell but the actual element uses .editor-layout, so variables were never applied. Also fixed the Quill toolbar having a hardcoded background: white instead of using the CSS variable.

  • [Bug] Fixed article publishing from the editor failing to publish to the project relay (wss://relay.decentnewsroom.com). The relay list stored in the User entity (populated via getRelayListForDisplay()) contains the public project relay URL; when that list was passed directly to NostrClient::publishEvent(), the project relay was kept alongside the local relay, causing Docker to open an external WebSocket to itself (which hangs). publishEvent() now filters out project relay URLs whenever the local relay is present, mirroring the same guard already applied in getRelaysForPublishing().

  • [Bug] Fixed /subscription/vanity failing for anonymous visitors. The Twig template used a non-existent login route in the signed-out CTA; it now points to the correct app_login route.

  • [Improvement] Removed the redundant writer-requirements intro sentence from the Essayist landing page and deleted the now-unused essayist.landing.requirements.intro translation key across all locales.

  • [Improvement] Removed the redundant second sentence in the Essayist model section and deleted the now-unused essayist.landing.model.paragraph2 translation key across all locales.

  • [Bug] Fixed stale users still seeing ws://strfry:7777 in the editor relays panel after the initial fix. Existing User.relays values are now normalized on editor load via UserRelayListService::normalizeRelayListForDisplay(), which rewrites internal local relay URLs to the public project relay URL and deduplicates entries.

  • [Feature] Added a profile-owner CTA on author pages: when your profile has no nip05 value set, a Get NIP-05 button is shown next to Settings and links to vanity-name signup (vanity_index).

  • [Feature] Added /admin/essayist administration page. Admins can review pending writer candidates (ROLE_ESSAYIST_CANDIDATE) with article counts, approve or reject them, manage approved authors (ROLE_ESSAYIST_AUTHOR) with downgrade/revoke actions, and manually grant or revoke supporter access (ROLE_ESSAYIST_SUPPORTER). Linked from the admin dashboard.

  • [Feature] Implemented Essayist writer self-signup on /essayist. Logged-in users can submit a writer request; backend verifies CSRF token, requires at least 3 deduplicated longform articles known to DN plus a lud16 Lightning address, and assigns ROLE_ESSAYIST_CANDIDATE on success. Added in-page signup status messages, eligibility indicators, and a dedicated POST route (/essayist/request-writer-access).

  • [Feature] Added a public static Essayist landing page at /essayist with a launch explainer and timeline. The page introduces the writer-first rollout, shows writer requirements and moderation rules, marks reader/supporter access as coming soon, and gives Essayist a public home before the interactive signup flow is implemented. Added page styles in assets/styles/04-pages/essayist.css, wired them in assets/app.js, and documented the feature in documentation/Newsroom/essayist-landing-page.md.

  • [Bug] Fixed editor relays panel displaying the internal Docker address (ws://strfry:7777) instead of the public project relay URL (wss://relay.decentnewsroom.com) when a user has that relay in their kind 10002 list. Added UserRelayListService::getRelayListForDisplay() which resolves relay lists without remapping the project relay to the local hostname.

  • [Bug] Fixed settings relay-list publishing (kind 10002) falling back to local-only fanout. The signed relay list is now parsed immediately on publish, seeded into UserRelayListService cache+DB write-through, and the publish fanout targets the relays declared in the event tags (with local relay included), so subsequent profile/article publishes use the fresh relay set without waiting for async revalidation.

  • [Bug] Fixed user_relay_list write failing with SQLSTATE 23505 duplicate-key error on Postgres sequence desync (common after DB restores or bulk imports). Replaced the ORM find+persist pattern in persistToDatabase with a native INSERT … ON CONFLICT (pubkey) DO UPDATE … WHERE created_at < EXCLUDED.created_at upsert that atomically handles both insert and update paths, making sequence desync irrelevant.

  • [Bug] Fixed kind:10002 relay-list publish hanging until PHP max execution time. The project relay public hostname (wss://relay.decentnewsroom.com) was included in the publish fanout from the event tags verbatim; opening an external WebSocket to the same-physical strfry instance from inside Docker hangs. The project relay is now skipped in kind:10002 fanout via isProjectRelay() (the same guard used everywhere else). Both settings publish endpoints now also cap relay connections at 10 seconds so a slow external relay cannot block the HTTP response.

  • [Improvement] Profile metadata publish (kind:0) now also fans out to configured profile relays (relay_registry.profile_relays, e.g. wss://purplepag.es) in addition to the normal publishing relay set, improving profile discoverability across metadata directories.

  • [Feature] Added relay feeds (/relay-feed): browse the live article stream from content relays. A time-bounded async worker (5-min windows, auto-renewing) subscribes to kind:30023 events on that relay over WebSocket. New articles are pushed to the browser in real time via Mercure (public topic /relay-feed/{key}). Up to 100 articles per relay are held in a Redis rolling buffer so late-joining viewers receive history immediately. Articles are displayed as raw cards (image, title, summary) with no ingestion or QA processing — full pipeline is triggered only when the user clicks to read. Any number of viewers can share the same Mercure topic for a given relay. The relay selector is a dropdown limited to the project and content relays; arbitrary URLs are rejected.

  • [Feature] Added relay suggestion form embedded in /relay-feed. Sends a public kind-1 Nostr note (tagged t: nostr-relay) using the same nostr--nostr-single-sign pattern as the feedback form. No custom controller needed.

  • [Fix] Relay feeds no longer compete with async_low_priority workers. StartRelayFeedMessage is now routed to a dedicated async_relay_feeds transport. Three parallel consumers run on this transport (via app:run-workers), allowing up to three feeds to subscribe concurrently without queueing behind each other or starving background tasks like fan-out notifications and login warmup.

  • [Feature] Added relay feed author bylines on cards. Server-rendered buffered cards resolve names via UserFromNpub, and live Mercure cards include a linked author identifier using npub (with short hex fallback when conversion is unavailable).

  • [Bug] Fixed relay-feed and other UserFromNpub bylines falling back to shortened npubs for authors whose local profile had name but no display_name. RedisCacheService now treats a non-fallback name as valid metadata when reading from DB, and metadata cache writes now use the same hex-pubkey cache key format as reads (0_{hexPubkey}), preventing avoidable cache misses.

  • [Feature] Added Essayist relay (strfry-essayist): a dedicated strfry instance on port 7779 with a writer-first write policy. Only approved Essayist writers (users with ROLE_ESSAYIST_AUTHOR) can publish kind 30023 (published longform articles). All other pubkeys and kinds are rejected with a descriptive message. Activate with docker compose --profile essayist up -d.

  • [Feature] Added internal writer-approval API endpoint GET /api/internal/essayist/writer/{pubkey}. Called by the strfry-essayist write-policy.sh script on every incoming EVENT, protected by a shared bearer token (ESSAYIST_POLICY_TOKEN). Returns {"approved": true/false} based on user role (ROLE_ESSAYIST_AUTHOR).

  • [Feature] Added ROLE_ESSAYIST_AUTHOR and ROLE_ESSAYIST_SUPPORTER to RolesEnum. Writers are granted ROLE_ESSAYIST_AUTHOR via user:elevate to enable publishing on the Essayist relay. Readers are granted ROLE_ESSAYIST_SUPPORTER to access the Decent Newsroom Essayist feed page during the pre-public-launch phase. No public follow pack is published; the whitelist remains proprietary to the instance.

  • [Feature] Reserved essayist.decentnewsroom.com as the public WebSocket endpoint for the Essayist relay (wss://essayist.decentnewsroom.com). Caddy routes this host directly to strfry-essayist:7779 via the new @essayistRelay matcher in frankenphp/Caddyfile. Controlled by ESSAYIST_RELAY_DOMAIN env var (defaults to essayist.localhost in development).

  • [Docs] Defined Essayist pre-public-launch read model: during Phase 0, reads are served exclusively through a gated Decent Newsroom feed page (/essayist) backed by server-side relay queries; the relay URL is not publicly advertised. Access to the feed page requires ROLE_ESSAYIST_SUPPORTER, earned by supporting an approved writer. Renamed ROLE_ESSAYIST_READER to ROLE_ESSAYIST_SUPPORTER throughout the implementation notes to match the access model.

v0.0.38

Usability and styles.

  • [Bug] Fixed the unread counter on the Updates page always showing 0. The controller was calling markAllRead() before countUnreadForUser(), so the count was computed after all items were already marked read. The unread count is now captured before the mark-all-read call.
  • [Feature] Named interest sets (kind:30015) now appear as pills on the discover page. Logged-in users whose local DB contains kind:30015 sets (owned or followed) see a pill per set after the "My Interests" pill; each links to a new /my-interests/set/{pubkey}/{dTag} page that renders the set's tag strip and a paginated article list.
  • [Improvement] Replaced the topic pills strip on the discover page with a single "My Interests" pill. The full list of topic category pills has been removed; logged-in users who have a published interests list (kind 10015) now see a single pill linking to /my-interests.
  • [Improvement] Deprecated the topics list in the aside sidebar. The ForumAside Twig component (and its PHP class) have been removed; all templates that previously rendered it now have an empty aside block.
  • [Improvement] Removed icons for login options, adjusted the hints.
  • [Bug] Fixed async queue flooding that caused the async transport to accumulate thousands of messages within hours. Three compounding dispatch-without-deduplication patterns were addressed: (1) Comments::mount() now uses a 60-second Redis NX throttle (via new DispatchThrottle service) so a popular article receiving many concurrent page views enqueues at most one FetchCommentsMessage per minute instead of one per visitor. (2) RevalidateProfileCacheHandler now applies a 5-minute Redis NX throttle before dispatching FetchAuthorContentMessage, preventing a burst of profile-tab revalidations for the same author from fan出 into dozens of relay-fetch jobs on async. (3) AuthorController articles tab previously dispatched RevalidateProfileCacheMessage unconditionally on every page view; it now checks viewStore->fetchProfileTabData() first and only dispatches when the cached entry is absent or stale.
  • [Improvement] Removed Media, Podcasts, and News Bots tabs from the authenticated home feed, and removed the Follow Packs admin section that configured their sources. The underlying routes, FollowPackAdminController, its template, and the admin dashboard nav card are all removed.
  • [Bug] Fixed legacy /article/d/{slug} disambiguation (and /article/d/{slug}/draft) URLs falling into the /{vanity}/d/{slug} vanity-name resolver because the vanity route has priority: 5 while the article/d/… routes defaulted to priority 0. Both /article/d/{slug} and /article/d/{slug}/draft now declare priority: 10 so Symfony matches them before trying to resolve "article" as a vanity name.
  • [Bug] Fixed editor Preview tab displaying raw LaTeX/math delimiters instead of rendered equations. After injecting the server-rendered HTML into the preview body, renderMathInElement (KaTeX) is now called on the preview container — matching the same pipeline the article reader uses. The hasRealMath and normalizeDollarMathInTextNodes helpers are exported from katex_controller.js and shared with layout_controller.js. A secondary fix ensures the preview always uses the freshest available content: when Quill is the active source the current delta is converted to markdown on the fly, and when CodeMirror is active its live doc is read instead of the (potentially stale) textarea value.
  • [Feature] Bot-traffic differentiation in visitor analytics. Every incoming HTTP request is now inspected by BotDetector, which matches the User-Agent header against ~60 patterns covering search-engine crawlers, social-media preview bots, SEO tools, HTTP libraries (curl, Python-requests, Scrapy, etc.), and headless browsers. Detected bots are stored with is_bot = true; empty / missing UAs are unconditionally flagged. All existing analytics queries (VisitRepository::applyTrackedVisitFilters) now append AND is_bot = false, so visit counts, unique visitors, bounce rate, and time-series charts automatically reflect human traffic only. The visit table gained two new columns: user_agent VARCHAR(512) and is_bot BOOLEAN DEFAULT false (migration Version20260511120000). The admin analytics page has a new Bot Traffic section with bot-vs-human comparison cards (with a red alert when bots exceed 50 %), a 30-day bot traffic chart, and a top-bot-UA table. The Recent Visits table now shows the User-Agent for each hit. See documentation/Reader/bot-detection.md.
  • [Security] Hard-block scanner/attacker probe paths at the Caddy layer. A path_regexp matcher (@blockProbes) in frankenphp/Caddyfile intercepts common attack-surface probes (.env*, .git/*, phpinfo.php, wp-login.php, xmlrpc.php, adminer.php, composer.json/lock, .htaccess, /actuator/*, and more) and responds with 404 before FrankenPHP ever boots a PHP worker — zero Symfony routing cost, zero DB writes, zero visit-tracking overhead.
  • [Bug] Fixed "Load more" in profile media tab showing only placeholder backgrounds indefinitely. Images injected by the JS controller were using src directly, bypassing the media--image-loader Stimulus controller. The media-discovery.css stylesheet (globally loaded) sets .masonry-image { opacity: 0 } and only reveals images via the .loaded class added by image-loader on successful load. Dynamically-inserted items now use data-src + data-controller="media--image-loader" + data-media--image-loader-target="image", matching the server-rendered Twig template.
  • [Bug] Subscriptions page now shows human-readable titles for both publication and NIP-51 set subscriptions. Publication subscriptions resolve the title from the Magazine entity first and fall back to the kind 30040 Event's title tag; NIP-51 set subscriptions now look up the matching event by coordinate and display its title or name tag instead of the raw naddr1… label string. The d-tag identifier is used as last resort in both cases.
  • [Improvement] Redistributed Messenger transport routing to drain the async backlog and prevent similar pile-ups. FanOutUpdateMessage (high-volume: one per subscriber per ingested event), FetchMediaEventsMessage (cron), FetchMissingCurationMediaMessage, and ProjectMagazineMessage are moved from async to async_low_priority, whose dedicated consumer was previously idle. Three previously-unrouted messages are now explicitly routed: PrefetchNostrEmbedsMessage and FetchHighlightsMessage (both were running synchronously inside HTTP requests, slowing page loads) and PersistGatewayEventsMessage — all go to async_low_priority. The async transport is now reserved for strictly user-facing fetches: comments, author articles/content, and on-demand event lookups. The async_low_priority consumer memory limit raised from 128 M to 192 M to handle the broader workload.
  • [Improvement] Better profile page organization.
  • [Bug] Latest-articles feeds on the discover page now exclude articles whose titles start with EA://INTEL or EA://NEWS. The LatestArticlesExclusionPolicy gained an EXCLUDED_TITLE_PREFIXES constant and a hasBotTitlePrefix check that is applied before the promotional-spam heuristic; both the Redis cached view path and the database fallback benefit automatically.
  • [Bug] NIP-05 badges no longer display non-NIP-05 values stored in a user's nip05 field. Some users set their field to a bech32 Nostr string (e.g. nprofile1…, npub1…) instead of a local@domain.tld identifier. The Nip05Badge component now validates the structural format (local@domain.tld — local part alphanumeric/dot/dash/underscore, domain contains a dot, no whitespace) before rendering anything. Values that fail this check are silently dropped; the verified/unverified display logic only runs for structurally valid identifiers.
  • [Bug] NIP-05 badges now show all of a user's identifiers, not just the first verified one. Previously Nip05Badge was hidden entirely when verification failed (network error, DNS lookup failure, pubkey mismatch), so users with multiple NIP-05 identifiers only saw the one(s) that passed the live check. Unverified identifiers now render in a muted style so the identifier is always visible; verified identifiers keep the green badge with the checkmark and relay count tooltip. Previously Nip05Badge was hidden entirely when verification failed (network error, DNS lookup failure, pubkey mismatch), so users with multiple NIP-05 identifiers only saw the one(s) that passed the live check. Unverified identifiers now render in a muted style so the identifier is always visible; verified identifiers keep the green badge with the checkmark and relay count tooltip.
  • [Feature] Vanity name registration is now paid again via Lightning. The SUBSCRIPTION (5,000 sats/quarter, 90-day term) and ONE_TIME (100,000 sats, lifetime) payment types are restored as the user-facing options; FREE is deprecated and no longer issued to new registrations. The registration form now presents both pricing option cards with embedded Lightning invoice flow: users reserve a name and are redirected to an invoice page (QR code + BOLT11 copy + payment poll via utility--payment-status Stimulus controller). Payment is confirmed automatically by vanity:check-receipts (now wired to cron every 5 minutes) matching zap receipts against pending BOLT11 invoices. vanity:process-expired is also wired to cron (daily at 1 AM) to release lapsed subscription names. The ui--vanity-name Stimulus controller gained plan-selection card highlighting and a paymentRadio target. The NIP-05 /.well-known/nostr.json endpoint now includes both ACTIVE and PENDING records so a paid reservation is resolvable immediately during the payment confirmation window (via new VanityNameRepository::findActiveOrPendingByVanityName and findAllActiveOrPending methods).
  • [Feature] Articles now show a "Included in" sidebar widget linking to any magazine(s) that contain the article. A new article_in_publication index table tracks which kind 30040 events directly reference each article coordinate. GenericEventProjector keeps the index up-to-date on every kind 30040 ingestion; a one-time app:rebuild-publication-index command populates it from existing data. When an article belongs to a category inside a magazine, the sidebar shows Magazine › Category with links to both.
  • [Bug] Latest-articles feeds now skip rotating referral-code spam posts even when they use a fresh pubkey every time. The shared LatestArticlesExclusionPolicy now treats titles/summaries/contents containing referral-code promo text as feed-excluded, and both the Redis cached view and the database fallback apply the same rule so the items disappear immediately instead of waiting for the next cache refresh.
  • [Bug] Fixed the admin dashboard "Total Articles (unique by slug)" metric materially under-counting the article corpus. The previous query was COUNT(DISTINCT slug) FROM article, which collapsed every cross-author slug collision into a single count — so the 200 different authors who each have a hello-world, about, welcome, getting-started, or otherwise-common slug were being counted as one article. Per NIP-01 a parameterized-replaceable event is uniquely keyed by (kind, pubkey, d-tag); two different authors with the same slug are two different articles. AdminDashboardService::getContentStats now uses COUNT(*) FROM (SELECT 1 FROM article WHERE kind = 30023 ... GROUP BY pubkey, kind, slug) for total + 24h/7d/30d figures, and the dashboard subtitle was corrected from "(unique by slug)" to "(unique by author + slug)". Drafts (kind 30024) are excluded from the public dashboard count. Also added two new metrics: total_article_rows (raw row count) and redundant_revisions (= rows beyond the NIP-01 latest per coordinate); the dashboard surfaces "Redundant Revisions" as a dedicated card that turns red whenever it is non-zero, with copy pointing the operator at articles:purge-revisions.
  • [Bug] Hardened the article-revision pipeline so older revisions never accumulate in Postgres or Elasticsearch. Four converging fixes: (1) ArticleEventProjector::projectArticleFromEvent now performs an explicit NIP-01 ordering check on (pubkey, kind, slug) before persisting — late-arriving stale revisions (slow relay, RSS importer reaching back, articles:backfill-local against a relay with older history) are dropped silently instead of being persisted alongside the current revision; older existing revisions are removed before the new one is persisted, so each goes through Doctrine's postRemove lifecycle and the FOS Elastica listener evicts the stale ES doc. A UniqueConstraintViolationException from a concurrent writer is treated as already-ingested and yielded gracefully. (2) ReplaceableEventCleanupService::removeOlderArticleRevisions was rewritten from a bulk DQL DELETE (which bypasses Doctrine lifecycle events entirely — the historical reason ES kept ghost revisions long after the DB row was gone) to fetch + iterate + EntityManager::remove() + flush(), so every removed revision fires postRemove and the FOS Elastica listener drops the ES doc in lockstep. (3) EditorController::publishNostrEvent now calls removeOlderArticleRevisions after persisting a writer's edit — previously every editor save left the prior revision alive in the DB and ES, the dominant cause of revision overflow on writers who edit frequently. (4) FetchAuthorContentHandler::saveArticle (which independently persisted articles during author-refresh fetches with a broken eventId-on-Event-repo idempotency check and zero cleanup) was deleted; the article path now routes through ArticleEventProjector::projectArticleFromEvent so it shares the same NIP-01 guard, ES-aware cleanup, and graph-table sync. Added App\Repository\ArticleRepository::findAllRevisionsByCoordinate() helper used by both the projector and the new purge command. Added articles:purge-revisions console command — the proper hard-dedup tool that, unlike articles:deduplicate (which only flags rows DO_NOT_INDEX), deletes redundant rows directly via EntityManager::remove() so Postgres and ES converge in a single pass; supports --pubkey, --batch-size, --sleep-ms, --max-batches, --dry-run. See documentation/Reader/article-revision-hardening.md.
  • [Bug] Fixed author profile pages still showing empty article lists for many npubs even though the same articles render correctly in feeds and on direct article pages. Root cause: the previous "always query the DB" fix routed through ArticleSearchInterface::findByPubkey(), which on production resolves to ElasticsearchArticleSearch when ELASTICSEARCH_ENABLED=true. ES indexing lags behind ingestion (and can drop documents on partial reindex), so findByPubkey returned an empty hit set while the underlying article rows existed in Postgres — feeds and /a/{naddr} pages worked because they hit ArticleRepository directly. Both the synchronous controller path (AuthorController::getAuthorArticles and getDraftsTabData) and the background RevalidateProfileCacheHandler (buildArticlesData, buildDraftsData) now query ArticleRepository::findByPubkey() directly, matching the editor sidebar. Postgres is the single source of truth for an author's own articles; ES is no longer in this code path.
  • [Bug] Fixed articles tab showing no articles when cache is invalidated while async revalidation jobs are pending/stuck in queue. The articles tab now always queries the database directly as the source of truth instead of relying solely on cached data. Fixed the overview tab's isEmptyCachedTabPayload check which used && for all sections — this meant a cached overview with some media/highlights but zero articles would serve the stale empty-articles version for up to 24h; changed to treat empty recentArticles as a cache miss regardless of other sections (OR logic). Both AuthorController and RedisViewStore are aligned. Added profile:flush-cache [npub|--all] console command to clear profile tab caches manually when needed.
  • [Feature] Comments now display on UnfoldBundle post pages. Comments (kind 1111) and zap receipts (kind 9735) are fetched from the local database and displayed below the article content with author metadata (name, picture) and timestamps. Zaps are highlighted and show the sats amount. See documentation/Unfold/comments.md.
  • [Bug] UnfoldBundle post pages no longer render an empty Comments () label for zap-only threads. The post context now exposes an explicit non-zap comments_count and a dedicated thread-activity flag, so zap-only activity renders as Comments (0) instead of a blank count.
  • [Bug] Fixed markdown conversion for quote boundaries in Nostr content. The converter now decodes fully JSON-escaped newline payloads (literal \n) and inserts an explicit paragraph break after single-line blockquotes when followed by plain text, preventing CommonMark lazy blockquote continuation from swallowing the next paragraph.
  • [Removal] The /landing route has been permanently removed along with its template.
  • [Improvement] Updated the magazine setup login prompt copy across all supported locales to emphasize personalization: "Sign in with your Nostr identity to personalize your experience."
  • [Feature] Article and follow-pack share dropdowns now include a "Copy Coordinate" option. For articles, the coordinate format is 30023:pubkey:slug; for follow packs, it's 39089:pubkey:d-tag. This allows technical users to reference events and coordinate addresses directly without relying on Nostr address encoding.
  • [Feature] Settings -> Profile now supports multiple NIP-05 identifiers. The profile editor offers repeatable NIP-05 inputs, publishes each value as its own nip05 tag, and stores metadata nip05 content as an array while preserving immediate User entity updates as a normalized comma-separated value.
  • [Bug] Fixed author profile rendering crash in templates/partial/_author-section.html.twig caused by unsupported Twig regex_replace filter usage when formatting the website label. The template now uses built-in Twig string operations (replace, starts with, slice, trim) to produce the same display text without requiring extra Twig extensions.

v0.0.37

Metrics.

  • [Bug] Relay filter statistics now correctly count multi-filter REQs (not just the first filter) in gateway mode. NostrRelayPool and NostrRequestExecutor now preserve all filters from generated REQ payloads, RelayGatewayClient sends them as a filter list, and RelayGatewayCommand rebuilds/sends all filter objects per relay. This fixes undercounting/missing admin rows for case-variant comment filters such as #A/#a and #E/#e, and preserves OR semantics for those queries when routed through the relay gateway.
  • [Bug] Comment fetching is now properly stale-while-revalidate. FetchCommentsHandler no longer stops at a Redis comments-payload cache hit: it still performs the background relay refresh so newly published comments are not hidden for up to the cache TTL. The Comments Live Component now normalizes Mercure-delivered profile metadata from JSON arrays back to objects before resolving display names, fixing threaded “replying to …” and parent-preview metadata after live updates. SocialEventService::getComments() now fans out across the local relay, the root author’s content relays, and the default relay pool instead of querying only one source, which improves comment discovery on threads whose replies live off the local relay.
  • [Improvement] Highlight relay fanout: SocialEventService::getHighlightsForArticle() now fans out to the article author's write relays (via UserRelayListService::getRelaysForAuthorContent()) in addition to the local relay and default relays, improving highlight discovery across the Nostr network. The cap is raised from 3 to 6 relays to accommodate the extra author relays.
  • [Improvement] Bulk prefetch of unresolved nostr embeds in article content. When an article page is served and its processed HTML contains deferred embed placeholders (nostr: references not yet in the local DB), ArticleController dispatches a PrefetchNostrEmbedsMessage to the async worker queue. The new PrefetchNostrEmbedsHandler deduplicates the requested IDs and coordinates against the DB, then batch-fetches missing events from relays in two calls (by event ID and by coordinate), projects each via GenericEventProjector (plus ArticleEventProjector for longform kinds).
  • [Improvement] Comment/thread ingestion for article and event pages is now much more targeted and thread-aware. Relay fetch now queries both NIP-22 tag-case variants (#A/#a for coordinates, #E/#e for event ids) so comments are not missed when clients use lowercase tags. FetchCommentsHandler now does a one-hop reply hydration pass by fetching comments that reference newly seen comment ids, which improves thread completeness without waiting for random background ingestion. EventRepository::findCommentsByCoordinate now anchors lookups to explicit root tags and uses a recursive thread query to include nested kind:1111 replies and related kind:9735 zaps tied to those comment ids.
  • [Bug] Fixed author profile pages showing an empty article list even after Mercure notification fires confirming new articles were loaded. Two root causes: (1) FetchAuthorContentHandler saved articles to the DB and published to Mercure but never invalidated the Redis profile-tab cache (view:profile:tab:{pubkey}:*); if RevalidateProfileCacheHandler had already rebuilt the cache before the relay fetch completed, the newly saved articles were invisible for up to 24h. Fixed by calling RedisViewStore::invalidateProfileTabs() after each content batch is processed. (2) author_profile_tabs_controller.js updateTabContent() only dispatched a dead author-articles:update DOM event that nothing listened to — so when Mercure fired for the active tab, the tab content was never refreshed. Fixed by reloading the Turbo Frame via a server fetch (debounced 500 ms). Also added a _pendingUpdateByType flag so that switching to a tab with a notification badge appends ?refresh=1 to the request URL, forcing the server to bypass stale-while-revalidate cache and return a fresh DB query.
  • [Bug] Fixed catastrophic timeout rate on kinds=[20,21,22,9802,10015,30023,34235,34236];authors=N1;limit=500 — the author-profile content fetch. Three root causes fixed: (1) FetchAuthorContentHandler was calling getRelaysForFetching(), which returns the viewer's follows-pool (union of write relays of everyone the viewer follows) — exploding the per-author fan-out. It now calls the new UserRelayListService::getRelaysForAuthorContent() which uses the author's write relays (NIP-65 outbox) capped at 20, since that's where the author actually publishes. (2) limit=500 was gratuitous; the profile UI shows ~20 items per type; reduced to FETCH_LIMIT = 100. (3) Kind 10015 (INTERESTS — a replaceable singleton) is now stripped from the REQ if the DB already has a copy fresher than the since window (default 6 h), skipping the per-relay index lookup for a kind that hasn't changed.
  • [Feature] Per-filter REQ statistics — answers "what filters do we typically send to relays, and which take long to resolve?". A new RelayFilterStatsStore (Redis-backed, hash relay_filter_stats:{normalizedRelay} + index set) records counts and resolution latency per filter signature — a privacy-preserving shape descriptor that captures kinds (explicit), authors/ids/tag-key counts (authors=N12, #e=N3), since/until presence flags, and exact limit. No author hex pubkeys, event ids, or tag values are ever stored. The relay gateway records recordRequest() when each REQ is registered, recordEose() (with REQ→EOSE latency and event count) on EOSE, and recordTimeout() on CLOSED / deadline expiry. Surfaces: CLI relay:filter-stats [--relay=URL] [--top=N] [--sort=count|avg|max|timeout] and admin page /admin/relay/filters (global + per-relay drill-down with colour-graded latency columns and red timeout cells). Operator use case: a relay friend can be told exactly which filter shapes you typically send their way and which exceed their EOSE budget. See documentation/Nostr/relay-filter-stats.md.
  • [Improvement] Latency tracking is now recorded for every relay the application talks to, not just the configured content relays. Previously avg_latency_ms was only populated by NostrRelayPool::sendToRelays() (the TweakedRequest path), so the admin Relay Health table showed last_event_received and last_success for gateway-served relays but their latency column stayed empty. The relay gateway now measures (a) the WebSocket connect/handshake time and feeds it to recordSuccess() on connection open, (b) the REQ→EOSE round-trip per pending query and feeds it on completeQuery(), and (c) the EVENT→OK round-trip per pending publish and feeds it on completePendingPublish(). The local-relay long-lived subscription loop in NostrRelayPool::subscribeLocal() and the per-relay publishDirect() path now also record their elapsed time. All measurements blend into the existing exponential moving average (alpha = 0.3), so the admin dashboard latency column populates for any relay the gateway has interacted with.
  • [Improvement] Relay administration tables now have a live filter bar. A URL search input and one-click filter buttons (by purpose / Configured / Discovered / Muted / Unhealthy) are available on both the Relay Health table (/admin/relay) and the All Known Relays table (/admin/relay/gateway). A running "N of M relays" counter updates as filters are applied. Implemented as a lightweight Stimulus controller (admin--relay-filter) with no server round-trips.
  • [Improvement] Relays that accumulate 20 consecutive failures are now automatically muted by RelayHealthStore. A warning is logged at the time of auto-mute. Relays can be unmuted manually at any time from the admin relay dashboard. The threshold is exposed as RelayHealthStore::AUTO_MUTE_THRESHOLD.
  • [Bug] Amber login deep link now correctly opens Amber's NIP-46 connection screen instead of its signing-requests screen ("nothing to approve yet"). The link now uses the nostrconnect:// URI scheme directly (which Amber registers for NIP-46 connection initiation) rather than wrapping it in nostrsigner: (which routes to Amber's NIP-55 pending-requests screen).
  • [Bug] Fixed memory exhaustion on the visitor analytics admin page. getBounceRate() previously fetched every session row into PHP memory to count bounces; it now uses a single native SQL subquery (COUNT(*) FILTER (WHERE cnt = 1)). getVisitsBySession() now caps results at 50 rows. getVisitCountByRoute() now caps at 100 rows.
  • [Improvement] Magazine setup no longer blocks anonymous users at the /blog/setup entry point. The route now always hands off to mag_wizard_setup (while still dispatching background author-article sync for authenticated users only). The previous journey login CTA card was preserved and reused in the right sidebar: anonymous users now see that login/start-creating card there, while logged-in users keep the sidebar “Start a magazine” button.
  • [Deprecation] Removed deprecated MagazineProjector usage from MagazineWizardController::publishIndexEvent(). Publishing now relies on the existing event persistence + graph ingestion path (EventIngestionListener) for immediate availability, and no longer performs synchronous/async legacy magazine projection fallback.
  • [Feature] Expression inputs can now use follow-pack coordinates (39089:pubkey:d-tag) as first-class sources. AddressSourceResolver now dispatches kind 39089 to a dedicated FollowPackSourceResolver, which expands p tags to pack-member pubkeys and returns latest longform (kind:30023) events from those authors (deduplicated by author+d-tag). SourceResolver now also delegates event-id inputs that resolve to kind 39089 containers. The expression builder now includes a new template: "Latest longform from follow pack".
  • [Improvement] The "Latest longform from follow pack" expression template now sorts by required event property created_at (op sort prop created_at desc) instead of optional published_at tag metadata, giving consistent ordering even when published_at is missing.
  • [Bug] Follow-pack expression semantics now match $contacts semantics. A follow-pack coordinate (39089:pubkey:d-tag) is no longer treated as a special source that fetches longform events; instead, when used in match/not pubkey clauses it expands to the pack's p-tag pubkeys (same value-expansion model as $contacts).
  • [Improvement] Clarified the expression builder follow-pack template to avoid old source-model ambiguity. In ExpressionController, template copy now explicitly describes follow packs as a pubkey author filter over an existing event source, and the builder placeholders now distinguish source-address inputs from follow-pack coordinate values used in match prop pubkey clauses. Updated documentation/Expressions/follow-pack-longform-expression.md accordingly.
  • [Improvement] The follow-pack author-filter expression template now ships with a default source input: the Decent Newsroom recent-articles spell (nevent1qqs9sv8skzupa9s9dfss273lkw05l3dwne4wve5x0xy048fxnjnwklqzyr28tnjt89m4qufs7sk8lp35dmundqq08tn56hk0szyjsrxury37jqcyqqqqxzgv05wdu).
  • [Improvement] Follow-pack listing now prioritizes packs authored by the logged-in user at the top of /follow-packs, while preserving the existing article-count sort within own/global groups.

v0.0.36

Relay management.

  • [Bug] async_profiles queue no longer floods with duplicate profile update messages. A new ProfileUpdateDispatcher service wraps all UpdateProfileProjectionMessage / BatchUpdateProfileProjectionMessage dispatches with a Redis-backed throttle (SET NX, 5-minute TTL per pubkey). Any source that already dispatched a profile update for a given pubkey within the throttle window is silently suppressed — the same pubkey can only have one outstanding update message at a time. All four prior dispatch sites (ProfileEventIngestionService, ArticleEventProjector, RedisCacheService, EditorController) and the ProfileRefreshWorkerCommand now route through the dispatcher.
  • [Deprecation] The /bookshelf route is deprecated. The route is retained for backward compatibility and now permanently redirects (301) to /newsstand. The bookshelf nav link has been removed from the navigation.
  • [Feature] Unified login modal. Replaced the dual Extension/Signer buttons scattered across five entry points (login page, user menu, blog-journey setup, editor panel, editor mobile) with a single "Sign In" button that opens a picker modal. The modal presents four clearly described options: Browser Extension (with a list of known NIP-07 extensions: Alby, nos2x, Nostore, Flamingo), Amber (Android remote signer with deep-link support), Primal (Primal wallet remote signer), and Remote Signer (generic bunker:// paste). All NIP-46 options share a lazy-initialized QR code flow; the extension option uses the existing NIP-07 flow. New utility--login-modal Stimulus controller, LoginModal.html.twig component, and login-modal.css.
  • [Bug] NIP-66 relay monitor data was never collected even after trusting a monitor pubkey. Trusting a monitor only wrote a row to trusted_relay_monitor but never fetched the monitor's 10166/30166 events from external relays — the subscription workers don't cover those kinds. Fixed by dispatching a FetchRelayMonitorEventsMessage (routed to async_low_priority) whenever a monitor is trusted via the admin UI, which fetches kinds 10166 + 30166 for the monitor's pubkey from configured content relays and projects them through GenericEventProjector. Also added relay:fetch-monitor-events <pubkey|--all> CLI command for backfilling existing trusted monitors.
  • [Bug] Relay gateway no longer evicts on-demand shared connections that are mid-flight on an active query. The previous evictIdlestOnDemandConnection (and its user-keyed sibling) picked targets purely by idle time, which during a fan-out query — where 30+ relays all opened within seconds and sent REQs that had not yet received EOSE — meant tearing down freshly-opened sockets one second after their REQ went out. Visible in logs as a cascade of evicting idlest on-demand connection (cap reached) lines with idle_seconds => 1 or 2, killing connections still serving the same correlation that triggered them. The eviction logic now (1) skips connections younger than 5 s ("newborn grace" — they haven't had time to receive even an EOSE), (2) skips connections that have at least one not-done entry in pendingQueries, and (3) only evicts among the survivors. If every shared slot is either newborn or busy, the gateway switches to a soft cap: a warning is logged (soft cap exceeded — no idle on-demand connection to evict) and the new connection is opened anyway, briefly exceeding --max-shared-conns for the duration of the active fan-out — far better failure mode than guaranteeing the affected relays return nothing for the in-flight correlation.
  • [Improvement] Default --max-shared-conns raised from 20 to 50 to better fit observed fan-out workloads — a typical query targeting a user's NIP-65 list plus content/profile relays routinely needs 25–40 sockets concurrently.
  • [Feature] User-visible relay activity log on Settings → Relays. A new RelayUserActivityStore (Redis Stream relay_user_activity:{pubkey}, capped at 200 entries via XADD MAXLEN ~, 7-day key TTL) records per-user NIP-42 AUTH outcomes and publish results emitted by the relay gateway. Wired into RelayGatewayCommand at four hook points: NIP-46 server-side AUTH success/failure, NIP-07 Mercure-roundtrip start (pending) and completion (ok / failed on timeout), and every OK frame in completePendingPublish for user-keyed connections. The Settings → Relays tab now renders the last 50 entries server-side as a static colour-coded table (ok / pending / failed) showing time, event type, relay URL, status, event-id snippet and any relay-side reason. Lets users see at a glance whether a publish actually reached a relay, why a relay rejected an event, and whether a fresh AUTH challenge was triggered (vs. silently reusing the existing user-keyed socket within --user-idle-timeout).
  • [Bug] Profile metadata refresh no longer floods wss://purplepag.es with one query per author during article hydration sweeps. UpdateProfileProjectionHandler::updateMetadataFacet now (1) debounces by User::lastMetadataRefresh — dispatches that arrive within 30 minutes of the last refresh return immediately without any DB or relay work, and (2) checks the local Event table for the latest kind:0 before falling back to the profile relay.
  • [Feature] NIP-11 Relay Information Document fetching. The application now periodically fetches NIP-11 documents from every configured relay via HTTP (Accept: application/nostr+json) and caches the results in a new relay_information PostgreSQL table and a relay_info:{url} Redis key. The cached auth_required flag is used as a gateway preflight: anonymous WebSocket opens to relays advertised as requiring AUTH are now skipped, eliminating the wasted handshakes that previously generated spurious failure counts in RelayHealthStore. supported_nips is also pushed into the health store for future routing decisions. Admin UI at /admin/relay/nip11 shows all known relays with software, NIP list, auth flag, last fetch time, and per-row async refresh button. Refreshed automatically every 6 hours via cron.
  • [Feature] NIP-66 Relay Discovery. Kinds 10166 (relay monitor announcements) and 30166 (relay liveness observations) are now projected into three new tables: relay_monitor, monitored_relay, trusted_relay_monitor. Only events from operator-trusted monitor pubkeys are projected into measurements. RTT data blends lightly into RelayHealthStore latency averages. New RelayDirectoryService provides ranked relay lookup by supported kind, NIP, or composite health/latency/monitor score. Admin UI: /admin/relay/directory (ranked NIP-66 directory with kind/NIP filter) and /admin/relay/monitors (trust/untrust monitor pubkeys). See documentation/relay-discovery.md.
  • [Bug] Relay gateway event loop no longer deadlocks waiting for traffic on idle WebSocket connections. The post-connect setTimeout(0) previously applied to every relay client translates to stream_set_timeout($stream, 0, 0) in phrity/websocket, which on a blocking socket makes fread() block forever rather than return immediately — so processWebSocketMessages() would park indefinitely on whichever connection happened to be quiet. Symptoms in the logs: 30–60 s gaps between iterations, queries with timeout: 8 only being swept after 25–55 s, EOSE handled but query complete arriving 40+ s later, periodic Gateway: tick logs going silent for minutes, and the SIGALRM watchdog firing after a full 9-minute hang. Replaced with a finite 10 ms receive timeout (new RECEIVE_TIMEOUT_SECONDS constant) so the receive sweep is bounded by #connections × 10 ms; the existing catch in processWebSocketMessages already treats ConnectionTimeoutException as "nothing to read".
  • [Bug] Fixed ws://strfry:7777 reconnect storm in the relay gateway: persistent connections were being marked "dead" within the same second they were opened and reconnected on every maintenance pass. Caused by the same infinite-setTimeout(0) issue — fresh post-handshake reads returned empty and the WS framer treated the connection as closed before any real frame arrived. Resolved by the finite receive timeout above.
  • [Bug] Relay gateway dashboard no longer reports "No heartbeat found" / "(no cursor)" while the process is healthy. The heartbeat and Redis stream cursor keys (relay_gateway:heartbeat, relay_gateway:cursor:requests, relay_gateway:cursor:control) are now refreshed at the start of every event-loop iteration (throttled to once every 5 s, TTL 30 s) instead of only inside performMaintenance() every 30 s. A long WebSocket sweep or pending TLS handshake can no longer make the gateway look dead. The admin "alive" threshold dropped from 120 s → 60 s, the self-restart heartbeat-stale threshold dropped from 300 s → 90 s, and the SIGALRM watchdog from 600 s → 120 s — so a real stall is detected and recovered before the dashboard goes red.
  • [Bug] Relay gateway now applies a 5 s connect/handshake timeout when opening relay WebSockets (was the phrity/websocket default of 60 s). A single dead or rate-limited public relay can no longer block the event loop for a full minute during inline opens — it fails fast, falls into the existing exponential-backoff cooldown, and the loop continues serving other relays.
  • [Improvement] Relay gateway emits a periodic structured Gateway: tick log line every 10 s with the current connection counts, queue depths, last stream IDs and heartbeat age, making log gaps unambiguous (silence ≡ stalled loop, not just an idle period).
  • [Bug] Relay gateway now self-restarts when the event loop becomes unresponsive. A pcntl_alarm-based watchdog resets on every loop iteration; if any sub-call (TCP handshake, Redis operation, WebSocket receive) blocks for more than 10 minutes, SIGALRM fires and the process exits so Docker's restart: unless-stopped can bring it back. performMaintenance also tracks the last successful heartbeat write and triggers a graceful exit if the heartbeat has been stale for more than 5 minutes (e.g. Redis went away). A Docker healthcheck was added to both compose.yaml and compose.prod.yaml that runs docker/relay-gateway-healthcheck.php (checks the relay_gateway:heartbeat Redis key age) every 60 s, making a frozen gateway visible via docker ps and docker inspect.
  • [Bug] Fixed mobile editor publish and save-draft buttons: removed debug alerts and replaced unreliable getControllerForElementAndIdentifier lookup with the same pattern used by the desktop header — clicking the hidden Nostr publish button — so both mobile actions now correctly trigger the signing and publishing flow.
  • [Feature] Magazine categories now support one additional level of nesting. When a category's a tags reference other kind-30040 index events (subcategories), the magazine front page and category page render each subcategory as its own article list, indented visually with a left border. No editor changes — display only.
  • [Feature] If a magazine's top-level a tags include a kind:30023 article alongside its categories, that article is rendered as the magazine's front page instead of the featured category list. The article's full content (title, byline, body, topics) appears in the magazine layout with the MagazineHero at the top. The category lists are shown below the article as a navigation aid. If the article cannot be resolved, the usual featured-list front page is shown as a fallback.
  • [Improvement] Relay gateway: persistent connections to public relays are now pre-warmed at startup, proactively recycled every 3 minutes before relay idle-timeouts drop them, and guarded by per-relay exponential backoff (10 s → 300 s) when a relay returns 503/520. Maintenance now runs every 30 s (was 60 s) with keepalive pings sent after 20 s of idle (was 45 s). Queries respect the cooldown window and fail gracefully for the affected relay rather than opening a new inline connection — preventing the connection-per-request hammering pattern visible in the logs.
  • [Bug] Deduplicated follow packs in the 'Add to follow pack' dropdown on user profile pages.
  • [Improvement] Enhanced admin roles management: updated RoleController.php to provide robust user role assignment and removal, including featured writers, muted users, and RSS managers. Added metadata enrichment for user listings, improved flash messaging, and ensured cache refresh for muted pubkeys. All actions are now more consistent and error-tolerant.
  • [Feature] Articles feed now filters out revisions: only articles where published_at equals created_at (or published_at is not set) are shown in all article feeds — latest feed, follows feed, interests feed, and the combined for-you/articles tab. This ensures edits to existing articles do not surface as new entries. Applied in ArticleRepository::findLatestArticles, findLatestByPubkeys, and findByTopics, as well as CacheLatestArticlesCommand.
  • [Feature] Article cards and detail pages now show an "Edited" note (muted, italic) when an article has been revised — i.e. when created_at differs from published_at. Cards display the original published_at date as the primary date; the edit date is shown inline. Translated into all six supported locales.
  • [Bug] Fixed remote-signer (NIP-46) login: the POST /api/nostr-connect/session call made immediately after a successful bunker login was returning 401 because the PHP session had not yet been fully committed to Redis at the time of the request. setRemoteSignerSession() in signer_manager.js now also sets a nip46_server_sync_pending localStorage flag. A new exported syncServerSessionIfPending() function reads the stored session from localStorage and re-posts the credentials to the server; it is called on every turbo:load via app.js so the registration is retried on the reloaded page where the user is definitely authenticated. Once the server returns 201 the flag is cleared (and also cleared on logout via clearRemoteSignerSession()). The pre-reload best-effort call in both amber_connect_controller.js and signer_modal_controller.js now clears the flag on success to skip the redundant post-reload retry. Also aligned the amber_connect_controller.js login fetch to include Accept: application/json and Content-Type: application/json headers, consistent with signer_modal_controller.js.

v0.0.35

Updates.

  • [Feature] Opening the Updates page now marks all updates as read (in addition to clearing the seen/badge state). UpdateRepository::markAllRead issues a bulk UPDATE setting read_at for all unread rows belonging to the user, so items no longer render with the unread highlight and the unread count resets to zero on page load.
  • [Feature] Article updates (kind 30023) from the same publication/author are now collapsed into a single entry on the Updates page — only the latest is shown. The controller fetches a wider raw window and discards older same-eventPubkey duplicates before passing the list to the template, keeping the display to at most one article update per author/publication.
  • [Feature] Replaced ephemeral relay AUTH with per-user auth always. The nostr--relay-auth Stimulus controller is now rendered on every page for logged-in users (no longer gated behind a one-time login flash), runs for the entire browser session (removed the 45-second polling timeout), and handles AUTH challenges from connections opened at any point during the session. The relay gateway's routeConnection and routeConnectionForPublish methods no longer fall back to a shared (ephemeral) connection when a user pubkey is known — if no user-keyed WebSocket exists it is opened inline, so every relay operation for an authenticated user is signed with their real identity. Shared/anonymous connections still use ephemeral AUTH for system-level traffic. The now-redundant relay_auth_listen flash variable and $gatewayEnabled argument in UserMetadataSyncListener were removed.
  • [Feature] Migrated relay AUTH challenge delivery from HTTP polling to Mercure SSE. The nostr--relay-auth controller now opens a persistent EventSource to the Mercure hub (topic /relay-auth/{pubkeyHex}) instead of polling /api/relay-auth/pending every 2 s. Challenges arrive as push events and are signed immediately. MercureSubscriberTokenService now includes the /relay-auth/{hex} topic alongside /users/{id}/updates in the subscriber JWT so the existing HttpOnly cookie grants access without extra requests. The dead /api/relay-auth/pending polling endpoint was removed.
  • [Feature] Server-side NIP-42 AUTH signing for remote-signer (NIP-46) users. After a successful bunker login, the browser POSTs the ephemeral NIP-46 session credentials (clientPrivkeyHex, bunkerPubkeyHex, bunkerRelays) to the new POST /api/nostr-connect/session endpoint. The server stores them encrypted (AES-256-GCM) in Redis with an 8-hour TTL via Nip46SessionService. When the relay gateway receives an AUTH challenge for a user connection, it now checks for a stored session first: if found, Nip46AuthSigner performs the NIP-46 sign_event RPC directly (kind:24133 to the signer relays), receives the signed kind:22242 event back from the bunker, and sends AUTH to the relay without touching the browser. NIP-07 extension users still go through the Mercure SSE roundtrip as before. Sessions are removed on logout via LogoutRelayCleanupListener.
  • [Feature] Vanity name registration is now free and instant. A FREE payment type has been added to VanityNamePaymentType (prior paid types SUBSCRIPTION/ONE_TIME are deprecated and no longer issued). The availability-check inline script has been extracted to a dedicated Stimulus controller (ui--vanity-name). The BASE_DOMAIN env var is now used for the NIP-05 server domain (falls back to SERVER_NAME). The .well-known/nostr.json endpoint now includes both ACTIVE and PENDING records so no issued name is ever missing from NIP-05 lookups.
  • [Bug] Fixed NIP-05 badge rendering/verification when metadata contains multiple identifiers in one value (comma-separated legacy format). Author profile sidebar now normalizes identifiers by splitting on commas, trimming, and deduplicating, then verifies each identifier independently and renders one badge pill per verified NIP-05.
  • [Bug] Fixed app:messenger:reset-streams --discard stalling consumers by setting Redis consumer groups to last-delivered-id=+ (effectively no future IDs). --discard now uses XGROUP SETID ... $, which skips the current backlog but continues consuming newly published messages.
  • Added Updates Pro paywall. Free users may have up to 5 active update subscriptions of type npub or publication. NIP-51 set subscriptions and unlimited subscriptions require a paid Updates Pro subscription. New entity UpdateProSubscription (migration Version20260423140000) mirrors the active_indexing_subscription lifecycle (pending → active → grace → expired). Payment follows the same BOLT11 / zap-receipt loop as Active Indexing: SubscriptionZapReceiptWorkerCommand now also checks Updates Pro pending invoices. New console command updates-pro:expire-subscriptions moves subscriptions through grace/expiry and revokes ROLE_UPDATES_PRO. UpdateAccessService is the single gate check for controllers. Landing page at /subscription/updates-pro. See documentation/Subscriptions/updates-pro.md.
  • Added an Updates Center. Users can subscribe to npubs, kind:30040 publication coordinates, and NIP-51 set coordinates (kinds 3, 10000, 10015, 30003, 30004, 30005, 30015, 39089) to receive updates for new long-form content only — deliberately scoped to kinds 30023 (longform) and 30040 (publication index); no chapters, comments, reactions, zaps, or highlights. Two new entities (UpdateSubscription, Update) land in migration Version20260423120000 with (user_id, event_id) unique dedup and indexed unread/created-at lookups. GenericEventProjector dispatches FanOutUpdateMessage on the async transport for in-scope kinds after persist; FanOutUpdateHandler short-circuits any other kind (defence in depth), resolves recipients via UpdateMatcher (NPUB exact match, PUBLICATION coordinate self-match + incoming a-tag match, NIP-51 set expansion to p/a/t buckets deduplicated to one subscription per user), and publishes a private Mercure update to /users/{id}/updates. Browser delivery uses a new MercureSubscriberTokenService HS256 JWT scoped to that single topic, delivered via an HttpOnly mercureAuthorization cookie set by MercureCookieSubscriber on authenticated HTML responses (no JS Authorization-header plumbing — EventSource doesn't support it anyway), refreshed when <1h TTL remains. The ui--updates-stream Stimulus controller opens an EventSource with withCredentials: true, hands each update to the existing window.showToast helper, and prepends new items to the /updates list. /updates page shows the persisted feed; /updates/subscriptions manages adds (accepts pasted npub1… / naddr1… / hex pubkey / raw kind:pubkey:d) and removes, CSRF-gated. JSON API: /api/updates/unread-count, /api/updates/{id}/read, /api/updates/mark-all-seen. See documentation/Updates/updates-center.md.
  • Added quick update Subscribe actions from profile and publication views. On profiles, subscribe is now a dedicated button in the author action row (separate from FollowPackDropdown) posting the viewed author pubkey. On publications, subscribe remains in the MagazineHero actions dropdown using the magazine naddr. Both flows include a local redirect_to so users return to the current page. UpdatesController::addSubscription accepts this optional redirect path for success and validation-error flows.
  • Added a magazine-style actions dropdown to the follow-pack page header (/follow-pack/{npub}/{dtag}) with copy-share actions for the canonical Newsroom URL and the pack naddr, plus a one-click updates Subscribe action (for logged-in non-owners) that posts to updates_subscriptions_add and returns to the current page via redirect_to.
  • [Bug] Fixed update deep-links for publication index events (kind:30040) by centralizing destination routing in EventController. Updates now link to the generic event route (/e/{nip19}); once /e/... resolves the event payload, nested publication references (30040:*) redirect to magazine pages (/mag/{slug}), nested article references (30023:* or 30024:*) redirect to reading-list pages (/p/{npub}/list/{slug}), and unclassifiable payloads stay on the generic event view.
  • [Bug] On single event pages, kind:1 notes now surface the NIP-10 root (e tag marked root) as an OP card above the current event. Resolution is DB-first with synchronous relay fallback and projects relay hits through GenericEventProjector before rendering.
  • Added articles:backfill-local console command to repair article coverage after DB cleanups or past projector bugs. The command opens a one-shot REQ to the local strfry relay for kind:30023 (configurable via --kinds, e.g. 30023,30040,30041), streams every stored event through ArticleEventProjector::projectArticleFromEvent — which already short-circuits on existing event_id so the run is idempotent and safe to repeat — and exits on EOSE (or after a configurable idle timeout when a relay never sends EOSE). Supports --since / --until (unix ts or strtotime expressions like -90 days), --limit, and --dry-run (counts what would be ingested without writing). Unlike the long-lived articles:subscribe-local-relay worker, which uses since = max(created_at) and therefore never re-fetches events older than the newest row in the DB, the backfill ignores the newest-row watermark so missing rows in the middle of the history are actually refilled. Implementation adds a new NostrRelayPool::fetchLocalUntilEose() method that mirrors the long-lived subscribeLocal() loop but terminates on EOSE / idle timeout and politely closes the subscription on exit.
  • [Docs] Consolidated documentation/Subscriptions/ into a single protocol specification at documentation/NIP/SB.md (NIP-SB: Relay-Enforced Scoped Entitlements). The NIP follows standard Nostr NIP conventions (setext header block, `draft` `optional` `relay` `client` status tags, Abstract / Motivation / Scope / Terminology / Event Kinds / Canonical Scope Reference / Scope Definition / Entitled Content / Entitlement Request / Grant / Revoke / Entitlement State Semantics / Scope Definition Revision Semantics / Payment Verification / Payment Evidence Reuse and Idempotency / Relay Behavior / Subscriber Export / Relationship to Other NIPs / Client Behavior / Security Considerations / Example Flow / Acknowledgements). The spec is intentionally narrow: one primitive — relay-enforced entitlement state per (subscriber_pubkey, scope_ref) tuple — built on a single-letter G tag (p:<hex> or a:<kind>:<hex>:<dtag>, the latter constrained to the parameterized-replaceable 30000-39999 range) so it indexes by default per NIP-01. The publisher-access layer, free-tier rate limiting, and whitelist/coupon machinery from earlier drafts were dropped — they belong to the relay's local policy, not to a portable Nostr protocol. Payment is creator-paid (zap recipient = scope owner or designated payment_pubkey), receipts are explicitly not single-use mint tokens (multiple relays MAY honor the same receipt; same-relay re-use of the same receipt for the same tuple is idempotent and MUST NOT extend duration); applicable scope-definition revision is bound to payment-evidence time (zap receipt created_at for paid, 8110.created_at for free); entitlement state is computed from 8102/8112 ordered by created_at then event id, with an explicit note that the tie-break is inverted relative to NIP-01's lower-id-wins rule for replaceable events; 8102.expiration is created_at + expires_in from the applicable revision (default 1 year); revocation is at the tuple granularity (older unexpired grants do NOT resurrect when a newer grant is revoked). External payment descriptors are referenced via a dedicated payment_descriptor tag rather than overloading the scope a tag with a marker. Subscriber exports use NIP-51 kind:30000 and SHOULD be encrypted into .content if the scope owner wants the list private. The prior Subscriptions folder documents now serve as supplementary material; NIP-SB is the single authoritative spec.
  • In the update subscriptions management page, npub subscriptions now render the author's display name/npub via the UserFromNpub component; publication subscriptions show the resolved magazine title (DB lookup by pubkey+slug, falling back to the d-tag identifier); NIP-51 set subscriptions show the d-tag identifier from the coordinate.
  • [Docs] Reviewed, aligned, and restructured documentation/Subscriptions/. Resolved the kind-number contradictions across the spec docs (publisher grant is canonically 18101, membership grant is canonically 8102 — fixed stray 18102/18111 references in rewire-relay-specification.md and free-tier-feature.md). Resolved the auth-model contradiction by making AUTH-required-for-all-reads the single normative policy in subscriptions.md §A2.1, with a note on how public preview discoverability is handled via DN Client proxying. Added missing normative sections to rewire-relay-specification.md: Tag Indexing Requirement (the multi-char scope tag is not indexed by stock NIP-01 relays and must be explicitly configured), Receipt Replay Protection (zap receipts are consumed once per receipt_event_id, permanent ledger), Bearer Whitelist Quota (bearer kind-8103 grants without a p tag must carry uses or expiration and the relay tracks redemptions per grant), Free-Tier Rate Limit (0-sat scopes get per-pubkey and per-scope rate limits), Scope Definition Version Conflicts (pending subscribe requests validate against the scope def in effect at the request's created_at), Relay Signer Key Management (storage, rotation, compromise procedure). Added a glossary distinguishing Author / Publisher / Scope owner / Subscriber / Issuer key in subscriptions.md §3a. Corrected the coupon evidence identity to be a whitelist-grant event id (not coordinate — the grant is regular, not replaceable). Dropped the undocumented write_kinds tag from the publisher-grant example. Renamed the DN_-prefixed SCREAMING_SNAKE event labels to plain human-readable names (Publish Grant, Publish Revoke, Scope Definition, Subscribe Request, Membership Grant, Membership Revoke, Whitelist Grant, Whitelist Revoke) across all docs — kind numbers remain the authoritative identifiers; the old labels were documentation ergonomics only. Consolidated the folder from seven files to six: deleted the kind-numbers (1).md download-artifact-named file, folded its revoke-pair table + replaceability notes into a new INDEX.md that marks subscriptions.md and rewire-relay-specification.md as the normative source of truth and lists the rest as reference.

v0.0.34

Graph traversal operators.

  • Rewrote articles:deduplicate to do its work in Postgres with a ROW_NUMBER() OVER (PARTITION BY pubkey, slug, kind ORDER BY created_at DESC NULLS LAST, id DESC) window function instead of hydrating every Article through the ORM and keeping an in-memory $seen map. Each iteration runs one bounded UPDATE article SET index_status = DO_NOT_INDEX WHERE id IN (SELECT id FROM (…) t WHERE rn > 1 LIMIT :batch) so the transaction / WAL / lock footprint stays small and the command is interrupt-safe and idempotent (already-flagged rows are skipped via index_status <> :doNotIndex, so repeated runs converge instead of re-touching the same rows). New options: --batch-size (default 1000), --sleep-ms (default 100, lets the DB breathe between batches), --dry-run (count affected rows and exit), --max-batches (cap work per invocation, 0 = unlimited). Constructor now takes Doctrine\DBAL\Connection instead of EntityManagerInterface. The command intentionally does NOT offer a raw-SQL delete mode: actual row removal must flow through the ORM via db:cleanup (which uses $em->remove() + flush()) so the FOS Elastica Doctrine listener fires on postRemove and evicts the stale document from the Elasticsearch articles index in lockstep — otherwise ES would keep serving older duplicates that no longer exist in the DB. Recommended cleanup flow for a bloated article table: bin/console articles:deduplicate --dry-runbin/console articles:deduplicate --batch-size=500 --sleep-ms=200bin/console db:cleanup.
  • Added events:replay-deletions console command to backfill NIP-09 handling over the existing corpus: walks every kind:5 deletion request already stored in the event table (oldest-first so newer deletions widen the suppression window instead of being overwritten, batches of 100, optional --pubkey / --since / --limit / --dry-run scopes) and routes each one through EventDeletionService::processDeletionRequest() so targets that were ingested before NIP-09 handling existed are now cascade-deleted and tombstoned. Idempotent — deleted_event.target_ref is unique and the cascade uses targeted, pubkey-scoped DELETEs.
  • Implemented NIP-09 event deletion requests. Incoming kind:5 events now cascade-delete the referenced targets from all local stores (the event table, article/highlight/magazine projections, older replaceable versions up to created_at for a-tag refs), after verifying that each target's pubkey matches the deletion request author (spec §Client Usage MUST). A new deleted_event tombstone table records every honored reference (by event id or kind:pubkey:d coordinate) indefinitely, and both ingestion paths — GenericEventProjector::projectEventFromNostrEvent (strfry subscription workers, RSS importer, ad-hoc fetches) and PersistGatewayEventsHandler::__invoke (relay gateway batch ingest) — consult DeletedEventRepository::isSuppressed() before persisting, so re-publishes of deleted content from other relays are dropped silently. Deletion-of-a-deletion-request is a no-op per NIP-09, and a-tag tombstones correctly leave future revisions of the same coordinate (those with created_at > deletion.created_at) unaffected. Added App\Service\EventDeletionService, App\Entity\DeletedEvent + migration Version20260422120000, docs at documentation/Nostr/nip-09-deletion.md, and a Gherkin spec tests/NIPs/NIP-09.feature.
  • [Bug] Fixed author profile pages showing no articles in the overview and articles tabs for many npubs in production even though the same articles appeared on Discover and opened correctly via direct links. Root cause: RedisViewStore::storeProfileTabData cached the articles/overview/media/highlights/drafts payload with a hard 24h TTL regardless of whether the payload was empty. When a visitor hit a profile before the Article projection had landed (e.g. right after a relay-worker ingestion, or on a newly discovered author seen only in Discover via the cron-built view:articles:latest key), the resulting empty payload poisoned the cache for up to a day; owners bypass this cache so they still saw their own articles via the editor sidebar DB path, which is why the bug only manifested to visitors. Four converging changes: (1) RedisViewStore::storeProfileTabData now inspects the tab's primary payload array (articles / recentArticles+recentMedia+recentHighlights+authorMagazines / mediaEvents / highlights / drafts) and stores empty results with a short 120 s TTL (PROFILE_EMPTY_TTL) instead of the 24h PROFILE_MAX_TTL, so a transient empty result self-heals on the very next request. (2) AuthorController::profileTab treats an already-cached-but-empty tab payload as a cache miss via a new isEmptyCachedTabPayload() helper, rebuilding synchronously and dispatching background revalidation — this heals existing poisoned Redis entries on the first hit without waiting for TTL expiry. (3) ArticleEventProjector::projectArticleFromEvent() now calls RedisViewStore::invalidateProfileTabs($article->getPubkey()) after a new article is persisted, so external ingestion (strfry subscription worker, relay gateway, coordinate fallback fetches, RSS importer path) immediately drops the stale profile-tab entries instead of waiting on RevalidateProfileCacheHandler — previously only in-app publishes from EditorController invalidated this cache. (4) RevalidateProfileCacheHandler now relies on the short-empty-TTL semantics in storeProfileTabData, so a background rebuild that lands before Article projection has caught up no longer re-poisons a cache we just healed. Net effect on prod: first visitor to an affected profile triggers a synchronous rebuild that populates the real article list, and subsequent ingestions for that author keep the cache honest.
  • Rich web-resource previews on NIP-22 comment pages, fetched only after explicit user consent. Kind:1111 events whose root/parent scope is an external identity (NIP-73 I/i tag with K/k = web, e.g. a Wikipedia URL) now render a placeholder card above the comment content showing the target host, the URL, and a "Load preview" CTA — the reader approves the third-party fetch before it happens. On click, a Stimulus controller (utility--web-preview) hits the new GET /api/web-preview?url=… endpoint, which delegates to App\Service\WebPreviewService: streams up to 256 KiB of the target page's <head> with a 3 s timeout, extracts og:title / og:description / og:image / og:site_name (falling back to Twitter Card, <title>, and <meta name=description>), resolves relative image URLs, and caches successful previews for 24 h / negative results for 15 min in the default Symfony app cache. The Molecules:WebPreview Twig component deliberately does not fetch on render, so comment pages never contact arbitrary third-party servers on the reader's behalf. _kind1111_comment.html.twig uses it for any parentI / rootI matching ^https?:// and degrades gracefully to a plain external link for other NIP-73 identity schemes (podcast:item:guid, isbn, …). Styles live in assets/styles/03-components/web-preview.css; translations webPreview.loadCta / webPreview.loadNotice added across all 6 locales.
  • [Bug] Fixed async spell evaluation failing with [Semantical Error] line 0, col 85 near 'jsonb_array_elements(e.tags)': Error: Class 'jsonb_array_elements' is not defined. Any spell whose filter included a Nostr tag constraint (#t, #p, #e, …) went through EventRepository::findByFilter(), which built the tag predicate as a DQL EXISTS (SELECT 1 FROM jsonb_array_elements(e.tags) …) — a PostgreSQL-only function that the DQL parser treats as an undefined function class. Rewrote findByFilter() as a native SQL query using the jsonb containment operator @> (e.tags @> '[[name,value]]'::jsonb), matching the pattern already used by findReferencingEvents() / findReferencingEventsBatch(); this also lets the query use the existing GIN(jsonb_path_ops) index on event.tags instead of sequentially scanning. Multiple values for the same tag name are OR'd (Nostr filter semantics), and results are hydrated via the repository's existing mapRowToEvent() helper.
  • [Perf] Cut async_profiles queue pressure by three converging changes. (1) UserDTOProvider::refreshUser and ::loadUserByIdentifier now skip dispatching UpdateProfileProjectionMessage when User.lastMetadataRefresh is within a 4h window (METADATA_REFRESH_WINDOW_SECONDS). Previously every authenticated request re-hydrated the session user via refreshUser() and unconditionally queued a profile update, producing 1:1 queue growth with logged-in request volume; now the refresh daemon carries steady-state freshness and user traffic only dispatches after real staleness. (2) RedisViewStore::PROFILE_STALE_TTL raised from 60s → 600s (10 min), collapsing the RevalidateProfileCacheMessage dispatch rate from AuthorController profile views by ~10×. (3) async_profiles transport max_retries lowered from 1 → 0 in config/packages/messenger.yaml, so transient relay timeouts (the dominant failure mode for kind:0 fetches) no longer re-enqueue with a 10s delay and amplify queue depth under relay flakiness — the profile refresh daemon will pick up anything truly missed on its next pass.
  • [Bug] Fixed spell links pointing at a bare event id instead of the canonical nevent on the /spells listing and the /api/spells picker. Bech32::nevent(...) was being called with a single positional array argument (['id' => ..., 'relays' => [], 'author' => ..., 'kind' => ...]), but the library's __callStatic forwards arguments to NEvent::toBytes(mixed ...$data) via call_user_func_array, which requires named arguments for the $data['id'] / $data['author'] / $data['kind'] lookups to resolve. As a result every spell link fell through the try/catch to the hex event id fallback, producing URLs like /spell/<hex> that never matched the spell_view route (^nevent1[a-z0-9]+$). Both Organisms:SpellList and SpellController::apiList now call Bech32::nevent(id: ..., relays: [], author: ..., kind: KindsEnum::SPELL->value), so the listing and the expression-builder spell picker emit proper nevent1… links that route correctly to the spell view page.
  • Live progress log streaming for async expression and spell evaluation. While the async_expressions worker evaluates an expression (kind:30880) or spell (kind:777), every PSR-3 log record emitted by the bundle pipeline (ExpressionService, ExpressionRunner, the SourceResolver family, FeedCacheService) is teed to the same Mercure topic the loading page subscribes to (/expression-eval/{cacheKey} or /spell-eval/{cacheKey}) as a {status:"log", level, message, context, ts} payload, alongside the existing terminal status:"ready"|"error" update. A new LoggerSwitch PSR-3 facade is bound as LoggerInterface for every App\ExpressionBundle\* service so the async handlers can push a TeeLogger(monolog, MercureProgressLogger(hub, topic)) for the duration of __invoke (popped in finally) without refactoring any pipeline constructor; sync call sites (FeedApiController, tests) are unaffected because the switch's default delegate is the Monolog logger. The loading Stimulus controller (content--expression-feed) handles status:"log" by appending a timestamped entry to a scrolling log panel on both templates/expressions/view_loading.html.twig and templates/spells/view_loading.html.twig (styles in assets/styles/03-components/expression-log.css, now imported from app.js; translations under expressionView.logPanelTitle / spellView.logPanelTitle in all 5 locales), capped at 500 DOM entries and auto-scrolled. debug-level records are filtered at the source by MercureProgressLogger to avoid flooding the hub during tight per-item loops. Mercure publish failures in both handlers are now escalated from warning to error with full exception class + message, since silent publish failure (e.g. MERCURE_JWT_SECRET drift between the worker and php services, or unreachable http://php/.well-known/mercure) was the prime suspect for "Mercure is not getting any updates." See documentation/Expressions/async-evaluation.md for the payload schema, wiring, and diagnostic checklist.
  • Broadened spell (NIP-A7 kind:777) evaluation fanout so results reflect the actual live state of the network, not just the narrow subset of events cached in the local strfry relay / event table. RuntimeContext now carries the viewer's NIP-65 read relays (populated by RuntimeContextFactory via UserRelayListService::getRelaysForFetching), and SpellSourceResolver::executeSpell ALWAYS queries relays — specifically the union of the spell's own relays tags (if any) and the viewer's declared read relays, falling back to RelaySetFactory::getDefault() when neither is available — then merges relay hits with the local DB lookup (deduplicated by event id, honoring the spell's limit over the merged set). The previous behavior was DB-first with relays only consulted as a fallback (or only the spell's explicit relays when present), which consistently starved spell feeds of fresh upstream content on well-indexed kinds the local relay doesn't mirror. Per-viewer Redis cache keying on (eventId, created_at, viewer pubkey, contacts+interests hash) is unchanged and remains correct since the fanout is already per-viewer by construction.
  • [Bug] Fixed NIP-GX ancestor root on a kind:1111 comment surfacing a kind:30040 magazine (that happens to include the commented article) as the thread root. The climb was hopping from the comment to the article via its a/A tag, then — because kind:30023 has no defined threading-based upward rule — falling through TraversalResolver::parents()'s default branch to inclusionParents(), which reverse-indexes any publication whose a-tag list contains the article's coordinate. AncestorOperation::climbToRoot() now treats any a/A hop as final: once the walk advances into an event whose kind is not traversable under this NIP (i.e. not kind:1, kind:1111, or kind:30040), it terminates and emits that node as the farthest ancestor. Nested kind:30040 inclusion chains continue to climb as before, since kind:30040 remains a traversable kind.
  • [Bug] Fixed NIP-GX ancestor root surfacing standalone kind:1 notes (with no a tag associating them with any article) as their own roots. The NIP-GX totality clause ("input with no declared parent/root is its own root") was firing unconditionally, so off-topic notes from contacts filled the top of ancestor root feeds intended to show article-thread origins. TraversalResolver::canBeSelfRoot() now gates the totality emission per kind — for kind:1 it requires at least one a tag; other supported kinds are unchanged. AncestorOperation consults it in the self-root fallback branch.
  • [Bug] Fixed longform articles in expression/spell eval results rendering nested inside the generic bookmark event-card wrapper, which produced a duplicated card chrome (event header + inner Molecules:Card). partial/_bookmark_event_card.html.twig now short-circuits for kinds 30023/30024 and renders only the inner card.
  • Added first-class support for NIP-A7 spells (kind 777). A new spells:subscribe-local-relay hydration worker (registered under app:run-relay-workers as the spells subprocess, disable with --without-spells) projects kind:777 events from the local strfry relay into the event table. A new /spells browser page renders all known spells via Organisms:SpellList, and /spell/{nevent} runs the spell against the signed-in viewer's runtime context and renders results through the shared partial/_bookmark_event_card.html.twig dispatcher — same visual treatment as the expression results page. Execution goes through ExpressionService::evaluateSpell(Cached)SpellSourceResolver::executeEvent(), with a per-user Redis cache keyed on (eventId, created_at, viewer pubkey, contacts+interests hash) and async evaluation on the async_expressions transport (new EvaluateSpellMessage / EvaluateSpellHandler, Mercure topic /spell-eval/{cacheKey}, loading page reuses content--expression-feed Stimulus controller). Spells are addressed by nevent (regular, non-replaceable events); anonymous access to /spell/{nevent} shows a login prompt because spells may reference $me / $contacts.
  • Added a click-to-use spell picker to the expression builder (/expressions/create, /expressions/edit/...). Filter, set, and traversal stages now surface a + Spell button next to + Input / + Clause. It opens a modal fed by GET /api/spells with a live filter over name / description / topic / author; selecting a spell appends an ["input","e",<event-id>] clause to the active stage, which the engine's existing e-input resolution path picks up transparently. Styles live in assets/styles/03-components/spell-picker.css (no shading, no rounded edges per project conventions).
  • [Bug] Fixed the kind:1111 single-event page showing only a loading/fetch placeholder for the article referenced by an A/a tag when that article wasn't yet in the local DB. Organisms:ArticleFromCoordinate gained an autoFetch prop: when set, the component performs a synchronous getEventByNaddr against the author's NIP-65 relays (falling back to configured defaults) on cache miss, projects the fetched event through GenericEventProjector + ArticleEventProjector, and re-queries — so the "Replying to" / "In thread" card on a kind:1111 event page now shows the actual article card instead of the placeholder when the article is reachable on relays. Left disabled (default) for feed-like views that render many coordinates.
  • [Bug] Fixed kind:1111 comments appearing with the comment author/date as the visual headline on feed pages (bookmarks, expression results). The generic bookmark-card wrapper (partial/_bookmark_event_card.html.twig) was promoting the comment's pubkey + createdAt into its card-header even when the body rendered the "X commented: … [article card]" inversion, so users saw a comment card with an article attached rather than an article card with a comment callout. Kind:1111 now short-circuits the wrapper entirely and renders only the callout + the referenced article/note, with a small View comment link underneath — making the referenced article the headline of the list item as intended.
  • [Bug] Fixed ancestor root on NIP-22 kind:1111 comments still leaking intermediate comments into results. Root cause: TraversalResolver consulted only the event table, but ArticleEventProjector — the canonical path for longform ingestion via the strfry subscription worker, the RSS importer, the article controller's sync fetch, etc. — writes exclusively to the article table. Articles that rendered fine as cards (because Organisms:ArticleFromCoordinate queries ArticleRepository) were therefore invisible to the resolver, so a kind:1111 comment whose a/A/e/E tag pointed at such an article resolved to null parents / null root hint, and the climber either dropped the comment entirely or — when the comment's e tag did resolve to another cached kind:1111 whose own upstream article was article-table-only — surfaced that intermediate kind:1111 as the "farthest ancestor." The resolver now falls back to ArticleRepository on event-table miss: lookupById() checks eventId, lookupByCoord() checks (pubkey, slug) for longform kinds, and the batched prefetchForParents() path issues a secondary findBy() over articles for any ids / coords the event-table batch didn't resolve. Hits are wrapped in transient Event entities built from the Article's stored raw payload (falling back to reconstructing tags from indexed fields on older rows without raw) so downstream parent / root / tag access works identically. Two additional defensive fixes were made alongside the repository split: (a) kind1111Parent() no longer early-returns on the first e tag when its lookup misses — it continues scanning, so a comment with redundant e + a pointers finds its parent via whichever tag actually resolves; (b) AncestorOperation::climbToRoot() funnels its terminal decision through a terminate() helper that rejects any final node still carrying declared-but-unresolvable upstream references, so an intermediate kind:1111 can never be emitted as a thread root (per NIP-GX's address-resolution rule). Combined: ancestor root over a feed of kind:1 + kind:1111 from contacts now surfaces the originating article / note and drops comments whose thread origin genuinely isn't reachable, instead of leaking comment intermediates as headlines.
  • [Bug] Fixed ancestor root on NIP-22 kind:1111 comments surfacing an intermediate comment as the "root" instead of the thread's true origin. Two root causes: (a) many NIP-22 clients set the uppercase E/A root-scope tag to the event the user replied to rather than to the thread's actual first event, and our fast-path trusted that declaration and stopped after a single hop; (b) for comments with only lowercase e/a (no uppercase root scope), the BFS walk would emit the farthest successfully-resolved node, which is often still a kind:1111 when deeper intermediates are missing from the local cache. AncestorOperation now uses a dedicated iterative climber that takes the first resolvable parent at each hop, and on a dead-end consults the root-tag hint to jump over gaps in the local DB and keeps climbing from the hint's target — so misconfigured root-scope chains unravel instead of terminating at the first misplaced E tag. The NIP-GX totality clause (item with no declared parent/root is its own root) still fires, and the address-resolution rule (declared-but-unresolvable yields no event) is preserved when no hop made progress. For the "kind:1 + kind:1111 by contacts → ancestor root" spell-into-expression feed this means original articles/notes surface at the top of results; comments only remain when the comment itself is the seed AND has no upstream thread.
  • [Bug] Fixed the expression builder Stimulus controller silently corrupting clause tags when the user changed a clause's type. The previous implementation copied tag slots positionally across type switches, but match/not are 4-slot [type, ns, sel, value] while cmp / text are 5-slot [type, ns, sel, comparator|mode, value]. Switching match → cmp therefore promoted the match VALUE into the cmp COMPARATOR slot (e.g. a value of 30023 ending up as the comparator), and the default || 'gte' fallback never fired because a truthy string occupied the slot. The resulting tag ["cmp","tag","kind","30023","7d"] was impossible to clean up via the UI since the comparator <select> would show its default on render but the underlying tag array still carried the stale value. updateClause now extracts logical fields (ns, sel, comparator, mode, value, inputType) from the source clause first, then reassembles the target clause with those fields in the correct target slots, using type-appropriate defaults for any slot the source doesn't provide. Preserves ns/sel/value across type changes (intended behavior) and stops leaking values into meaning-mismatched slots.
  • Batched DB lookups across NIP-GX traversal stages. TraversalResolver now holds a per-evaluation memoization cache (id:<hex> and a:<kind:pubkey:d> → Event) and exposes two batch prefetch methods. ParentOperation and AncestorOperation call prefetchForParents() once before iterating the input set — scanning all declared e/E/a/A refs and issuing exactly two batched SQL queries (EventRepository::findByIds() + a new findByCoordinates()) instead of one findById/findByNaddr per item. For ancestor the BFS also prefetches at each frontier transition, so a multi-hop walk runs one batched query per level rather than one per node. ChildOperation and the seed layer of DescendantOperation call prefetchForChildren(), which replaces N per-item findReferencingEvents() calls with one findReferencingEventsBatch() per reference shape (e-tag and a-tag for kind:1111; e-tag for kind:1; plus coordinate prefetch for kind:30040 index items). ExpressionRunner::run() resets the caches at the start of every evaluation so no lookups leak between runs. Typical expression with 500 kind:1111 comments → ancestor root goes from ~500 DB round-trips to ~2 for the traversal stage.
  • Rewrote EventRepository::findReferencingEvents() to use the existing GIN(jsonb_path_ops) index on event.tags via the @> containment operator (tags @> '[["e","<value>"]]'::jsonb) instead of jsonb_array_elements + tag->>0 / tag->>1, which could not use the GIN index and degraded to a seq scan on large event tables. Added findReferencingEventsBatch() for batched reverse-index lookups — each OR'd containment predicate uses the GIN index independently so the planner combines them via bitmap OR. Also added findByCoordinates() which resolves many kind:pubkey:d tuples to Event entities in a single OR'd query using the d_tag column index.
  • [Bug] Fixed NIP-GX ancestor root leaking the input event as its own root when the declared root reference couldn't be resolved from the local DB. For kind:1111 comments this meant an expression like "comments by my contacts, then ancestor root" would return the comments themselves at the top of the result list whenever the referenced article (A/E tag) wasn't cached locally — defeating the point of asking for roots. NIP-GX's "input is its own root" totality clause is intended for events that genuinely declare no parent/root; a declared-but-unresolvable reference is covered by the address-resolution rule ("a coordinate that resolves to no visible event contributes no result"). TraversalResolver::hasDeclaredRoot() now distinguishes the two cases (uppercase E/A or lowercase e/a for kind 1111; marked root/reply e tag for kind 1), and AncestorOperation only falls back to the input-as-root behavior when no parent/root is declared at all. Comments whose root article isn't in the local DB are now dropped from ancestor root output instead of surfacing as their own headline.
  • Tidied the NIP-22 comment UX on article and single-event pages. The top-level comment form is now hidden behind an explicit "Write a comment" CTA (native <details>, no JS) so the page isn't front-loaded with an empty textarea, and each rendered comment gets its own "Reply to @name" toggle that opens a scoped form pre-wired with the article/event as root and the target comment as parent (lowercase e tag, K 1111, p author). Organisms:Comments now accepts rootContext, replyPublishUrl, and replyCsrfToken props for this, and CommentForm takes a stable form_id per reply. Styles live in assets/styles/03-components/article.css under .comment-form-toggle / .comment-reply-toggle.
  • Simplified the expression results page by removing the longform-vs-other split: all result events now flow through partial/_bookmark_event_card.html.twig, which is the single kind-dispatching partial. Added a kind 30023/30024 case inside the bookmark partial that renders via Molecules:Card, so longform articles still display as proper cards instead of dumping full parsed content.
  • Richer single-event page chrome. Kind 1111 (NIP-22) comments now render the event they are replying to as a card above the comment content, and — when different — the thread root as a secondary card below, parsed from the event's e/a/i and E/A/I tags (with k kind badges). Addressable parents (a) resolve through Organisms:ArticleFromCoordinate; event-id parents (e) are encoded to note1… and rendered through Molecules:NostrEmbed, which already handles longform, picture, and generic event cards with a deferred-fetch placeholder fallback. In feed contexts (bookmarks and expression results, both rendered via partial/_bookmark_event_card.html.twig), the layout is inverted: the resolved root article (preferring uppercase A then E, falling back to lowercase a/e) is rendered as the primary card and the comment text is shown as a small "X commented:" callout above it, so a feed of comments reads as a feed of the articles being discussed instead of a stack of context-free reply blurbs. A new generic _event_meta.html.twig partial also surfaces commonly-ignored tags (alt, summary, published_at, client, t hashtags linking to forum_tag, p mentions) below the content for arbitrary event kinds that don't have dedicated chrome. Styles live in assets/styles/03-components/event-single.css.
  • Added NIP-22 comments UI to the generic event page (/e/{nevent}) so comments can be viewed and published on any event kind, not just longform articles. Addressable events (kinds 30000–39999 with a d tag) use A/a coordinate references as before; non-addressable events (kind 1 notes, kind 20 pictures, kind 21/22 videos, kind 1450 tabular data, kind 39089 follow packs, etc.) now use E/e event-id references with a P/p author tag per NIP-22. SocialEventService::getComments, FetchCommentsMessage, the Organisms:Comments live component and CommentController::publish all branch on coordinate-vs-event-id, and the event template gates the UI off for kinds that already have dedicated flows (30023/30024 articles, 30004–30006 curation sets, 42 chat).
  • Updated the expression loading slow-notice copy to "Searching. Sorting. Filtering. Evaluating. Loading..." for a clearer in-progress state on async expression pages.
  • [Bug] Fixed the expression results page only rendering longform article cards. Since expressions can return events of any kind, the view now splits results into a longform grid (kinds 30023/30024 with title + d-tag) rendered via Organisms:CardList, and a fallback list of other kinds rendered through the existing partial/_bookmark_event_card.html.twig dispatcher — so pictures (20), videos (21/22/34235/34236), tabular data (1450), interests (10015), follow packs (39089) and arbitrary events now display instead of being silently filtered out.
  • Added NIP-GX graph traversal operators (parent, child, ancestor, descendant) to the ExpressionBundle. Traversal stages extend kind:30880 expressions with kind-specific parent/child resolution for kind:1 (NIP-10 marked threading), kind:1111 (NIP-22 comments, lowercase e/a parents), and kind:30040 (NKBIP-01 publication indices; inclusion-based parents, a-tag declaration order for children). Modifiers ancestor root and descendant leaves are supported. Downward traversal is DB-only and best-effort, with per-item visited sets to terminate cycles silently and depth bounds to protect against pathological chains.
  • Exposed the new NIP-GX traversal ops in the expression builder UI (/expressions/create and /expressions/edit/...). The stage operation dropdown now has a "Traversal (NIP-GX)" optgroup, and ancestor/descendant stages expose a modifier selector for root / leaves. Traversal stages are serialized as ["op","<op>"] or ["op","<op>","<modifier>"], matching the runner's parser.
  • [Bug] Fixed NIP-GX ancestor / ancestor root traversal stopping short of the true root for kind:1 threads and kind:1111 comment trees whenever an intermediate event wasn't cached in the local DB. The runner now honors the authoritative root-scope tags — NIP-10 marked "root" e tag and NIP-22 uppercase E/A — via a TraversalResolver::rootHint() shortcut that jumps straight to the true root in one hop. For ancestor root, the hint is preferred over the iterative walk; for plain ancestor, the hint is appended at the farthest position if the walk didn't already reach it, so the chain always terminates at the declared root when one is set.
  • Broadened the ExpressionBundle's event-id input resolution to query a union of the local relay, the configured content relays, and the viewer's NIP-65 read relays (deduplicated, capped at 16) when an input event is not in the local database. The local and content relays remain the canonical paths — the viewer is intentionally not assumed to be the author of referenced events, since expressions and the spells they reference can be authored by anyone and evaluated by anyone — but the viewer's relays are included as an additional low-priority probe. The viewer's pubkey is passed to the relay fetch for NIP-42 AUTH against their own session only, not as an authorship claim.