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 memberCTA instead of the generic not-found page. - [Bug] Made
POST /api/fetch-chapteridempotent 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
Expressionspersonal 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_exclusivemarking 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 exclusiveflag 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.serviceUrlin bothdocker/strfry/strfry.confanddocker/strfry-essayist/strfry.confso NIP-42 AUTH uses the correct namespace. - [Bug] Fixed
strfry/strfry-essayiststartup 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
serviceUrltemplating flow tostrfry-essayistincompose.prod.yaml, soESSAYIST_RELAY_PUBLIC_URLis re-injected on every restart. - [Bug] Fixed Essayist protected-event relay rejects (
Protected event and no serviceUrl configured) by wiringstrfry-essayistserviceUrlfromESSAYIST_RELAY_PUBLIC_URLat container startup. - [Bug] Wired the default
strfryrelayserviceUrlfromRELAY_PUBLIC_URLat container startup so protected-event handling has a canonical relay URL. - [Bug] Fixed editor event signing to avoid duplicate NIP-70 protected tags (
["-"]) whenPublish ONLY to Essayistis 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:auditcurrent-record freshness checks against PostgreSQLstatement_timeoutcancellations: replaced offset-based scanning with keyset batching and added adaptive timeout fallback that recursively splits timed-out batches, so--fixcan 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
30040publication index events to their write relays via the newPOST /api/broadcast-publicationendpoint. - [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
/bookshelfusing signed kind30045directory events (d=my-book-collection), including add/remove actions on search and reader pages, a new/bookshelf/my-booksinventory route, and relay-backed persistence through/api/bookshelf/directory. - [Improvement] Restyle search bar in
/bookshelf, preserve query, update translations. - [Bug] Fixed
/bookshelfthumbnails to use theimagetag 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
/bookshelfwith remote kind30040metadata search, replaceable-event deduplication, batched kind30041chapter 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-contentinto a Newsroom-style searchable/reading-listinventory, leaving My Content focused on authored articles and drafts. - [Bug] Reading Nook now labels the default kind
10003bookmark list asBookmarks. - [Improvement] Reworked
/reading-nookfrom grouped collection cards into a Newsroom-style searchable inventory table with section tabs, compact metadata columns, and row action menus. - [Improvement] Restyled
/follow-packswith the shared flat article-card row layout, including curator metadata, pack counts, descriptions, and cover images. - [Improvement] Restyled the public
/listscuration 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.apparticle 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
30015dtags populate the Identifier field and are preserved when publishing replacements. - [Bug] Reading Nook topic lists now label untitled kind
10015lists asMy interests, and kind30015interest-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
ArticleFromCoordinatewrapper 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-contentsorting when Nostr event update dates are Unix timestamps instead ofDateTimeInterfaceobjects. - [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
isLoggedInwas not supplied by using Twig's authenticated user context directly. - [Improvement] Rebuilt
/my-contentas 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\NostrKernelBundlewith 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/currenttraffic 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 kind10003events from the last cached snapshot, work across repeated no-reload interactions, and persist failed publishes aspendingfor 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 thedata-relaysattribute, 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/publishidempotent 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.jsonnow caches the full catalog payload for 10 minutes, and/mag/{mag}/manifest.jsoncaches 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 synchronousReadingListNavigationService::findNavigation()on initial response, relying on the existingarticle-list-navTurbo 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 viafindOneBy(..., ['createdAt' => 'DESC'])instead of loading and sorting all revisions in PHP. - [Performance] Optimized admin analytics dashboard query count: consolidated
getArticlePublishStats()andgetZapInvoiceStats()from 5 queries each to single SQL queries using conditional aggregation; consolidatedgetBotVsHumanStats()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
Eventtag metadata getters (getTitle(),getSummary(),getSlug()) to ignore malformed/short tags instead of reading missing offsets, fixingUndefined array key 1warnings 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/analyticsload 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 ingetDailyUniqueVisitors()with a single aggregated SQL query. Main dashboard now runs ~6 fast, time-bounded queries. - [Performance] Discover page
/discoverRecent tab now loads lazily via a dedicated Turbo Frame endpoint (/discover/tab/recent) backed byfetchLatestArticles()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-contentdelete action wiring by aligning the Stimulus controller filename/identifier mapping withdata-controller="content--my-content-delete", so the NIP-09 delete button now initializes and handles clicks correctly. - [Improvement]
My BookmarksandMy Interestspages 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 ContentandMy Magazines) so/reading-nookfocuses on read-side collections only; authored management remains in the Newsroom workspace. - [Bug] Removed runtime dependency on
api.iconify.designduring icon cache warmup/asset compilation by switching UX Icons to localiconoirSVG assets and disabling remote Iconify fetches. - [Improvement] Restyled
/reading-nookinto 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 fromassets/typescript/nostr-utils.tsby the controllers that use them. - [Performance] Article pages now lazy-load related articles through a dedicated
article-related-frameTurbo Frame endpoint; the frame renders the existingRelatedArticlesTwig component and resolves fallback suggestions viaContentSearchService::findRelatedArticles(). - [Improvement] Temporarily hid the
Editorialtab/section on/discover. - [Improvement] Updated
/discovertabs: renamedArticlestoRecent, temporarily hid theActivitytab, and added aHighlightstab 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
/highlightsempty-page cache misses by aligning controller DB fallback with the sameHighlightsource used byapp:cache-latest-highlights(instead of querying kind9802fromevent), 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 lightweighthasPrev/hasMorenavigation, 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
Discovermenu item pointing to/discover(routediscover), and removedTopicsandHighlightsfrom the global menu. - [Refactor] Forum deprecation Phase 1: Migrated all direct
ArticleSearchInterfaceusages inForumController,HomeFeedController,ArticleSearchController,MagazineEditorController,DefaultController,RelatedArticlescomponent, andAuthorControllertoContentSearchService. Removed boilerplate deduplication/normalization helpers from controllers. Removed unused$articleSearchparameter from fourAuthorControllerprivate 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-levelArticleSearchInterfacefor 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-managerto the new Reading Nook/Newsroom layouts with local sidebar data fromNavigationBuilderTrait; added missing nav translation keys inmessages.{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:
SidebarNavreusable Twig component for consistent nav rendering across layoutsNavigationBuilderTraithelper providingbuildMainNav(),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 indexedparsed_reference.target_coordpath first (joining to source events) with a legacyevent.tags @>fallback only when graph references are unavailable. - [Bug] Fixed nested magazine category pages failing to load:
FeaturedListnow 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 byCategoryLinkTwig component DB lookups: replaced broadtags @>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.tagsslug scans with indexed(kind, d_tag)lookups (with legacy fallback) and by batching chapter coordinate resolution inDefaultController::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}/commentsand/p/{npub}/d/{slug}/highlightsframe 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_taglookup 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 kind10000(mute lists) from theuser_datastream, eliminated duplicate10002ingestion, removed non-standard kind777, reduced LMDB mmap from 10 GiB → 3 GiB, droppedmaxreaders256 → 64, tightenedqueryTimesliceBudgetMicrosecondsto 4 ms,maxFilterLimitto 200,maxSubsPerConnectionto 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.shinstead of curling/up, eliminating HTTP probe traffic while keepingservice_healthystartup ordering. - [Deprecated] Removed the legacy ChatBundle wiring from Symfony config (
services.yaml,security.yaml,messenger.yaml,doctrine.yaml,twig.yaml,config/packages/chat.yaml, andconfig/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.jsonto 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.jsonbuild path to use indexed coordinate resolvers (EventRepository::findByCoordinates,ArticleRepository::findByCoordinates) for category/chapter/article hydration, replacing per-item rawevent.tagsscans 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/subscriptionsonto the Reading Nook layout so subscription management stays in the read-side workspace. - [Bug] Added a Postgres healthcheck to
compose.yamlsoservice_healthydependencies no longer fail withnewsroom-database-1 has no healthcheck configuredduring startup. - [Bug] Hardened
events:replay-deletionsfor large backfills: the command now streams kind5request ids in configurable batches, resets Doctrine state between requests/chunks, andEventDeletionServiceflushes tombstones in small chunks while deduplicating repeated refs inside one deletion request, preventinguniq_deleted_event_target_reffailures. - [Improvement] Replaced deprecated
doctrine:query:sqlcommand references in project documentation withdbal:run-sqlto align with current DoctrineBundle guidance and reduce console deprecation noise. - [Bug]
POST /api/broadcast-articlenow 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-eventscommand 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-contentarticle rows that builds a kind5(NIP-09) delete request for the selected article (e+atags), asks the signer to sign it, and publishes it via the user-context event endpoint. - [Bug] Fixed
/e/naddr1...handling for long-form coordinates (kind30023): 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:auditorphan detection crashing on newer Doctrine DBAL versions by replacing removedConnection::PARAM_STR_ARRAYusages inGraphAuditCommandwithArrayParameterType::STRINGforIN (?)array bindings. - [Bug] Expression result cards now surface event
summarymetadata, oraltwhen 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-autoloaderflags to reduce build iterations, usecomposer dump-env prod --emptyto 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.yamlfor dev, external IP-based in production only), made production secrets required (:?VAR must be setpattern), 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.shthat 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
.dockerignorewith 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 includephp:80for 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 buildRuntimeContextonce 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::$typeaccess in feed API naddr decoding with payload type validation. - [Bug] Fixed the Essayist members page crashing on
/essayist/membersby correcting the claim-section Twig translation calls and adding the missingessayist.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 (kind9735), and a new recipient-confirmation fallback inspired by Payment Superchats: recipients can confirm pending claims directly on/essayist/members(optionally storing a kind9741attestation event id), which grants/extends the payer membership without requiring relay-wide receipt scraping. NewBolt11PaymentVerifierutility,EssayistZapClaimentity + repository,EssayistZapClaimServiceverification/attestation flow,EssayistClaimZapButtonLive 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 (
ZapButtonwithenableMembershipClaim=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.twigfor 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.mdfor 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.xmllisting static pages, published articles (up to 1 000), and magazines; linked from the site footer and referenced inrobots.txtfor crawler discovery - [Deprecated] Remove search credits system: deleted
src/Credits/namespace (CreditTransactionentity,CreditsManagerservice,RedisCreditStore),CreditTransactionController,GetCreditsComponent, admin transactions template,credits.cachepool, and deprecatedgetTransactionStats()fromAdminDashboardService - [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 withux_icon()calls using validiconoir:...namespaced icon syntax - [Bug] Fix pricing page horizontal overflow by adding
overflow-x: hiddenon the grid container andmin-width: 0; overflow: hiddenon cards - [Improvement] Rename "Total Visits" → "Page Views" and "Unique Visitors" → "Unique Sessions / Users" in admin analytics; add clarifying notes
- [Improvement] Add
wss://spatia-arcana.comto content relay list - [Improvement] Highlights feed now surfaces kind:1 text note references (e-tags): controller extracts
e/Etags and renders them viaNostrEmbed; deduplication key updated to cover all source types - [Bug] Fixed
mentionifyin profile/about rendering to also detect and linknostr:npub1...(and plainnpub1...) mentions, not only@npub1...tokens.
v0.0.44
- [Improvement] Added explicit NIP-11
iconmetadata 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-gatewaynow adds NIP-11 CORS headers (Access-Control-Allow-Origin/Headers/Methods), answersOPTIONS /preflight, and defaults missing or*/*Accepttoapplication/nostr+jsonwhen proxying metadata requests. - [Improvement] Essayist gateway now intercepts non-
30023clientEVENTwrites and responds withOK false+NOTICE(blocked: ...) without closing the websocket, so policy rejections do not look like relay/session failure. - [Improvement] Restored
docker/strfry-essayist/write-policy.shlongform-only gating (kind30023) and changed rejection messages to a policy-styleblocked:reason so non-matching writes are handled as expected relay policy instead of relay/internalerrorbehavior in clients. - [Improvement] Increased Essayist gateway AUTH wait time by configuring
AUTH_TIMEOUT_SECONDSto default to 30s in Compose (ESSAYIST_GATEWAY_AUTH_TIMEOUT_SECONDSoverride), reducing prematureAUTH timeoutdisconnects for slower signers. - [Improvement] Added explicit NIP-11 relay icon metadata (
icon) todocker/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.shto accept all event kinds from authenticated Essayist members; membership/auth remains enforced atessayist-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-targetsoften opening with an empty editor when the latest kind10133payment-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 existingpaytotargets. - [Bug] Fixed profile-page crashes on PostgreSQL caused by filtering JSONB
event.tagswithLIKEin follow-pack lookups. The editorial/follow-pack queries now fetch recent candidates and apply p-tag membership checks in PHP, avoidingoperator does not exist: jsonb ~~ unknown. - [Bug] Fixed PostgreSQL profile/editorial crashes caused by querying
magazine.contributors(JSON column) withLIKE. Contributor lookups now use JSONB containment (contributors::jsonb @> ...) on PostgreSQL, avoidingoperator does not exist: json ~~ unknownerrors. - [Bug] Fixed Essayist relay (and any future server-initiated WebSocket) clients timing out without ever receiving the NIP-42
AUTHchallenge: removedencode zstd gzipfrom therelay.localhostandessayist.localhostCaddy 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 asAUTH timeout, closingin the essayist-gateway logs because the gateway pushesAUTHimmediately on connect (strfry was unaffected since it is purely client-initiated). - [Improvement] essayist-gateway now logs the
AUTHchallenge only after the frame is written, surfaces write failures asoutcome=write_failed, and applies a 5s write deadline so a stalled socket cannot silently hide a delivery problem. - [Bug] Fixed
/essayist/memberstemplate crash (Unknown "m" test) by correcting the self-zap guard comparison intemplates/essayist/members.html.twigfrom 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
ptags. 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 withROLE_ESSAYIST_MEMBERorROLE_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
GETrequest tostrfry-essayistand return strfry's native response (instead of returning 404 for non-application/nostr+jsonbrowser requests). - [Bug] Fixed Essayist relay 404 errors in production:
compose.prod.yamlnow automatically enables the essayist profile (strfry-essayistandessayist-gateway) instead of requiring manual--profile essayistflag. - [Bug] Fixed Essayist relay routing when behind a reverse proxy: Caddyfile now matches on both
HostandX-Forwarded-Hostheaders 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, andREDIS_PASSWORDvariables as other services instead of requiring a separateESSAYIST_GATEWAY_REDIS_URL. - [Improvement] Made the Essayist home link visible in the user menu for
ROLE_ESSAYIST_MEMBERusers. - [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-deletionsfailing on PostgreSQL withSQLSTATE[42803]by removing theORDER BYclause from its internalCOUNT()query before batching kind5deletion 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.jsinline 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
npublookups from crashing on long ornostr:-prefixed identifiers by switchingNostrKeyUtil::npubToHex()to the long-inputnostriphant/nip-19decoder instead of the length-limitedswentel/nostr-phpBech32 converter. - [Bug] Fixed wiki events (kind
30817) not appearing in magazine category pages by queueing targeted asyncnaddrfetches 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
30040resolution in/e/*routes: publication index events now redirect to magazines when they reference nested30040indices, and redirect to reading lists when they reference longforms (30023/30024), chapters (30041), or wiki entries (30817). Reading list rendering now resolves30041and30817a-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.
ChatEventSignernow 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.ChatGroupAdminControllerhandles 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-eventaccepts 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
npubvalue (or is aChatUser).templates/base.html.twignow renders the relay-auth controller only when a usable npub exists,App\Twig\NostrExtensionnow treats null/empty inputs as empty strings, andApp\ChatBundle\Entity\ChatUser::eraseCredentials()is annotated to satisfy the Symfony 7.3 deprecation. - [Bug] Fixed private chat infrastructure in Docker Compose. The
strfry-chatrelay is now started by default again, its data volume is persisted, and its write-policy no longer depends on missingjqor 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
UnfoldBundledefault theme width inconsistency: the home (index.hbs) and category (category.hbs) templates now use the same800pxmax width as post pages instead of rendering extra wide.
v0.0.43
- [Improvement]
essayist-gatewaynow enriches NIP-11 JSON when upstreamstrfryomits optional fields: setsiconto{public-base-url}/favicon.icoand fillslimitation.auth_required=truepluslimitation.restricted_writes=trueby default. - [Bug] Fixed Quill view-font controls leaking outside the active editor instance by removing global
#editorfallback 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
/updateswithout 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 partialspartial/_activity_items.html.twigandpartial/_update_items.html.twigso Discover and the Updates page reuse them without duplication. Newhome_feed.tab.activity,home_feed.tab.updates,home_feed.activity.*, andhome_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.npubis null:UserFromNpub::mount()now accepts a nullable$identand returns early instead of crashing. - [Feature] Added editor role management to admin roles (
/admin/role): a new Editors section now lists users withROLE_EDITORand 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-tabshandles 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-phplibrary'sKeyclass requires strictly lowercase bech32 strings. Added defensive normalization tostrtolower(trim())at all key conversion points — inNostrKeyUtil, Twig filters, and all services/commands that callconvertToHex()orconvertPublicKeyToBech32(). 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:
ChatMessageServicewas missingChatWebPushServiceinjection, 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.twigwas 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
Activitytab to/essayist/homethat shows recent member activity from the current Essayist membership pool. A newEssayistMemberActivityServiceresolves currentROLE_ESSAYIST_MEMBERpubkeys, fetches recent events from local storage, and emits a mixed feed of highlights (kind9802), reposts (kind16), and comments (kind1111).EssayistController::homeFeedTab()now supportsactivity, 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
/highlightsfeed for/essayist/homeActivity highlights by extractingtemplates/partial/_highlight_feed_card.html.twigand rendering both pages through that shared partial. - [Improvement] Updated the
/e/{ident}event page to render kind9802highlights through the same shared highlight card partial (templates/partial/_highlight_feed_card.html.twig) used by/highlightsand Essayist Activity. - [Improvement] Essayist Activity highlight items now resolve
a/Aarticle references into preview cards (via generatednaddr+ parsed preview data), matching/highlightsfeed 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 includerecentCountandnewestCreatedAt, 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 withsince=now-6hinstead of all-time, so historical backfills no longer show up as fake "new" tab counts. - [Bug] Fixed the
TipButtonpayment 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 correspondinghttps://geyser.fund/project/{target}page directly. - [Improvement] Added a visible loading indicator to the
TipButtontrigger while the modal is fetching the latest payment targets event, so clicking the button no longer feels unresponsive. - [Improvement] Author profiles now rely on
TipButtononly: kind0zap targets (lud16/lud06) are merged into the tip payment list with deduplication against existing kind10133lightningtargets, and the separate profileZapButtonaction has been removed. - [Improvement]
TipButtonnow performs a targeted relay lookup for only kind10133when 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 kind10133(Payment Targets) directly after kind10002(Relay List), with create/manage actions that deep-link to the new page. - [Improvement] Added an admin-only debug preview to the
TipButtonmodal that shows the latest kind10133event 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
TipButtonLive Component crashing withrequires the "$index" argumentwhen 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 intests/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 — kind10133is now a first-class user-context event alongside relay lists, mute lists, and follows. A newApp\Service\Nostr\PaymentTargetServiceparses each kind 10133 event'spaytotags into typedPaymentTargetDTOs, normalizing thetypeto 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 newTwig\Components\Molecules\TipButtonLive Component renders a "Tip" button next to the existingZapButtonon 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 thelightningtype — producing a BOLT11 invoice + QR — or shows the rawpayto://type/authorityURI 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 alud16in their kind 0 but no kind 10133 yet, a syntheticlightningtarget 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 newnostr--nostr-settings-payment-targetsStimulus controller, and publishes the signed kind 10133 event through the existingapi_settings_event_publishendpoint (kind 10133 was added toKindBundles::USER_CONTEXTso 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), andSubscribeLocalUserContextCommand::SUBSCRIBE_KINDS(local-relay user-context worker). Documentation indocumentation/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, configuressensiolabs/typescript-bundleto use/usr/local/bin/swc, compilesasset-map:compileat image build time, and the runtime entrypoint reuses the bakedpublic/assets/manifest.jsoninstead 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.yamlsetsRELAY_GATEWAY_ENABLEDtotrueunless explicitly overridden and clears the inheritedgatewayprofile fromrelay-gateway, sodocker compose -f compose.yaml -f compose.prod.yaml --env-file .env.prod.local up -dstarts 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, andNostrClient::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-articleenforces 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-timehash_equals) for the template layer. NewarticleActions.protectedBadgeandarticleActions.protectedNotetranslations. - [Feature] Article cards now show an
Essayistsource badge whenever the underlying article carries theessayist_exclusiveflag. The badge is rendered directly insidetemplates/components/Molecules/Card.html.twig(next to the existingdiscussed/follows/interests/categorybadges) by readingarticle.essayistExclusivedefensively 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. Newhome_feed.source.essayist/home_feed.source.essayist_titletranslation keys added to all five locales (en, de, es, fr, sl) and a new.source-badge--essayiststyling rule inassets/styles/03-components/source-badge.csskeeps it visually distinct from the other badges. - [Feature] External clients publishing kind 30023/30024 articles directly to
strfry-essayistnow actually land in the local PostgreSQLarticletable. AddedSubscribeEssayistRelayCommand(essayist:subscribe-relay) — a long-lived worker that connects toESSAYIST_RELAY_INTERNAL_URL(defaults tows://strfry-essayist:7779), subscribes to kinds30023and30024, and pipes every incoming event throughArticleEventProjector.NostrRelayPool::subscribeLocal()gained an optional fifth argument?string $relayUrlOverrideso a single subscription loop can target any relay, not just the local default.RunRelayWorkersCommand(the worker-relay manager) auto-spawns the new worker wheneverESSAYIST_RELAY_INTERNAL_URLis set in the environment; it can be disabled with--without-essayist. When the env var is empty (theessayistcompose 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 theessayistprofile afterworker-relayis already running just works.compose.yamlnow passesESSAYIST_RELAY_INTERNAL_URLthrough to theworker-relayservice (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 newbool $markEssayistExclusive = falseargument; only theessayist:subscribe-relayworker passestrue, 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 tofalse.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 inEditorController::publishNostrEventkeeps its explicitsetEssayistExclusive(true)call, and the per-article admin action (EditorController::toggleEssayistExclusive) remains authoritative for manual flips. - [Feature] Added an
essayist_exclusiveboolean flag on thearticletable so individual articles can be marked as Essayist-exclusive at the database level. All public listing/search paths inArticleRepository(findLatestArticles,searchByQuery,findByTopics,findLatestByPubkeys,findByPubkey,advancedSearch+advancedSearchWithTags,findArticlesWithComments) now exclude flagged rows by default and accept an opt-inbool $includeEssayistExclusive = falseparameter 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 commandessayist:mark-exclusive <author> <slug> [--unmark](accepts npub, hex pubkey, or full30023:pubkey:slugcoordinate) flips the flag across every revision row viaArticleRepository::setEssayistExclusiveByCoordinate(). MigrationVersion20260523120000adds the column (defaultFALSE) plus a partial index on theTRUEsubset. The editor's "Publish ONLY to Essayist" toggle wires the flag fromEditorController::publishNostrEvent(after a server-sideROLE_ESSAYIST_MEMBER/ROLE_ESSAYIST_EARLY_BIRD/ROLE_ADMINcheck on the caller), and the per-article actions dropdown can flip it later viaEditorController::toggleEssayistExclusive. - [Feature] Added the Essayist members directory at
GET /essayist/members(EssayistController::members). Access is gated: visitors must be logged in and hold one ofROLE_ESSAYIST_MEMBER,ROLE_ESSAYIST_CANDIDATE, orROLE_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 holdingROLE_ESSAYIST_MEMBERwho exposes a Lightning address (User::$lud16or the Redis-cached metadatalud16) and renders the existingZapButtonLive Component per row, prefilled withrecipientPubkeyandrecipientLud16so contributors go through the standard NIP-57 modal (amount + comment → LNURL invoice → QR). New translations underessayist.members.*and new styles inassets/styles/04-pages/essayist.cssunder.essayist-members__*. The landing-page/essayistjoin 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_membershipledger table. NewEssayistMembershipentity +EssayistMembershipRepository. NewEssayistMembershipService::recordGrant()is the only writer — it rejects payments belowessayist.membership.minimum_sats(default1000), short-circuits on duplicate receipt ids, finds-or-creates the payer'sUserrow by npub, and computesexpires_atusing 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 ensuresROLE_ESSAYIST_MEMBERis present and pre-warms the gateway membership cache viaEssayistMembershipCacheService::markApproved(). A companionexpireLapsed()revokes the role from users whose latest row has expired (skippingROLE_ESSAYIST_EARLY_BIRDholders) and publishes a revocation viamarkRevoked(). - [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 thedescriptiontag, confirms the recipient (ptag) currently holdsROLE_ESSAYIST_MEMBER, extracts the amount from the receipt'samounttag (or the request'samounttag as fallback, both in millisats), and callsrecordGrant()— idempotent via the receipt-id unique constraint.essayist:expire-membershipsruns 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 overexpireLapsed(). Both are wired intodocker/cron/crontab. - [Feature] Added "Also publish to Essayist" and "Publish ONLY to Essayist" options to the article editor relays panel, visible to
ROLE_ESSAYIST_MEMBERand admins (admin preview). Two new non-mappedEditorTypecheckboxes are rendered insidetemplates/editor/panels/_relays.html.twigalongside the regular outbox relay list, with a newui--essayist-publish-optionsStimulus 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 holdsROLE_ESSAYIST_MEMBERorROLE_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 passesensureLocalRelay: falsetoNostrClient::publishEventso 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
/highlightslisting. Highlight events ingested through generic paths (relay gateway, follow/author content fetches, relay subscription workers) were only being written to theeventtable — only the dedicatedapp:fetch-highlightscron and the in-app publish endpoint populated thehighlighttable the article page queries. AddedApp\Service\HighlightProjectorand a hook inGenericEventProjector::projectEventFromNostrEventso every kind:9802 event is mirrored into thehighlighttable idempotently (by event id), with the article coordinate extracted froma/Atags. Coordinate extraction now accepts30023:,30024:,30040:, and30041:prefixes (previously only30023: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
Privateinstead of the genericUnknownlabel when the zapper pubkey is not available. - [Bug] Fixed "Broadcast to Essayist" reporting
1/2 relayseven though the user only picked Essayist.NostrClient::publishEventunconditionally augmented any non-empty relay list with the local strfry relay viaensureLocalRelayInList, so a single-target publish always ended up addressing two relays. Added anensureLocalRelayparameter (defaulttruefor backwards compatibility) andArticleBroadcastControllernow passesfalsebecause the caller already chose its targets explicitly. The Essayist broadcast now hits onlystrfry-essayistand the toast reads1/1. - [Bug] Fixed
strfry-essayistrejecting every incoming event withPlugin error: JSON object key "id" not found. strfry's plugin protocol requires each response line to echo the event'sid({"id":"<event-id>","action":"accept"}), butdocker/strfry-essayist/write-policy.shwas emitting{"action":"accept"}with no id, so strfry refused to ingest the event and surfaced a genericerror: internal errorto the client. The script now extracts the event id from the strfry input JSON using POSIX parameter expansion (nojq/sed/grepneeded) and includes it in every accept/reject response. - [Bug] Fixed
strfry-essayistrejecting every incoming event withjq: not foundfollowed byPlugin error: JSON object key "id" not found. Thedockurr/strfryAlpine image does not shipjq, so the write-policy script crashed on its first line. Rewrotedocker/strfry-essayist/write-policy.shas pure POSIX shell usingcasepattern matching against strfry's minified plugin JSON ("kind":30023is unambiguous becausekindis numeric in the plugin input). No new package install required. - [Bug] Fixed
strfry-essayistrejecting every incoming event withwrite policy blocked event … error: internal errorcaused byPermission deniedwhen 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 forstrfry-essayistnow copies the script to/usr/local/bin/essayist-write-policy.sh, chmods it+x, andstrfry.confpointswritePolicy.pluginat that path — independent of host file mode. - [Improvement] "Broadcast to Essayist" now sidesteps the NIP-42 AUTH gateway for logged-in
ROLE_ESSAYIST_MEMBERusers.ArticleBroadcastControllerrewrites any target relay URL that matchesessayist.relay_public_urltoessayist.relay_internal_url(defaultws://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-articlereturningsuccess: trueeven 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 withsuccess: falseand 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_MEMBERandROLE_ADMIN. The item re-publishes the stored signed event (unchangedid/sig) to the Essayist members-only relay by calling the existingPOST /api/broadcast-articleendpoint with an explicitrelayspayload containing only the Essayist WSS URL. The relay URL is configured via new parameteressayist.relay_public_url(envESSAYIST_RELAY_PUBLIC_URL, defaultwss://${ESSAYIST_RELAY_DOMAIN}) and exposed to Twig as the globalessayist_relay_url. Admins see an(admin)preview badge so they can verify the flow without holding the membership role. A dedicatedbroadcastEssayistStimulus action onui--article-actions-dropdownreuses 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::replaceBareTextNostrnow 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 thenostr: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/homenow uses Mercure SSE instead of polling.EssayistController::home()activates aStartRelayFeedMessagesubscription for the strfry-essayist relay (reusing the existing relay-feed infrastructure), derives the Mercure topic/relay-feed/{key}, and passes it to the template. Thecontent--essayist-latestStimulus controller opens anEventSourceon that topic, prepends new article cards as they arrive, and trims the list to 2 items. A newPOST /essayist/home/keepaliveendpoint 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 existingcontent--home-tabsStimulus 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). ExtendedEssayistFeedServicewithfetchByPubkeys(array $pubkeys)andfetchByTopics(array $hashtags)relay filter methods, and enhancedbuildCardto exposettag values as$card->topics. The aside features the configuredESSAYIST_WRITERSfollow 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 newAtoms:LatestEssayistArticlesTwig component and acontent--essayist-latestStimulus 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/homeinstead of the flat feed. Addedessayist.home.*translation keys in all six locale files. - [Feature] Implemented Essayist gated feed page (
GET /essayist/feed). AddedEssayistFeedServicethat connects directly tostrfry-essayist:7779via WebSocket, fetches kind:30023 articles until EOSE, and converts events to stdClass cards compatible withCardList/Card. The feed route performs manual role-checking: non-members/anons are redirected to the landing page withjoin_status=access_denied;ROLE_ADMINbypasses the membership gate and sees an admin-preview banner. Addedessayist.relay_internal_urlparameter (envESSAYIST_RELAY_INTERNAL_URL, defaultws://strfry-essayist:7779) and wiredEssayistFeedServiceinservices.yaml. Landing page "Visit front page" CTA now links to/essayist/feedfor members; admins get an "Admin preview" button. Addedessayist.feed.*translation keys across all six locale files. - [Bug] Fixed Essayist launch date comparison using server local time.
LAUNCH_DATEandEARLY_BIRD_DEADLINEare now full RFC 3339 datetime strings anchored to midnight UTC (2026-06-01T00:00:00+00:00/2026-05-31T00:00:00+00:00), andisLaunchedis evaluated against a UTCnow, 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, indocker/essayist-gateway/): NIP-42 challenge generation, kind:22242 verification with strict relay-URL normalisation and ±60screated_attolerance, two-tier membership lookup (Redis fast-path + PHP slow-path with circuit breaker), push-based revocation via Redis pub/sub onessayist_member_revokedthat forcibly closes live authenticated sockets, NIP-11 passthrough (whitelisted toGET /withAccept: application/nostr+json), per-IP and global connection caps, pre-auth frame buffer with size cap,/healthprobe (process + upstream TCP + Redis PING) and Prometheus/metrics. Two-stage Alpine Dockerfile with embeddedgo teststep. - [Improvement] Added
essayist-gatewaytocompose.yamlunder theessayistprofile; Caddy@essayistRelaynow routes toessayist-gateway:7780instead ofstrfry-essayist:7779. Removed the host-port mapping fromstrfry-essayist— the relay is reachable only from the gateway on the internal Docker network.ESSAYIST_POLICY_TOKENandREDIS_PASSWORDare now required (:?interpolation) instead of falling back to insecure defaults. - [Improvement] Reduced
docker/strfry-essayist/write-policy.shto a kind-only filter. Membership enforcement happens upstream at the gateway, so the relay no longer needscurl,jq -e, or theESSAYIST_POLICY_TOKENsecret to call back into the Symfony app. - [Feature] Added
App\Service\Essayist\EssayistMembershipCacheServicethat 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 intoEssayistAdminController::grantMember/revokeMember/approveCandidate, theclaimEarlyBirdflow onEssayistController, and theuser:elevateCLI command. Redis failures are logged but never block the underlying role change. - [Bug] Fixed unhandled
Invalid bech32 checksumcrash inArticleControllerwhen a stored pubkey or user identifier is malformed — allKey::convertToHex/Key::convertPublicKeyToBech32calls 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
UserFromNpubcomponent crash on the article disambiguation page — the component was passed:pubkeybut itsmount()method expects:ident. - [Bug] Fixed
admin/publication_subdomain/index.html.twigthrowing 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-5utility class applied to the summary paragraph inZineList,ReadingListList,FollowPackList, and the reading-list management page. when edited through the reading list wizard.ReadingListManager::loadPublishedListIntoDraft()now reads the event'stypetag and stores it in theread_wizard_typesession key, so the review step preserves the original type instead of defaulting toreading-list. - [Bug] Fixed my-content page routing all kind 30040 items to the reading list editor. Items with
rawType=magazinenow 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
authorfield on magazines and reading lists: anauthorinput is now available in the magazine setup wizard and the reading list setup wizard. When filled in, the value is stored as anauthortag on the published event. Wherever a byline is displayed (magazine hero, reading list detail page, reading list card component), the plain-textauthorvalue supersedes the publishing npub and is shown as unlinked text. If no author override is set, the existingUserFromNpubprofile link is shown as before. - [Feature] Reading list cover images: cover images stored in the
imagetag 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-listtag, 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 inMediaAttachmentTypeandZapSplitType; addeddefault_protocol: nullto allUrlTypefields inMediaAttachmentType,EditorType, andAdvancedMetadataType. - [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_newroute 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_newroute 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 sameactive/pendingstatus 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 asmagazineSlugto the template; the button links directly to/mag/{slug}. The "Edit magazine" bottom button now also links tomag_wizard_edit/{slug}. - [Bug] Fixed newly published magazines not appearing in the "My Magazines" list. Two root causes: (1) The
mag_wizard_launchedpage now proactively callsEventIngestionListener::processRawEvent()for the just-published magazine index and all new category events before the page renders, ensuringcurrent_recordis always up to date whenZineListqueries it — regardless of whether the silent non-fatalprocessEvent()call inapi-index-publishsucceeded. (2) Thenostr_index_sign_controller.jswas stripping ALLatags from the magazine event skeleton before rebuilding, losing existing-list category references — fixed to preserve anyatags that are not being re-signed in the current session. - [Feature] Designed
essayist-gateway: a standalone Go WebSocket proxy that sits between Caddy andstrfry-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 activeROLE_ESSAYIST_MEMBERvia a two-tier Redis cache / PHP API lookup before forwarding to the relay. Documented indocumentation/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.kindcolumn overflowing for Nostr event kinds above 32 767 (e.g. kind 34235). Column type changed fromSMALLINTtoINTEGER. -
[Bug] Fixed
ArticleInPublicationDoctrine mapping generating column namecontainer_dtaginstead of the actual DB columncontainer_d_tag. Added explicitname: '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) fromStaticControllerinto a dedicatedEssayistController. 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_AUTHORandROLE_ESSAYIST_SUPPORTERfromRolesEnumand all admin tooling.approveCandidate()now grantsROLE_ESSAYIST_MEMBERdirectly instead ofROLE_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_AUTHORinstead ofROLE_ESSAYIST_MEMBER. Early-bird members (and future pool contributors) could not publish to the relay despite holding the correct role. UpdatedEssayistWriterPolicyControllerand thewrite-policy.shrejection message to useROLE_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 usesgetSigner()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/eventendpoint 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_BIRDandROLE_ESSAYIST_MEMBERwith no payment required. Offer available until 31 May 2026. Translated into all six supported locales. -
[Bug] Fixed
published_at/created_atsemantics in the editor.published_atwas 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-shellbut the actual element uses.editor-layout, so variables were never applied. Also fixed the Quill toolbar having a hardcodedbackground: whiteinstead 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 theUserentity (populated viagetRelayListForDisplay()) contains the public project relay URL; when that list was passed directly toNostrClient::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 ingetRelaysForPublishing(). -
[Bug] Fixed
/subscription/vanityfailing for anonymous visitors. The Twig template used a non-existentloginroute in the signed-out CTA; it now points to the correctapp_loginroute. -
[Improvement] Removed the redundant writer-requirements intro sentence from the Essayist landing page and deleted the now-unused
essayist.landing.requirements.introtranslation key across all locales. -
[Improvement] Removed the redundant second sentence in the Essayist model section and deleted the now-unused
essayist.landing.model.paragraph2translation key across all locales. -
[Bug] Fixed stale users still seeing
ws://strfry:7777in the editor relays panel after the initial fix. ExistingUser.relaysvalues are now normalized on editor load viaUserRelayListService::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
nip05value set, aGet NIP-05button is shown next to Settings and links to vanity-name signup (vanity_index). -
[Feature] Added
/admin/essayistadministration 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 alud16Lightning address, and assignsROLE_ESSAYIST_CANDIDATEon 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
/essayistwith 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 inassets/styles/04-pages/essayist.css, wired them inassets/app.js, and documented the feature indocumentation/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. AddedUserRelayListService::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
UserRelayListServicecache+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_listwrite failing withSQLSTATE 23505duplicate-key error on Postgres sequence desync (common after DB restores or bulk imports). Replaced the ORM find+persist pattern inpersistToDatabasewith a nativeINSERT … ON CONFLICT (pubkey) DO UPDATE … WHERE created_at < EXCLUDED.created_atupsert 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 viaisProjectRelay()(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 (taggedt: nostr-relay) using the samenostr--nostr-single-signpattern as the feedback form. No custom controller needed. -
[Fix] Relay feeds no longer compete with
async_low_priorityworkers.StartRelayFeedMessageis now routed to a dedicatedasync_relay_feedstransport. Three parallel consumers run on this transport (viaapp: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 usingnpub(with short hex fallback when conversion is unavailable). -
[Bug] Fixed relay-feed and other
UserFromNpubbylines falling back to shortened npubs for authors whose local profile hadnamebut nodisplay_name.RedisCacheServicenow treats a non-fallbacknameas 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 withROLE_ESSAYIST_AUTHOR) can publish kind 30023 (published longform articles). All other pubkeys and kinds are rejected with a descriptive message. Activate withdocker 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 incomingEVENT, protected by a shared bearer token (ESSAYIST_POLICY_TOKEN). Returns{"approved": true/false}based on user role (ROLE_ESSAYIST_AUTHOR). -
[Feature] Added
ROLE_ESSAYIST_AUTHORandROLE_ESSAYIST_SUPPORTERtoRolesEnum. Writers are grantedROLE_ESSAYIST_AUTHORviauser:elevateto enable publishing on the Essayist relay. Readers are grantedROLE_ESSAYIST_SUPPORTERto 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.comas the public WebSocket endpoint for the Essayist relay (wss://essayist.decentnewsroom.com). Caddy routes this host directly tostrfry-essayist:7779via the new@essayistRelaymatcher infrankenphp/Caddyfile. Controlled byESSAYIST_RELAY_DOMAINenv var (defaults toessayist.localhostin 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 requiresROLE_ESSAYIST_SUPPORTER, earned by supporting an approved writer. RenamedROLE_ESSAYIST_READERtoROLE_ESSAYIST_SUPPORTERthroughout 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()beforecountUnreadForUser(), 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
ForumAsideTwig 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
asynctransport 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 newDispatchThrottleservice) so a popular article receiving many concurrent page views enqueues at most oneFetchCommentsMessageper minute instead of one per visitor. (2)RevalidateProfileCacheHandlernow applies a 5-minute Redis NX throttle before dispatchingFetchAuthorContentMessage, preventing a burst of profile-tab revalidations for the same author from fan出 into dozens of relay-fetch jobs onasync. (3)AuthorControllerarticles tab previously dispatchedRevalidateProfileCacheMessageunconditionally on every page view; it now checksviewStore->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 haspriority: 5while thearticle/d/…routes defaulted to priority 0. Both/article/d/{slug}and/article/d/{slug}/draftnow declarepriority: 10so 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. ThehasRealMathandnormalizeDollarMathInTextNodeshelpers are exported fromkatex_controller.jsand shared withlayout_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 theUser-Agentheader 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 withis_bot = true; empty / missing UAs are unconditionally flagged. All existing analytics queries (VisitRepository::applyTrackedVisitFilters) now appendAND is_bot = false, so visit counts, unique visitors, bounce rate, and time-series charts automatically reflect human traffic only. Thevisittable gained two new columns:user_agent VARCHAR(512)andis_bot BOOLEAN DEFAULT false(migrationVersion20260511120000). 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. Seedocumentation/Reader/bot-detection.md. - [Security] Hard-block scanner/attacker probe paths at the Caddy layer. A
path_regexpmatcher (@blockProbes) infrankenphp/Caddyfileintercepts common attack-surface probes (.env*,.git/*,phpinfo.php,wp-login.php,xmlrpc.php,adminer.php,composer.json/lock,.htaccess,/actuator/*, and more) and responds with404before 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
srcdirectly, bypassing themedia--image-loaderStimulus controller. Themedia-discovery.cssstylesheet (globally loaded) sets.masonry-image { opacity: 0 }and only reveals images via the.loadedclass added byimage-loaderon successful load. Dynamically-inserted items now usedata-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
titletag; NIP-51 set subscriptions now look up the matching event by coordinate and display itstitleornametag instead of the rawnaddr1…label string. The d-tag identifier is used as last resort in both cases. - [Improvement] Redistributed Messenger transport routing to drain the
asyncbacklog and prevent similar pile-ups.FanOutUpdateMessage(high-volume: one per subscriber per ingested event),FetchMediaEventsMessage(cron),FetchMissingCurationMediaMessage, andProjectMagazineMessageare moved fromasynctoasync_low_priority, whose dedicated consumer was previously idle. Three previously-unrouted messages are now explicitly routed:PrefetchNostrEmbedsMessageandFetchHighlightsMessage(both were running synchronously inside HTTP requests, slowing page loads) andPersistGatewayEventsMessage— all go toasync_low_priority. Theasynctransport is now reserved for strictly user-facing fetches: comments, author articles/content, and on-demand event lookups. Theasync_low_priorityconsumer 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://INTELorEA://NEWS. TheLatestArticlesExclusionPolicygained anEXCLUDED_TITLE_PREFIXESconstant and ahasBotTitlePrefixcheck 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
nip05field. Some users set their field to a bech32 Nostr string (e.g.nprofile1…,npub1…) instead of alocal@domain.tldidentifier. TheNip05Badgecomponent 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
Nip05Badgewas 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. PreviouslyNip05Badgewas 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) andONE_TIME(100,000 sats, lifetime) payment types are restored as the user-facing options;FREEis 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 viautility--payment-statusStimulus controller). Payment is confirmed automatically byvanity:check-receipts(now wired to cron every 5 minutes) matching zap receipts against pending BOLT11 invoices.vanity:process-expiredis also wired to cron (daily at 1 AM) to release lapsed subscription names. Theui--vanity-nameStimulus controller gained plan-selection card highlighting and apaymentRadiotarget. The NIP-05/.well-known/nostr.jsonendpoint now includes bothACTIVEandPENDINGrecords so a paid reservation is resolvable immediately during the payment confirmation window (via newVanityNameRepository::findActiveOrPendingByVanityNameandfindAllActiveOrPendingmethods). - [Feature] Articles now show a "Included in" sidebar widget linking to any magazine(s) that contain the article. A new
article_in_publicationindex table tracks which kind 30040 events directly reference each article coordinate.GenericEventProjectorkeeps the index up-to-date on every kind 30040 ingestion; a one-timeapp:rebuild-publication-indexcommand populates it from existing data. When an article belongs to a category inside a magazine, the sidebar showsMagazine › Categorywith 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
LatestArticlesExclusionPolicynow 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 ahello-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::getContentStatsnow usesCOUNT(*) 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) andredundant_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 atarticles:purge-revisions. - [Bug] Hardened the article-revision pipeline so older revisions never accumulate in Postgres or Elasticsearch. Four converging fixes: (1)
ArticleEventProjector::projectArticleFromEventnow 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-localagainst 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'spostRemovelifecycle and the FOS Elastica listener evicts the stale ES doc. AUniqueConstraintViolationExceptionfrom a concurrent writer is treated as already-ingested and yielded gracefully. (2)ReplaceableEventCleanupService::removeOlderArticleRevisionswas rewritten from a bulk DQLDELETE(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 firespostRemoveand the FOS Elastica listener drops the ES doc in lockstep. (3)EditorController::publishNostrEventnow callsremoveOlderArticleRevisionsafter 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 throughArticleEventProjector::projectArticleFromEventso it shares the same NIP-01 guard, ES-aware cleanup, and graph-table sync. AddedApp\Repository\ArticleRepository::findAllRevisionsByCoordinate()helper used by both the projector and the new purge command. Addedarticles:purge-revisionsconsole command — the proper hard-dedup tool that, unlikearticles:deduplicate(which only flags rows DO_NOT_INDEX), deletes redundant rows directly viaEntityManager::remove()so Postgres and ES converge in a single pass; supports--pubkey,--batch-size,--sleep-ms,--max-batches,--dry-run. Seedocumentation/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 toElasticsearchArticleSearchwhenELASTICSEARCH_ENABLED=true. ES indexing lags behind ingestion (and can drop documents on partial reindex), sofindByPubkeyreturned an empty hit set while the underlyingarticlerows existed in Postgres — feeds and/a/{naddr}pages worked because they hitArticleRepositorydirectly. Both the synchronous controller path (AuthorController::getAuthorArticlesandgetDraftsTabData) and the backgroundRevalidateProfileCacheHandler(buildArticlesData,buildDraftsData) now queryArticleRepository::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
isEmptyCachedTabPayloadcheck 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 emptyrecentArticlesas a cache miss regardless of other sections (OR logic). BothAuthorControllerandRedisViewStoreare aligned. Addedprofile: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-zapcomments_countand a dedicated thread-activity flag, so zap-only activity renders asComments (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
/landingroute 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's39089: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
nip05tag, and stores metadatanip05content 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.twigcaused by unsupported Twigregex_replacefilter 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.
NostrRelayPoolandNostrRequestExecutornow preserve all filters from generated REQ payloads,RelayGatewayClientsends them as a filter list, andRelayGatewayCommandrebuilds/sends all filter objects per relay. This fixes undercounting/missing admin rows for case-variant comment filters such as#A/#aand#E/#e, and preserves OR semantics for those queries when routed through the relay gateway. - [Bug] Comment fetching is now properly stale-while-revalidate.
FetchCommentsHandlerno 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. TheCommentsLive 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 (viaUserRelayListService::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),
ArticleControllerdispatches aPrefetchNostrEmbedsMessageto theasyncworker queue. The newPrefetchNostrEmbedsHandlerdeduplicates 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 viaGenericEventProjector(plusArticleEventProjectorfor 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/#afor coordinates,#E/#efor event ids) so comments are not missed when clients use lowercase tags.FetchCommentsHandlernow 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::findCommentsByCoordinatenow 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)
FetchAuthorContentHandlersaved articles to the DB and published to Mercure but never invalidated the Redis profile-tab cache (view:profile:tab:{pubkey}:*); ifRevalidateProfileCacheHandlerhad already rebuilt the cache before the relay fetch completed, the newly saved articles were invisible for up to 24h. Fixed by callingRedisViewStore::invalidateProfileTabs()after each content batch is processed. (2)author_profile_tabs_controller.jsupdateTabContent()only dispatched a deadauthor-articles:updateDOM 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_pendingUpdateByTypeflag so that switching to a tab with a notification badge appends?refresh=1to 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)FetchAuthorContentHandlerwas callinggetRelaysForFetching(), 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 newUserRelayListService::getRelaysForAuthorContent()which uses the author's write relays (NIP-65 outbox) capped at 20, since that's where the author actually publishes. (2)limit=500was gratuitous; the profile UI shows ~20 items per type; reduced toFETCH_LIMIT = 100. (3) Kind 10015 (INTERESTS — a replaceable singleton) is now stripped from the REQ if the DB already has a copy fresher than thesincewindow (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, hashrelay_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 exactlimit. No author hex pubkeys, event ids, or tag values are ever stored. The relay gateway recordsrecordRequest()when each REQ is registered,recordEose()(with REQ→EOSE latency and event count) on EOSE, andrecordTimeout()on CLOSED / deadline expiry. Surfaces: CLIrelay: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. Seedocumentation/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_mswas only populated byNostrRelayPool::sendToRelays()(the TweakedRequest path), so the admin Relay Health table showedlast_event_receivedandlast_successfor gateway-served relays but their latency column stayed empty. The relay gateway now measures (a) the WebSocket connect/handshake time and feeds it torecordSuccess()on connection open, (b) the REQ→EOSE round-trip per pending query and feeds it oncompleteQuery(), and (c) the EVENT→OK round-trip per pending publish and feeds it oncompletePendingPublish(). The local-relay long-lived subscription loop inNostrRelayPool::subscribeLocal()and the per-relaypublishDirect()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 asRelayHealthStore::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 innostrsigner:(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/setupentry point. The route now always hands off tomag_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
MagazineProjectorusage fromMagazineWizardController::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.AddressSourceResolvernow dispatches kind 39089 to a dedicatedFollowPackSourceResolver, which expandsptags to pack-member pubkeys and returns latest longform (kind:30023) events from those authors (deduplicated by author+d-tag).SourceResolvernow 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 optionalpublished_attag metadata, giving consistent ordering even whenpublished_atis missing. - [Bug] Follow-pack expression semantics now match
$contactssemantics. A follow-pack coordinate (39089:pubkey:d-tag) is no longer treated as a special source that fetches longform events; instead, when used inmatch/notpubkey clauses it expands to the pack'sp-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 inmatch prop pubkeyclauses. Updateddocumentation/Expressions/follow-pack-longform-expression.mdaccordingly. - [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_profilesqueue no longer floods with duplicate profile update messages. A newProfileUpdateDispatcherservice wraps allUpdateProfileProjectionMessage/BatchUpdateProfileProjectionMessagedispatches 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 theProfileRefreshWorkerCommandnow route through the dispatcher. - [Deprecation] The
/bookshelfroute 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-modalStimulus controller,LoginModal.html.twigcomponent, andlogin-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_monitorbut never fetched the monitor's 10166/30166 events from external relays — the subscription workers don't cover those kinds. Fixed by dispatching aFetchRelayMonitorEventsMessage(routed toasync_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 throughGenericEventProjector. Also addedrelay: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 ofevicting idlest on-demand connection (cap reached)lines withidle_seconds => 1or2, 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 inpendingQueries, 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-connsfor 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-connsraised 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 Streamrelay_user_activity:{pubkey}, capped at 200 entries viaXADD MAXLEN ~, 7-day key TTL) records per-user NIP-42 AUTH outcomes and publish results emitted by the relay gateway. Wired intoRelayGatewayCommandat four hook points: NIP-46 server-side AUTH success/failure, NIP-07 Mercure-roundtrip start (pending) and completion (ok/failedon timeout), and everyOKframe incompletePendingPublishfor 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.eswith one query per author during article hydration sweeps.UpdateProfileProjectionHandler::updateMetadataFacetnow (1) debounces byUser::lastMetadataRefresh— dispatches that arrive within 30 minutes of the last refresh return immediately without any DB or relay work, and (2) checks the localEventtable 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 newrelay_informationPostgreSQL table and arelay_info:{url}Redis key. The cachedauth_requiredflag 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 inRelayHealthStore.supported_nipsis also pushed into the health store for future routing decisions. Admin UI at/admin/relay/nip11shows 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 intoRelayHealthStorelatency averages. NewRelayDirectoryServiceprovides 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). Seedocumentation/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 tostream_set_timeout($stream, 0, 0)in phrity/websocket, which on a blocking socket makesfread()block forever rather than return immediately — soprocessWebSocketMessages()would park indefinitely on whichever connection happened to be quiet. Symptoms in the logs: 30–60 s gaps between iterations, queries withtimeout: 8only being swept after 25–55 s, EOSE handled butquery completearriving 40+ s later, periodicGateway: ticklogs going silent for minutes, and the SIGALRM watchdog firing after a full 9-minute hang. Replaced with a finite 10 ms receive timeout (newRECEIVE_TIMEOUT_SECONDSconstant) so the receive sweep is bounded by#connections × 10 ms; the existing catch inprocessWebSocketMessagesalready treatsConnectionTimeoutExceptionas "nothing to read". - [Bug] Fixed
ws://strfry:7777reconnect 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 insideperformMaintenance()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: ticklog 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,SIGALRMfires and the process exits so Docker'srestart: unless-stoppedcan bring it back.performMaintenancealso 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 Dockerhealthcheckwas added to bothcompose.yamlandcompose.prod.yamlthat runsdocker/relay-gateway-healthcheck.php(checks therelay_gateway:heartbeatRedis key age) every 60 s, making a frozen gateway visible viadocker psanddocker inspect. - [Bug] Fixed mobile editor publish and save-draft buttons: removed debug alerts and replaced unreliable
getControllerForElementAndIdentifierlookup 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
atags 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
atags 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.phpto 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_atequalscreated_at(orpublished_atis 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 inArticleRepository::findLatestArticles,findLatestByPubkeys, andfindByTopics, as well asCacheLatestArticlesCommand. - [Feature] Article cards and detail pages now show an "Edited" note (muted, italic) when an article has been revised — i.e. when
created_atdiffers frompublished_at. Cards display the originalpublished_atdate 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/sessioncall 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()insigner_manager.jsnow also sets anip46_server_sync_pendinglocalStorage flag. A new exportedsyncServerSessionIfPending()function reads the stored session from localStorage and re-posts the credentials to the server; it is called on everyturbo:loadviaapp.jsso 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 viaclearRemoteSignerSession()). The pre-reload best-effort call in bothamber_connect_controller.jsandsigner_modal_controller.jsnow clears the flag on success to skip the redundant post-reload retry. Also aligned theamber_connect_controller.jslogin fetch to includeAccept: application/jsonandContent-Type: application/jsonheaders, consistent withsigner_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::markAllReadissues a bulkUPDATEsettingread_atfor 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-
eventPubkeyduplicates 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-authStimulus 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'srouteConnectionandrouteConnectionForPublishmethods 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-redundantrelay_auth_listenflash variable and$gatewayEnabledargument inUserMetadataSyncListenerwere removed. - [Feature] Migrated relay AUTH challenge delivery from HTTP polling to Mercure SSE. The
nostr--relay-authcontroller now opens a persistentEventSourceto the Mercure hub (topic/relay-auth/{pubkeyHex}) instead of polling/api/relay-auth/pendingevery 2 s. Challenges arrive as push events and are signed immediately.MercureSubscriberTokenServicenow includes the/relay-auth/{hex}topic alongside/users/{id}/updatesin the subscriber JWT so the existing HttpOnly cookie grants access without extra requests. The dead/api/relay-auth/pendingpolling 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 newPOST /api/nostr-connect/sessionendpoint. The server stores them encrypted (AES-256-GCM) in Redis with an 8-hour TTL viaNip46SessionService. When the relay gateway receives an AUTH challenge for a user connection, it now checks for a stored session first: if found,Nip46AuthSignerperforms the NIP-46sign_eventRPC 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 viaLogoutRelayCleanupListener. - [Feature] Vanity name registration is now free and instant. A
FREEpayment type has been added toVanityNamePaymentType(prior paid typesSUBSCRIPTION/ONE_TIMEare deprecated and no longer issued). The availability-check inline script has been extracted to a dedicated Stimulus controller (ui--vanity-name). TheBASE_DOMAINenv var is now used for the NIP-05 server domain (falls back toSERVER_NAME). The.well-known/nostr.jsonendpoint now includes bothACTIVEandPENDINGrecords 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 --discardstalling consumers by setting Redis consumer groups tolast-delivered-id=+(effectively no future IDs).--discardnow usesXGROUP 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
npuborpublication. NIP-51 set subscriptions and unlimited subscriptions require a paid Updates Pro subscription. New entityUpdateProSubscription(migrationVersion20260423140000) mirrors theactive_indexing_subscriptionlifecycle (pending → active → grace → expired). Payment follows the same BOLT11 / zap-receipt loop as Active Indexing:SubscriptionZapReceiptWorkerCommandnow also checks Updates Pro pending invoices. New console commandupdates-pro:expire-subscriptionsmoves subscriptions through grace/expiry and revokesROLE_UPDATES_PRO.UpdateAccessServiceis the single gate check for controllers. Landing page at/subscription/updates-pro. Seedocumentation/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 migrationVersion20260423120000with(user_id, event_id)unique dedup and indexed unread/created-at lookups.GenericEventProjectordispatchesFanOutUpdateMessageon theasynctransport for in-scope kinds after persist;FanOutUpdateHandlershort-circuits any other kind (defence in depth), resolves recipients viaUpdateMatcher(NPUB exact match, PUBLICATION coordinate self-match + incominga-tag match, NIP-51 set expansion top/a/tbuckets deduplicated to one subscription per user), and publishes a private Mercure update to/users/{id}/updates. Browser delivery uses a newMercureSubscriberTokenServiceHS256 JWT scoped to that single topic, delivered via an HttpOnlymercureAuthorizationcookie set byMercureCookieSubscriberon authenticated HTML responses (no JS Authorization-header plumbing —EventSourcedoesn't support it anyway), refreshed when <1h TTL remains. Theui--updates-streamStimulus controller opens anEventSourcewithwithCredentials: true, hands each update to the existingwindow.showToasthelper, and prepends new items to the/updateslist./updatespage shows the persisted feed;/updates/subscriptionsmanages adds (accepts pastednpub1…/naddr1…/ hex pubkey / rawkind:pubkey:d) and removes, CSRF-gated. JSON API:/api/updates/unread-count,/api/updates/{id}/read,/api/updates/mark-all-seen. Seedocumentation/Updates/updates-center.md. - Added quick update
Subscribeactions from profile and publication views. On profiles, subscribe is now a dedicated button in the author action row (separate fromFollowPackDropdown) posting the viewed author pubkey. On publications, subscribe remains in theMagazineHeroactions dropdown using the magazinenaddr. Both flows include a localredirect_toso users return to the current page.UpdatesController::addSubscriptionaccepts 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 packnaddr, plus a one-click updatesSubscribeaction (for logged-in non-owners) that posts toupdates_subscriptions_addand returns to the current page viaredirect_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:*or30024:*) 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 (
etag markedroot) as an OP card above the current event. Resolution is DB-first with synchronous relay fallback and projects relay hits throughGenericEventProjectorbefore rendering. - Added
articles:backfill-localconsole 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 throughArticleEventProjector::projectArticleFromEvent— which already short-circuits on existingevent_idso 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-livedarticles:subscribe-local-relayworker, which usessince = 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 newNostrRelayPool::fetchLocalUntilEose()method that mirrors the long-livedsubscribeLocal()loop but terminates on EOSE / idle timeout and politely closes the subscription on exit. - [Docs] Consolidated
documentation/Subscriptions/into a single protocol specification atdocumentation/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-letterGtag (p:<hex>ora:<kind>:<hex>:<dtag>, the latter constrained to the parameterized-replaceable30000-39999range) 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 designatedpayment_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 receiptcreated_atfor paid,8110.created_atfor free); entitlement state is computed from8102/8112ordered bycreated_atthen 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.expirationiscreated_at + expires_infrom 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 dedicatedpayment_descriptortag rather than overloading the scopeatag with a marker. Subscriber exports use NIP-51kind:30000and SHOULD be encrypted into.contentif 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
UserFromNpubcomponent; 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 canonically18101, membership grant is canonically8102— fixed stray18102/18111references inrewire-relay-specification.mdandfree-tier-feature.md). Resolved the auth-model contradiction by making AUTH-required-for-all-reads the single normative policy insubscriptions.md §A2.1, with a note on how public preview discoverability is handled via DN Client proxying. Added missing normative sections torewire-relay-specification.md: Tag Indexing Requirement (the multi-charscopetag is not indexed by stock NIP-01 relays and must be explicitly configured), Receipt Replay Protection (zap receipts are consumed once perreceipt_event_id, permanent ledger), Bearer Whitelist Quota (bearer kind-8103 grants without aptag must carryusesorexpirationand 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'screated_at), Relay Signer Key Management (storage, rotation, compromise procedure). Added a glossary distinguishing Author / Publisher / Scope owner / Subscriber / Issuer key insubscriptions.md §3a. Corrected thecouponevidence identity to be a whitelist-grant event id (not coordinate — the grant is regular, not replaceable). Dropped the undocumentedwrite_kindstag from the publisher-grant example. Renamed theDN_-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 thekind-numbers (1).mddownload-artifact-named file, folded its revoke-pair table + replaceability notes into a newINDEX.mdthat markssubscriptions.mdandrewire-relay-specification.mdas the normative source of truth and lists the rest as reference.
v0.0.34
Graph traversal operators.
- Rewrote
articles:deduplicateto do its work in Postgres with aROW_NUMBER() OVER (PARTITION BY pubkey, slug, kind ORDER BY created_at DESC NULLS LAST, id DESC)window function instead of hydrating everyArticlethrough the ORM and keeping an in-memory$seenmap. Each iteration runs one boundedUPDATE 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 viaindex_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 takesDoctrine\DBAL\Connectioninstead ofEntityManagerInterface. The command intentionally does NOT offer a raw-SQL delete mode: actual row removal must flow through the ORM viadb:cleanup(which uses$em->remove()+flush()) so the FOS Elastica Doctrine listener fires onpostRemoveand evicts the stale document from the Elasticsearcharticlesindex 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-run→bin/console articles:deduplicate --batch-size=500 --sleep-ms=200→bin/console db:cleanup. - Added
events:replay-deletionsconsole command to backfill NIP-09 handling over the existing corpus: walks every kind:5 deletion request already stored in theeventtable (oldest-first so newer deletions widen the suppression window instead of being overwritten, batches of 100, optional--pubkey/--since/--limit/--dry-runscopes) and routes each one throughEventDeletionService::processDeletionRequest()so targets that were ingested before NIP-09 handling existed are now cascade-deleted and tombstoned. Idempotent —deleted_event.target_refis 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
eventtable,article/highlight/magazineprojections, older replaceable versions up tocreated_atfora-tag refs), after verifying that each target'spubkeymatches the deletion request author (spec §Client Usage MUST). A newdeleted_eventtombstone table records every honored reference (by event id orkind:pubkey:dcoordinate) indefinitely, and both ingestion paths —GenericEventProjector::projectEventFromNostrEvent(strfry subscription workers, RSS importer, ad-hoc fetches) andPersistGatewayEventsHandler::__invoke(relay gateway batch ingest) — consultDeletedEventRepository::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, anda-tag tombstones correctly leave future revisions of the same coordinate (those withcreated_at > deletion.created_at) unaffected. AddedApp\Service\EventDeletionService,App\Entity\DeletedEvent+ migrationVersion20260422120000, docs atdocumentation/Nostr/nip-09-deletion.md, and a Gherkin spectests/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::storeProfileTabDatacached 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 theArticleprojection had landed (e.g. right after a relay-worker ingestion, or on a newly discovered author seen only in Discover via the cron-builtview:articles:latestkey), 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::storeProfileTabDatanow 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 24hPROFILE_MAX_TTL, so a transient empty result self-heals on the very next request. (2)AuthorController::profileTabtreats an already-cached-but-empty tab payload as a cache miss via a newisEmptyCachedTabPayload()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 callsRedisViewStore::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 onRevalidateProfileCacheHandler— previously only in-app publishes fromEditorControllerinvalidated this cache. (4)RevalidateProfileCacheHandlernow relies on the short-empty-TTL semantics instoreProfileTabData, 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/itag withK/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 newGET /api/web-preview?url=…endpoint, which delegates toApp\Service\WebPreviewService: streams up to 256 KiB of the target page's<head>with a 3 s timeout, extractsog: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. TheMolecules:WebPreviewTwig component deliberately does not fetch on render, so comment pages never contact arbitrary third-party servers on the reader's behalf._kind1111_comment.html.twiguses it for anyparentI/rootImatching^https?://and degrades gracefully to a plain external link for other NIP-73 identity schemes (podcast:item:guid,isbn, …). Styles live inassets/styles/03-components/web-preview.css; translationswebPreview.loadCta/webPreview.loadNoticeadded 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 throughEventRepository::findByFilter(), which built the tag predicate as a DQLEXISTS (SELECT 1 FROM jsonb_array_elements(e.tags) …)— a PostgreSQL-only function that the DQL parser treats as an undefined function class. RewrotefindByFilter()as a native SQL query using the jsonb containment operator@>(e.tags @> '[[name,value]]'::jsonb), matching the pattern already used byfindReferencingEvents()/findReferencingEventsBatch(); this also lets the query use the existingGIN(jsonb_path_ops)index onevent.tagsinstead of sequentially scanning. Multiple values for the same tag name are OR'd (Nostr filter semantics), and results are hydrated via the repository's existingmapRowToEvent()helper. - [Perf] Cut
async_profilesqueue pressure by three converging changes. (1)UserDTOProvider::refreshUserand::loadUserByIdentifiernow skip dispatchingUpdateProfileProjectionMessagewhenUser.lastMetadataRefreshis within a 4h window (METADATA_REFRESH_WINDOW_SECONDS). Previously every authenticated request re-hydrated the session user viarefreshUser()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_TTLraised from 60s → 600s (10 min), collapsing theRevalidateProfileCacheMessagedispatch rate fromAuthorControllerprofile views by ~10×. (3)async_profilestransportmax_retrieslowered from 1 → 0 inconfig/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
neventon the/spellslisting and the/api/spellspicker.Bech32::nevent(...)was being called with a single positional array argument (['id' => ..., 'relays' => [], 'author' => ..., 'kind' => ...]), but the library's__callStaticforwards arguments toNEvent::toBytes(mixed ...$data)viacall_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 thetry/catchto the hex event id fallback, producing URLs like/spell/<hex>that never matched thespell_viewroute (^nevent1[a-z0-9]+$). BothOrganisms:SpellListandSpellController::apiListnow callBech32::nevent(id: ..., relays: [], author: ..., kind: KindsEnum::SPELL->value), so the listing and the expression-builder spell picker emit propernevent1…links that route correctly to the spell view page. - Live progress log streaming for async expression and spell evaluation. While the
async_expressionsworker evaluates an expression (kind:30880) or spell (kind:777), every PSR-3 log record emitted by the bundle pipeline (ExpressionService,ExpressionRunner, theSourceResolverfamily,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 terminalstatus:"ready"|"error"update. A newLoggerSwitchPSR-3 facade is bound asLoggerInterfacefor everyApp\ExpressionBundle\*service so the async handlers can push aTeeLogger(monolog, MercureProgressLogger(hub, topic))for the duration of__invoke(popped infinally) 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) handlesstatus:"log"by appending a timestamped entry to a scrolling log panel on bothtemplates/expressions/view_loading.html.twigandtemplates/spells/view_loading.html.twig(styles inassets/styles/03-components/expression-log.css, now imported fromapp.js; translations underexpressionView.logPanelTitle/spellView.logPanelTitlein all 5 locales), capped at 500 DOM entries and auto-scrolled.debug-level records are filtered at the source byMercureProgressLoggerto avoid flooding the hub during tight per-item loops. Mercure publish failures in both handlers are now escalated fromwarningtoerrorwith full exception class + message, since silent publish failure (e.g.MERCURE_JWT_SECRETdrift between theworkerandphpservices, or unreachablehttp://php/.well-known/mercure) was the prime suspect for "Mercure is not getting any updates." Seedocumentation/Expressions/async-evaluation.mdfor 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 /
eventtable.RuntimeContextnow carries the viewer's NIP-65 read relays (populated byRuntimeContextFactoryviaUserRelayListService::getRelaysForFetching), andSpellSourceResolver::executeSpellALWAYS queries relays — specifically the union of the spell's ownrelaystags (if any) and the viewer's declared read relays, falling back toRelaySetFactory::getDefault()when neither is available — then merges relay hits with the local DB lookup (deduplicated by event id, honoring the spell'slimitover the merged set). The previous behavior was DB-first with relays only consulted as a fallback (or only the spell's explicitrelayswhen 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 rooton akind:1111comment surfacing akind:30040magazine (that happens to include the commented article) as the thread root. The climb was hopping from the comment to the article via itsa/Atag, then — becausekind:30023has no defined threading-based upward rule — falling throughTraversalResolver::parents()'s default branch toinclusionParents(), which reverse-indexes any publication whosea-tag list contains the article's coordinate.AncestorOperation::climbToRoot()now treats anya/Ahop as final: once the walk advances into an event whose kind is not traversable under this NIP (i.e. notkind:1,kind:1111, orkind:30040), it terminates and emits that node as the farthest ancestor. Nestedkind:30040inclusion chains continue to climb as before, sincekind:30040remains a traversable kind. - [Bug] Fixed NIP-GX
ancestor rootsurfacing standalonekind:1notes (with noatag 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 ofancestor rootfeeds intended to show article-thread origins.TraversalResolver::canBeSelfRoot()now gates the totality emission per kind — forkind:1it requires at least oneatag; other supported kinds are unchanged.AncestorOperationconsults 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.twignow 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-relayhydration worker (registered underapp:run-relay-workersas thespellssubprocess, disable with--without-spells) projects kind:777 events from the local strfry relay into theeventtable. A new/spellsbrowser page renders all known spells viaOrganisms:SpellList, and/spell/{nevent}runs the spell against the signed-in viewer's runtime context and renders results through the sharedpartial/_bookmark_event_card.html.twigdispatcher — same visual treatment as the expression results page. Execution goes throughExpressionService::evaluateSpell(Cached)→SpellSourceResolver::executeEvent(), with a per-user Redis cache keyed on(eventId, created_at, viewer pubkey, contacts+interests hash)and async evaluation on theasync_expressionstransport (newEvaluateSpellMessage/EvaluateSpellHandler, Mercure topic/spell-eval/{cacheKey}, loading page reusescontent--expression-feedStimulus controller). Spells are addressed bynevent(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+ Spellbutton next to+ Input/+ Clause. It opens a modal fed byGET /api/spellswith 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 existinge-input resolution path picks up transparently. Styles live inassets/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/atag when that article wasn't yet in the local DB.Organisms:ArticleFromCoordinategained anautoFetchprop: when set, the component performs a synchronousgetEventByNaddragainst the author's NIP-65 relays (falling back to configured defaults) on cache miss, projects the fetched event throughGenericEventProjector+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'spubkey+createdAtinto itscard-headereven 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 smallView commentlink underneath — making the referenced article the headline of the list item as intended. - [Bug] Fixed
ancestor rooton NIP-22kind:1111comments still leaking intermediate comments into results. Root cause:TraversalResolverconsulted only theeventtable, butArticleEventProjector— the canonical path for longform ingestion via the strfry subscription worker, the RSS importer, the article controller's sync fetch, etc. — writes exclusively to thearticletable. Articles that rendered fine as cards (becauseOrganisms:ArticleFromCoordinatequeriesArticleRepository) were therefore invisible to the resolver, so a kind:1111 comment whosea/A/e/Etag pointed at such an article resolved to null parents / null root hint, and the climber either dropped the comment entirely or — when the comment'setag 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 toArticleRepositoryon event-table miss:lookupById()checkseventId,lookupByCoord()checks(pubkey, slug)for longform kinds, and the batchedprefetchForParents()path issues a secondaryfindBy()over articles for any ids / coords the event-table batch didn't resolve. Hits are wrapped in transientEvententities 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 firstetag when its lookup misses — it continues scanning, so a comment with redundante+apointers finds its parent via whichever tag actually resolves; (b)AncestorOperation::climbToRoot()funnels its terminal decision through aterminate()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 rootover 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 rooton NIP-22kind:1111comments surfacing an intermediate comment as the "root" instead of the thread's true origin. Two root causes: (a) many NIP-22 clients set the uppercaseE/Aroot-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 lowercasee/a(no uppercase root scope), the BFS walk would emit the farthest successfully-resolved node, which is often still akind:1111when deeper intermediates are missing from the local cache.AncestorOperationnow 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 misplacedEtag. 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/notare 4-slot[type, ns, sel, value]whilecmp/textare 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 of30023ending 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.updateClausenow 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. Preservesns/sel/valueacross type changes (intended behavior) and stops leaking values into meaning-mismatched slots. - Batched DB lookups across NIP-GX traversal stages.
TraversalResolvernow holds a per-evaluation memoization cache (id:<hex>anda:<kind:pubkey:d>→ Event) and exposes two batch prefetch methods.ParentOperationandAncestorOperationcallprefetchForParents()once before iterating the input set — scanning all declarede/E/a/Arefs and issuing exactly two batched SQL queries (EventRepository::findByIds()+ a newfindByCoordinates()) instead of onefindById/findByNaddrper item. Forancestorthe BFS also prefetches at each frontier transition, so a multi-hop walk runs one batched query per level rather than one per node.ChildOperationand the seed layer ofDescendantOperationcallprefetchForChildren(), which replaces N per-itemfindReferencingEvents()calls with onefindReferencingEventsBatch()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 rootgoes from ~500 DB round-trips to ~2 for the traversal stage. - Rewrote
EventRepository::findReferencingEvents()to use the existing GIN(jsonb_path_ops) index onevent.tagsvia the@>containment operator (tags @> '[["e","<value>"]]'::jsonb) instead ofjsonb_array_elements + tag->>0 / tag->>1, which could not use the GIN index and degraded to a seq scan on large event tables. AddedfindReferencingEventsBatch()for batched reverse-index lookups — each OR'd containment predicate uses the GIN index independently so the planner combines them via bitmap OR. Also addedfindByCoordinates()which resolves manykind:pubkey:dtuples to Event entities in a single OR'd query using thed_tagcolumn index. - [Bug] Fixed NIP-GX
ancestor rootleaking the input event as its own root when the declared root reference couldn't be resolved from the local DB. Forkind:1111comments this meant an expression like "comments by my contacts, thenancestor root" would return the comments themselves at the top of the result list whenever the referenced article (A/Etag) 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 (uppercaseE/Aor lowercasee/afor kind 1111; markedroot/replye tag for kind 1), andAncestorOperationonly 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 fromancestor rootoutput 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 (lowercaseetag,K 1111,pauthor).Organisms:Commentsnow acceptsrootContext,replyPublishUrl, andreplyCsrfTokenprops for this, andCommentFormtakes a stableform_idper reply. Styles live inassets/styles/03-components/article.cssunder.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 akind 30023/30024case inside the bookmark partial that renders viaMolecules: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/iandE/A/Itags (withkkind badges). Addressable parents (a) resolve throughOrganisms:ArticleFromCoordinate; event-id parents (e) are encoded tonote1…and rendered throughMolecules: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 viapartial/_bookmark_event_card.html.twig), the layout is inverted: the resolved root article (preferring uppercaseAthenE, falling back to lowercasea/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.twigpartial also surfaces commonly-ignored tags (alt,summary,published_at,client,thashtags linking toforum_tag,pmentions) below the content for arbitrary event kinds that don't have dedicated chrome. Styles live inassets/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 adtag) useA/acoordinate 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 useE/eevent-id references with aP/pauthor tag per NIP-22.SocialEventService::getComments,FetchCommentsMessage, theOrganisms:Commentslive component andCommentController::publishall 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 existingpartial/_bookmark_event_card.html.twigdispatcher — 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 extendkind:30880expressions with kind-specific parent/child resolution forkind:1(NIP-10 marked threading),kind:1111(NIP-22 comments, lowercasee/aparents), andkind:30040(NKBIP-01 publication indices; inclusion-based parents,a-tag declaration order for children). Modifiersancestor rootanddescendant leavesare 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/createand/expressions/edit/...). The stage operation dropdown now has a "Traversal (NIP-GX)" optgroup, andancestor/descendantstages expose a modifier selector forroot/leaves. Traversal stages are serialized as["op","<op>"]or["op","<op>","<modifier>"], matching the runner's parser. - [Bug] Fixed NIP-GX
ancestor/ancestor roottraversal stopping short of the true root forkind:1threads andkind:1111comment 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 uppercaseE/A— via aTraversalResolver::rootHint()shortcut that jumps straight to the true root in one hop. Forancestor root, the hint is preferred over the iterative walk; for plainancestor, 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.