Procházet zdrojové kódy

Oasis release 0.7.8

psy před 1 dnem
rodič
revize
0b9fb75750
100 změnil soubory, kde provedl 8615 přidání a 1860 odebrání
  1. 2 1
      .gitignore
  2. 1 0
      README.md
  3. 0 660
      docs/CHANGELOG.md
  4. 87 89
      docs/PUB/deploy.md
  5. 78 0
      docs/PUB/oasis-config.json.example
  6. 65 0
      docs/PUB/server-config.json.example
  7. 71 1
      docs/devs/install.md
  8. 29 3
      install.sh
  9. 20 2
      oasis.sh
  10. 15 4
      scripts/build-deb.sh
  11. 91 0
      scripts/oasis-pub.js
  12. 24 2
      src/AI/buildAIContext.js
  13. 36 11
      src/AI/routes_index.js
  14. 875 100
      src/backend/backend.js
  15. 92 0
      src/client/assets/larp/houses.json
  16. binární
      src/client/assets/larp/images/academia.jpg
  17. binární
      src/client/assets/larp/images/arrakis.jpg
  18. binární
      src/client/assets/larp/images/dogma.jpg
  19. binární
      src/client/assets/larp/images/helix.jpg
  20. binární
      src/client/assets/larp/images/hermandad.jpg
  21. binární
      src/client/assets/larp/images/quark.jpg
  22. binární
      src/client/assets/larp/images/solaris.jpg
  23. binární
      src/client/assets/larp/images/terraverde.jpg
  24. binární
      src/client/assets/larp/images/unsystem.jpg
  25. 354 19
      src/client/assets/styles/style.css
  26. 19 0
      src/client/assets/themes/Matrix-SNH.css
  27. 335 24
      src/client/assets/translations/oasis_ar.js
  28. 339 26
      src/client/assets/translations/oasis_de.js
  29. 250 47
      src/client/assets/translations/oasis_en.js
  30. 338 27
      src/client/assets/translations/oasis_es.js
  31. 343 28
      src/client/assets/translations/oasis_eu.js
  32. 347 26
      src/client/assets/translations/oasis_fr.js
  33. 332 21
      src/client/assets/translations/oasis_hi.js
  34. 340 27
      src/client/assets/translations/oasis_it.js
  35. 341 28
      src/client/assets/translations/oasis_pt.js
  36. 335 24
      src/client/assets/translations/oasis_ru.js
  37. 327 16
      src/client/assets/translations/oasis_zh.js
  38. 55 45
      src/client/oasis_client.js
  39. 12 2
      src/configs/config-manager.js
  40. 4 2
      src/configs/media-favorites.json
  41. 8 3
      src/configs/oasis-config.json
  42. 2 4
      src/configs/server-config.json
  43. 7 1
      src/configs/shared-state.js
  44. 2 2
      src/configs/snh-invite-code.json
  45. binární
      src/models/activity_model.js
  46. 42 9
      src/models/agenda_model.js
  47. 70 10
      src/models/audios_model.js
  48. 229 22
      src/models/banking_model.js
  49. 3 4
      src/models/blockchain_model.js
  50. 8 7
      src/models/bookmarking_model.js
  51. 94 6
      src/models/calendars_model.js
  52. 93 16
      src/models/chats_model.js
  53. 3 3
      src/models/courts_model.js
  54. 12 20
      src/models/crypto.js
  55. 2 5
      src/models/cv_model.js
  56. 8 7
      src/models/documents_model.js
  57. 294 25
      src/models/events_model.js
  58. 18 14
      src/models/feed_model.js
  59. 180 31
      src/models/forum_model.js
  60. 8 7
      src/models/images_model.js
  61. 1 0
      src/models/jobs_model.js
  62. 514 0
      src/models/larp_model.js
  63. 3 2
      src/models/logs_model.js
  64. 98 28
      src/models/main_models.js
  65. 123 10
      src/models/maps_model.js
  66. 7 15
      src/models/market_model.js
  67. 65 1
      src/models/melody_model.js
  68. 4 4
      src/models/opinions_model.js
  69. 113 12
      src/models/pads_model.js
  70. 5 7
      src/models/parliament_model.js
  71. 4 10
      src/models/pixelia_model.js
  72. 18 1
      src/models/pm_model.js
  73. 23 8
      src/models/projects_model.js
  74. 35 3
      src/models/reports_model.js
  75. 3 2
      src/models/search_model.js
  76. 1 0
      src/models/shops_model.js
  77. 7 9
      src/models/stats_model.js
  78. 2 6
      src/models/tags_model.js
  79. 25 6
      src/models/tasks_model.js
  80. 39 0
      src/models/tombstone_validator.js
  81. 5 2
      src/models/torrents_model.js
  82. 1 0
      src/models/transfers_model.js
  83. 2 1
      src/models/trending_model.js
  84. 13 9
      src/models/tribes_model.js
  85. 8 7
      src/models/videos_model.js
  86. 3 5
      src/models/votes_model.js
  87. 38 5
      src/server/SSB_server.js
  88. 11 0
      src/server/lanRouter.js
  89. 12 2
      src/server/package-lock.json
  90. 2 1
      src/server/package.json
  91. 21 12
      src/views/AI_view.js
  92. 128 55
      src/views/activity_view.js
  93. 14 9
      src/views/agenda_view.js
  94. 171 69
      src/views/audio_view.js
  95. 221 12
      src/views/banking_views.js
  96. 66 17
      src/views/blockchain_view.js
  97. 90 66
      src/views/bookmark_view.js
  98. 40 16
      src/views/calendars_view.js
  99. 39 17
      src/views/chats_view.js
  100. 0 0
      src/views/clearnet_view.js

+ 2 - 1
.gitignore

@@ -7,4 +7,5 @@ src/AI/embeddings/
 src/AI/.cache/
 .update_required
 cache/
-src/configs/banking-eco-history.json
+src/configs/
+test/results/

+ 1 - 0
README.md

@@ -87,6 +87,7 @@ Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
  + Jobs: Module to discover and manage jobs.	
  + Legacy: Module to manage your secret (private key) quickly and securely.	
  + Latest: Module to receive the most recent posts and discussions.
+ + L.A.R.P.: Module for a live-action role-playing layer with 9 houses.
  + Logs: Module to record (via AI assistant) your experiences.
  + Maps: Module to manage and share offline maps.
  + Market: Module to exchange goods or services.

+ 0 - 660
docs/CHANGELOG.md

@@ -1,660 +0,0 @@
-# Changelog
-
-All notable changes to this project will be documented in this file.
-
-<!--
-## [Unreleased]
-
-### Added
-### Changed
-### Deprecated
-### Removed
-### Fixed
-### Security
--->
-
-## v0.7.7 - 2026-05-16
-
-### Added
-
-- Proxy to Clearnet (Core plugin).
-- Lifetime model: content decay (Core plugin).
-- Suggested candidates section on job detail (Jobs plugin).
-- LAN peer discovery and feed replication (Core plugin).
-- AI navigator response page that always renders suggestions (AI plugin).
-- Carbon footprint per block shown in /blockexplorer (Blockchain plugin).
-- QR codes beside inhabitant name in /inhabitants, CV, jobs candidatures and activity cards (Core plugin).
-- OasisID in footer linked to /profile (Core plugin).
-- New Peers section in /invites with Direct Connect form (host, port, public key) (Invites plugin).
-- Smart Contract PDF export from transfer detail (Transfers plugin).
-- Melody module: generate and download personal melodies (Melody plugin).
-- Calendar dates surfaced as agenda items and overdue task reminders sent as PMs (Agenda plugin).
-- Today, Upcoming and Overdue filters on /agenda (Agenda plugin).
-- Two-column tribe-style layout on /profile and /author with module-driven Public Content (Core plugin).
-- Avatar Content toggles in /profile/edit to pick which modules appear on the avatar (Core plugin).
-
-### Changed
-
-- Unified card aesthetic across all modules: border, rounded corners, background, padding (Core plugin).
-- Stats dashboard restructured with tables sorted by count and consistent color scheme (Core plugin).
-- Market list simplified to shop-style cards with photo, kind, title and price (Market plugin).
-- Banking ECOin chart sorted and hidden when data is outdated (Banking plugin).
-- Logs Export button shown only when there are logs to export (Logs plugin).
-- Project detail and list views trimmed of empty rows and redundant summaries (Projects plugin).
-- Publishing posts immediately invalidates the activity feed cache (Core plugin).
-- First-contact flag file (Core plugin).
-- LAN router now stages discovered peers and lets the replication scheduler decide (Core plugin).
-- `oasis.sh --help` clarifies GUI options and forwarded flags (Core plugin).
-
-### Fixed
-
-- Agenda subscribe button used GET on a POST-only route (Jobs plugin).
-- LAN-discovered peers now appear in /peers Discovered and Connections (Core plugin).
-- /peers staged-peers list drained from the pull-stream source instead of being treated as an array (Core plugin).
-- Avatar Content pills now toggle off visually when unchecked (Core plugin).
-- Smart Contract PDF route no longer shadowed by the `/transfers/:id` wildcard (Transfers plugin).
-- Slow profile load and `NS_ERROR_NOT_AVAILABLE` caused by long blob waits (Core plugin).
-
-## v0.7.6 - 2026-05-09
-
-### Added
-
-- Graphos: interactive network map (Graphos plugin).
-- PUB systemd service unit (Documentation).
-- NameAuthor resolver (Core plugin).
-- Error page handlering (Core plugin).
-- Smart navigation prompt (AI plugin).
-
-### Changed
-
-- Advanced statistics dashboard (Core plugin).
-- Online peer detection (Core plugin).
-- Peers table: clickable keys and clearer columns (Core plugin).
-- Invites table layout (Invites plugin).
-- Menu reorganization: invites and peers moved to Tools (Core plugin).
-- CLI launcher: gui/server/help modes and argument forwarding (Core plugin).
-- PUB deployment guide rewritten for `sh oasis.sh server` (Documentation).
-- Tribe creator auto-follows new members at startup (Tribes plugin).
-- Blockexplorer decrypts encrypted tribe content transparently (Core plugin).
-- Standalone chat content is encrypted end-to-end (Chats plugin).
-- Standalone calendars (and their dates and notes) are encrypted end-to-end (Calendars plugin).
-- Calendar invite UI: author can generate codes; non-participants can validate a code (Calendars plugin).
-- Standalone maps (OPEN and CLOSED) are encrypted end-to-end (Maps plugin).
-- Blockexplorer attempts transparent decryption for any encrypted block (Core plugin).
-- Oasis flume LOCK stacktrace (Core plugin).
-
-### Fixed
-
-- Calendar chain removing (Calendars plugin).
-- Hide stack traces in unreachable pub errors (Invites plugin).
-- Tribe deletion now cascades to sub-tribes and their content (Tribes plugin).
-- Sub-tribe access requires parent membership (Tribes plugin).
-- Concurrent tribe updates now resolve deterministically (creator wins, then oldest member) (Tribes plugin).
-- Standalone chats, pads, maps and calendars require membership for direct URL access (Core plugin).
-- Maps CLOSED enforcement now applies to standalone maps (Maps plugin).
-
-## v0.7.5 - 2026-05-01
-
-### Added
-                                                                                                                                              
-- Lists tags with counts and filters items (Tribes plugin).
-- Sub-tribe invite carries full ancestry key chain (Tribes plugin).
-                                                                                                                                                                                                   
-### Changed
-
-- More layers of privacy/encryption applied to sensitive content at different places (Core plugin).
-
-### Fixed
-
-- Governance cycles and general parliament proposals (Tribes plugin).
-
-## v0.7.4 - 2026-04-25
-
-### Added
-
-- Logs module: create, manage and share records about your experiences (Logs plugin).
-
-### Fixed
-
-- Tribes strict mode ACLs (Tribes plugin).
-- Labyrinth game scoring (Games plugin).
-
-## v0.7.3 - 2026-04-20
-
-### Added
-
-- Wish settings: determines how to access the whole content and your level of experiences into the network (Core plugin).
-- PM settings: configure the level of exposition to private messages in the network (Core plugin).
-
-### Changed
-
-- More layers of privacy/encryption applied to sensitive content at different places (Core plugin).
-
-### Fixed
-
-- Pads ACL (Pads plugin).
-- PUB UBI claim (Banking plugin).
-
-## v0.7.2 - 2026-04-16
-
-### Added
-
-- Torrents module: create, manage and share torrents (Torrents plugin).
-
-### Changed
-
-- More layers of privacy/encryption applied to sensitive content at different places (Core plugin).
-
-### Fixed
-
-- Chat participants (Chats plugin).
-
-## v0.7.1 - 2026-04-14
-
-### Added
-
-- "nodemon" dev running scripts and documentation (Core plugin).
-- TikTakToe game (Games plugin).
-- Neon Infiltrator (Games plugin).
-
-### Changed
-
-- Console output configuration parameters verbose (Core plugin).
-- More layers of privacy/encryption applied to sensitive content at different places (Core plugin).
-
-### Fixed
-
-- Refeeds activity (Feeds plugin).
-
-## v0.7.0 - 2026-04-11
-
-### Added
-
-- Pads module: create, manage and discover collaborative encrypted text editors (Pads plugin).
-- Chats module: create, manage and discover encrypted chats (Chats plugin).
-- Games module: play and share your scores in various mini-games (Games plugin).
-- Calendars module: create, manage and discover calendars (Calendars plugin).
-
-### Changed
-
-- Connectivity mode notice: offline/online (Core plugin).
-
-## v0.6.9 - 2026-04-04
-
-### Added
-
-- Maps module: create, manage and share offline maps with coordinates, markers and collaborative features (Maps plugin).
-- Shops module: create, manage and discover shops with products, favorites, opinions and purchases (Shops plugin).
-
-### Fixed
-
-- Searching blobs and items rendering (Search plugin).
-- UBI claim system: auto-execute epoch on Banking overview, prominent "Claim UBI!" button (Banking plugin).
-- UBI displayed in inhabitant profiles (Inhabitants plugin).
-- UBI filter added to transfers (Transfers plugin).
-- Favorites enrichment in Documents edit and single views (Documents plugin).
-- Bookmarks edit view favorite toggle (isFav → isFavorite) (Bookmarks plugin).
-- Favorites add/remove error handling for all media modules (Core plugin).
-- PUB wallet settings configuration (Settings plugin).
-
-### Changed
-
-- Tribes/Sub-Tribes content encryption (AES-256-GCM) (Tribes plugin).
-
-## v0.6.8 - 2026-03-12
-
-### Fixed
-
-- Government method image (Parliament plugin).
-- Trending text formatting- tombstoned content (Trending plugin).
-- Opinions text formatting- tombstoned content (Opinions plugin).
-- Forums text formatting (Forums plugin).
-
-## v0.6.7 - 2026-03-04
-
-### Added
-
-- Writing comments within feeds (Feed plugin).
-- PUB metadata and invitations (Invites plugin).
-- Chinese simplified (zh) translation (i18n).
-- Arab (ar) translation (i18n).
-- Hindi (hi) translation (i18n).
-- Russian (ru) translation (i18n).
-
-### Fixed
-
-- SNH-Mobile Theme (Core plugin).
- 
-### Changed
-
-- Multiple alignment of views between modules (Multiple plugins).
-- Cloud tags (Tags plugin).
-- Auto-join button for PUB: SNH "La Plaza" (Invites plugin).
-- i18n languages array expanded: ['en', 'es', 'fr', 'eu', 'de', 'it', 'pt', 'zh', 'ar', 'hi', 'ru'] (Core plugin).
-
-## v0.6.6 - 2026-02-23
-
-### Added
-
-- SNH-Mobile Theme (Core plugin).
-- Added sub-tribes (Core plugin).
-- Max size control when uploading any file (Core plugin).
-- File uploading into every single commenting point (Core plugin).
-- Add an IP/Port for creating direct peering (Peers plugin).
-- Oasis new updates available notice banner (Core plugin).
-- Carbon footprint indicator in Statistics based on blobs/blockchain weight (Statistics plugin).
-- Canvas block visualization in Block Explorer showing last 50 blocks as colored bars (Blockexplorer plugin).
-- Inbox notification badge showing unread message count in topbar (Inbox plugin).
-- Feed published success confirmation message banner (Feed plugin).
-- Default SNH invite code loaded from snh-invite-code.json (Invites plugin).
-- Peer deduplication by host in Invites (Invites plugin).
-- Peer deduplication by key and table layout for peer listing (Peers plugin).
-- "Device source" field showing KIT/DESKTOP/MOBILE based on theme (Inhabitants/Profile plugin).
-- Module preset buttons for grouped configurations: Basic, Social, Economy, Full (Modules plugin).
-- Dominant opinion highlight next to Total Opinions (Trending/Opinions plugin).
-- German (de) translation (i18n).
-- Italian (it) translation (i18n).
-- Portuguese (pt) translation (i18n).
-- Shared state module for cross-module communication (Core plugin).
-
-### Fixed
-
-- MIME type error when uploading .mp4 (Videos plugin).
-- URL generation problems when containing "special characters" (Forum plugin).
-- Language selection between executing instances (Core plugin).
-- LAN broadcasting features (Core plugin).
-- Currently online peers discovering (Peers plugin).
-- Activity level shadowing (Inhabitants plugin).
-- Strip dangerous HTML tags from markdown output (Core plugin).
-- Plaintext injection vulnerability: comprehensive stripDangerousTags sanitization across all user text inputs in backend (Core Security).
-- Activity feed post truncation at 500 chars with "View details" link for long posts (Activity plugin).
-- Spread content now shows excerpt (300 chars) instead of just hashtag name (Activity plugin).
-- IN REPLY TO improved display with border-left styling, author name bold, and context preview (Activity plugin).
-- Activity level dot was empty, now displays colored dot (●) matching inhabitants view (Profile plugin).
-- Task description layout in Search: description now appears on new line below label (Search plugin).
-- Projects description label formatting with flex-direction column and word-break (Projects plugin).
-- Banking addresses from OASIS source now have delete action (Banking plugin).
-- Direct Connect form moved below networking action buttons for better UX (Peers plugin).
-- Peer validation and table layout for clearer peer listing (Peers plugin).
- 
-### Changed
-
-- Tribes for adding a "fractal" of mods inside (Tribes plugin).
-- Removing metadata and added strong controls before uploading: PDF, video, audio, image... (Core plugin).
-- Packages.json (Core plugin).
-- i18n languages array expanded: ['en', 'es', 'fr', 'eu', 'de', 'it', 'pt'] (Core plugin).
-- Activity view now truncates long posts instead of expanding full content (Activity plugin).
-- Peers view uses table layout instead of nested lists (Peers plugin).
-- Block Explorer shows visual block canvas above the block list (Blockexplorer plugin).
-
-## v0.6.5 - 2026-01-16
-
-### Added
-
-- Blockexplorer search engine (Blockexplorer plugin).
-
-### Fixed
-
-- Spreading threads- comments (Activity plugin).
-- Minor fixes (Activity plugin).
-
-## v0.6.4 - 2026-01-05
-
-### Added
-
-- More information shared when spreading content at inhabitants activity (Activity plugin).
-- New PUB: pub.andromeda.oasis
-
-### Changed
-
-- Strong backend refactoring (4962L -> 2666L) (Core plugin).
-
-### Fixed
-
-- Fixed parliament cycles (Parliament plugin).
-- Refeeding with hashtags (Feed plugin).
-
-## v0.6.3 - 2025-12-10
-
-### Fixed
-
-- Fixed mentions (Core plugin).
-- Fixed feeds (Feed plugin).
-- Minor details at market view (Market plugin).
-
-## v0.6.2 - 2025-12-05
-
-### Added
-
-- Added a footer (Core plugin).
-- Added favorites to media related modules (Favorites plugin).
-- Added advanced search engine integration into modules (Search plugin).
- 
-### Changed
-
-- Added Oasis version at GUI (Core plugin).
-- Added templates for reporting standardization (Reports plugin).
-- Added market new functionalities (Market plugin).
-- Added bookmarks new functionalities (Bookmarks plugin).
-- Added jobs new filters (Jobs plugin).
- 
-### Fixed
-
-- Security fixes (Core plugin).
-- Reports filters (Reports plugin).
-- Tasks minor changes (Tasks plugin).
-- Events minor changes (Events plugin).
-- Votations minor changes (Votations plugin).
-- Market minor changes (Market plugin).
-- Projects minor changes (Projects plugin).
-- Jobs minor changes (Jobs plugin).
-- Transfers minor changes (Transfers plugin).
-
-## v0.6.1 - 2025-12-01
-
-### Changed
-
-- Added more notifications for tribes activity (Activity plugin).
-- Reordered filters (Opinions plugin).
- 
-### Fixed
-
-- Feed minor changes (Feed plugin).
-- Tribes feed styles container (Tribes plugin).
-
-## v0.6.0 - 2025-11-29
-
-### Changed
-
-- Added more opinion categories (Opinions plugin).
- 
-### Fixed
-
-- Tag counters (Tags plugin).
-- Duplicated content when searching (Search plugin).
-- Inhabitant-linked styles for Contact and PUB (Activity plugin).
-- Old posts retrieving at inhabitant profile (Core plugin).
-- Fixed threading comments (Core plugin).
-
-## v0.5.9 - 2025-11-28
-
-### Added
-
-- Added fixed (also linked) threads into activity feed (Activity plugin).
- 
-### Fixed
-
-- Fixed laws stats (Parliament plugin).
-
-## v0.5.8 - 2025-11-25
-
-### Fixed
-
-- Fixed post preview from a pre-cached context (Core plugin).
-- Fixed tasks assignement to others different to the author (Core plugin).
-- Fixed comments context adding different to blog/post (Core plugin).
-
-## v0.5.7 - 2025-11-24
-
-### Added
-
-- Collapsible menu entries (Core plugin).
-
-### Fixed
-
-- Remote videos fail to load at Firefox/LibreWolf (Core plugin).
-- Fixed the comment query to return all posts whose root is the topic ID (Core plugin).
-- Fixed render-format for latest posts (Core plugin).
-- Fixed inhabitants listing for short-time activities (Activity plugin).
-
-## v0.5.6 - 2025-11-21
-
-### Added
-
-- Extended post-commenting into various modules (bookmarks, images, audios, videos, documents, votations, events, tasks, reports, market, projects, jobs).
- 
-### Changed
-
-- Added details about current proposals at Courts (Courts plugin).
-- Parliament proposal listing when voting process has started (Parliament plugin).
- 
-### Fixed
-
-- Votations deduplication applied when directly voting from Parliament (Votes plugin).
- 
-## v0.5.5 - 2025-11-15
-
-### Added
-
-- Conflicts resolution system (Courts plugin).
- 
-## v0.5.4 - 2025-10-30
-
-### Fixed
-
-- Content stats (Stats plugin).
-- Non-avatar inhabitants listing (Inhabitants plugin).
-- Inhabitants suggestions (Inhabitants plugin).
-- Activity level (Inhabitants plugin).
-- Parliament duplication (Parliament plugin).
-- Added Parliament to blockexplorer (Blockexplorer plugin).
-
-## v0.5.3 - 2025-10-27
-
-### Fixed
-
-- Tribes duplication (Tribes plugin- Activity plugin- Stats plugin).
-
-## v0.5.2 - 2025-10-22
-
-### Added
-
-- Government system (Parliament plugin).
- 
-### Fixed
-
-- Forum category translations (Forum plugin).
-
-## v0.5.1 - 2025-09-26
-
-### Added
-
-- Activity level measurement (Inhabitants plugin).
-- Home page settings (Settings plugin).
-
-### Fixed
-
-- ECOIn wallet addresses (Banking plugin).
-- Tribes view (Tribes plugin).
-- Inhabitants view (Inhabitants plugin).
-- Avatar view (Main module).
-- Forum posts (Forums plugin).
-- Tribes info display (Search plugin).
-
-## v0.5.0 - 2025-09-20
-
-### Added
-
-- Custom answer training (AI plugin).
-
-### Fixed
-
-- Clean-SNH theme.
-- AI learning (AI plugin).
-
-## v0.4.9 - 2025-09-01
-
-### Added
-
-- French translation.
- 
-### Changed
-
-- Inbox (PM plugin).
-
-## v0.4.8 - 2025-08-27
- 
-### Fixed
-
-- Fixed legacy codes (invites plugin).
-- Fixed SHS generator (script).
- 
-### Changed
-
-- SHS CAPS (for private gardering).
-- Deploy PUB documentation.
-- Invites.
-- Banking.
-- Inhabitants.
-
-## v0.4.7 - 2025-08-27
-
-### Added
-
-- Online, discovered, unknown listing (peers plugin).
-- Federated, unfederated, unreachable networks (invites plugin).
- 
-### Fixed
-
-- Fixed mentioning (mentions plugin).
-- Forum feed (activity plugin).
- 
-### Changed
-
-- Stats.
-- Mentions.
-- Peers.
-- Invites.
-- Activity.
-
-## v0.4.6 - 2025-08-24
- 
-### Fixed
-
-- Follow/Unfollow and Pledges (projects plugin).
-- Karma SCORE (inhabitants plugin).
- 
-### Changed
-
-- Activity.
-- Inhabitants.
-- Search.
-
-## v0.4.5 - 2025-08-21
-
-### Added
-
-- Exchange (ECOin current value) for all inhabitants (banking plugin).
-- Karma SCORE.
-- Upload a set of images/collections (images plugin).
- 
-### Fixed
-
-- Add a new bounty (projects plugin).
-- Activity duplications.
- 
-### Changed
-
-- Activity.
-- Avatar.
-- Inhabitants.
-- Stats.
-
-## v0.4.4 - 2025-08-17
-
-### Added
-
-- Projects: Module to explore, crowd-funding and manage projects.
-- Banking: Module to distribute a fair Universal Basic Income (UBI) using commons-treasury.
- 
-### Changed
-
-- AI.
-- Activity.
-- BlockExplorer.
-- Statistics.
-- Avatar.
-
-## v0.4.3 - 2025-08-08
-
-### Added
-
-- Limiter to blockchain logstream retrieval.
-
- - Jobs: Module to discover and manage jobs.
- - BlockExplorer: Module to navigate the blockchain.
-
-## v0.4.0 - 2025-07-29
-
-### Added
-
- - Forums: Module to discover and manage forums.
-
-## v0.3.8 - 2025-07-21
-
-### Added
-
-- AI model called "42".
-
-## v0.3.5 - 2025-06-21 (summer solstic)
-
-### Changed
-
-- Hardcore "hacking" and refactoring for: models- backend- middleware- views.
-
-### Added
-
-- Some "core" modules:
-
- - Agenda: Module to manage all your assigned items.
- - Audios: Module to discover and manage audios.
- - Bookmarks: Module to discover and manage bookmarks.
- - Cipher: Module to encrypt and decrypt your text symmetrically (using a shared password).
- - Documents: Module to discover and manage documents.
- - Events: Module to discover and manage events.
- - Feed: Module to discover and share short-texts (feeds).
- - Governance: Module to discover and manage votes.
- - Images: Module to discover and manage images.
- - Invites: Module to manage and apply invite codes.
- - Legacy: Module to manage your secret (private key) quickly and securely.
- - Latest: Module to receive the most recent posts and discussions.
- - Market: Module to exchange goods or services.
- - Multiverse: Module to receive content from other federated peers.
- - Opinions: Module to discover and vote on opinions.
- - Pixelia: Module to draw on a collaborative grid.
- - Popular: Module to receive posts that are trending, most viewed, or most commented on.
- - Reports: Module to manage and track reports related to issues, bugs, abuses, and content warnings.
- - Summaries: Module to receive summaries of long discussions or posts.
- - Tags: Module to discover and explore taxonomy patterns (tags).
- - Tasks: Module to discover and manage tasks.
- - Threads: Module to receive conversations grouped by topic or question.
- - Transfers: Module to discover and manage smart-contracts (transfers).
- - Trending: Module to explore the most popular content.
- - Tribes: Module to explore or create tribes (groups).
- - Videos: Module to discover and manage videos.
- - Wallet: Module to manage your digital assets (ECOin).
- - Topics: Module to receive discussion categories based on shared interests.
-
-- New languages: Spanish, Euskara and French.
-
-- New themes: SNH-Clear, SNH-Purple and SNH-Matrix.
-
-- L.A.R.P (Live Action Role-PLaying) structure.
-
-## v0.3.0 - 2024-12-15
-
-### Changed
-
-- Migration to Node.js v22.12.0 (LTS)
-
-## v0.2.3 - 2022-11-05
-
-### Added
-
-- Federation with SSB Multiverse
-
-## v0.1.0 - 2022-07-24
-
-### Added
-
-- Initial commit

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 87 - 89
docs/PUB/deploy.md


+ 78 - 0
docs/PUB/oasis-config.json.example

@@ -0,0 +1,78 @@
+{
+  "themes": {
+    "current": "Dark-SNH"
+  },
+  "ux": {
+    "current": "blocks"
+  },
+  "modules": {
+    "popularMod": "on",
+    "topicsMod": "on",
+    "summariesMod": "on",
+    "latestMod": "on",
+    "threadsMod": "on",
+    "multiverseMod": "on",
+    "invitesMod": "on",
+    "walletMod": "off",
+    "legacyMod": "off",
+    "cipherMod": "off",
+    "bookmarksMod": "on",
+    "videosMod": "on",
+    "docsMod": "on",
+    "audiosMod": "on",
+    "tagsMod": "on",
+    "imagesMod": "on",
+    "trendingMod": "on",
+    "eventsMod": "on",
+    "tasksMod": "on",
+    "marketMod": "off",
+    "votesMod": "on",
+    "tribesMod": "on",
+    "reportsMod": "on",
+    "opinionsMod": "on",
+    "padsMod": "on",
+    "calendarsMod": "on",
+    "transfersMod": "off",
+    "feedMod": "on",
+    "pixeliaMod": "on",
+    "melodyMod": "on",
+    "agendaMod": "on",
+    "aiMod": "off",
+    "aiNavMod": "off",
+    "forumMod": "on",
+    "gamesMod": "on",
+    "jobsMod": "off",
+    "shopsMod": "off",
+    "projectsMod": "off",
+    "bankingMod": "off",
+    "parliamentMod": "on",
+    "courtsMod": "on",
+    "favoritesMod": "on",
+    "logsMod": "on",
+    "mapsMod": "on",
+    "chatsMod": "on",
+    "torrentsMod": "on",
+    "graphosMod": "on",
+    "larpMod": "on"
+  },
+  "wallet": {
+    "url": "http://localhost:7474",
+    "user": "",
+    "pass": "",
+    "fee": "5"
+  },
+  "walletPub": {
+    "pubId": ""
+  },
+  "ai": {
+    "prompt": "Provide an informative and precise response."
+  },
+  "ssbLogStream": {
+    "limit": 2000
+  },
+  "homePage": "activity",
+  "language": "en",
+  "wish": "whole",
+  "pmVisibility": "whole",
+  "lanBroadcasting": false
+}

+ 65 - 0
docs/PUB/server-config.json.example

@@ -0,0 +1,65 @@
+{
+  "logging": {
+    "level": "notice"
+  },
+  "caps": {
+    "shs": "H5EC+V5BU9s0lWxCkt4z8a095Sj8a6TgiLKPYi1JD7s="
+  },
+  "pub": true,
+  "local": false,
+  "friends": {
+    "dunbar": 300,
+    "hops": 3
+  },
+  "gossip": {
+    "connections": 50,
+    "local": false,
+    "friends": true,
+    "seed": true,
+    "global": true
+  },
+  "replicationScheduler": {
+    "autostart": true,
+    "partialReplication": null
+  },
+  "autofollow": {
+    "enabled": true,
+    "feeds": [
+      "@0qSCyK3xyL71X4qKkmf84Cb2riP6OeUqxCvbP2Z6HWs=.ed25519"
+    ]
+  },
+  "connections": {
+    "seeds": [
+    ],
+  "incoming": {
+    "net": [
+    {
+      "scope": ["device", "local", "public"],
+      "transform": "shs",
+      "port": 8008,
+      "external": "pub.example.com"
+    }
+    ],
+      "unix": [
+        {
+          "scope": [
+            "device",
+            "local",
+            "private"
+          ],
+          "transform": "noauth"
+        }
+      ]
+    },
+    "outgoing": {
+      "net": [
+        {
+          "transform": "shs"
+        }
+      ],
+      "tunnel": [],
+      "onion": [],
+      "ws": []
+    }
+  }
+}

+ 71 - 1
docs/devs/install.md

@@ -10,7 +10,77 @@ cd src/server
 npm run dev
 ```
 
-Once Oasis is started in dev mode, visit [http://localhost:3000](http://localhost:3000). 
+Once Oasis is started in dev mode, visit [http://localhost:3000](http://localhost:3000).
 
 The backend restarts automatically (via [nodemon](https://nodemon.io)) whenever you save changes to `.js` or `.json` files in `src/backend/`, `src/models/`, `src/views/`, or `src/client/`. Static assets (`src/client/assets/`) do not trigger a restart. Page autoreload is not available because we avoid using JavaScript in the browser — reload the page manually to display your changes.
 
+## Two-process architecture
+
+Oasis runs as two cooperating Node processes:
+
+- **`SSB_server.js`** — boots the local Secure Scuttlebutt sbot (gossip, EBT, friends, blobs, LAN, search, box, query, tangle, links, backlinks). Owns `~/.ssb`.
+- **`backend.js`** — Koa HTTP server that connects to the sbot through `ssb-client` and renders pages with hyperaxe. Serves `http://localhost:3000`.
+
+The backend talks to the sbot over a local Unix socket. If you only restart the backend (the default in `npm run dev`), the sbot keeps running. If you change anything under `src/server/` or anything that holds an SSB handle inside a model, restart the sbot too (kill the `SSB_server.js` process and re-run `npm start`).
+
+## npm scripts
+
+Run these from `src/server/`:
+
+- **`npm start`** — boots the SSB sbot in the background, waits ~10 s, then starts the HTTP backend. Use this for an end-to-end local run.
+- **`npm run start:ssb`** — start only the SSB sbot.
+- **`npm run start:backend`** — start only the HTTP backend (assumes sbot is already running).
+- **`npm run dev`** — backend under nodemon watch (auto-restart on file change). Sbot is **not** watched.
+
+The launcher script at the repo root (`oasis.sh`) wraps these and detects whether to start in `server`, `pub`, or `gui` mode.
+
+## Tests
+
+Unit and integration tests live under `test/` at the repo root, grouped per module in `test/mods/`. Coverage spans **40+ modules** including tribes, feed, banking, parliament, courts, jobs, market, shops, media (audio/video/image/document/torrent), maps, pads, calendars, events, tasks, votes, transfers, reports, projects, opinions, activity, AI, CV, LARP, melody, and more.
+
+Run the full suite from the `oasis/` directory:
+
+```sh
+# All modules, each in a subprocess with safe ~/.ssb isolation
+bash test/run.sh
+
+# Same, but skip the confirmation prompt
+bash test/run.sh --yes
+
+# All modules in a single Node process (no isolation, faster but mixes state)
+node test/run.js
+
+# A single module
+node test/run.js mods/tribes
+node test/run.js mods/media/audios
+
+# Per-module shortcut (no isolation, fast iteration)
+bash test/mods/<module>/run.sh
+```
+
+`test/run.sh` moves your live `~/.ssb` to a timestamped backup (`~/.ssb-bak-YYYYMMDD_HHMMSS`) before the run and creates a fresh empty `~/.ssb` for the tests. **Stop any running Oasis instance first** — only one process can hold `~/.ssb` open at a time. Pass `--restore` to restore the original `~/.ssb` after the run finishes (CI uses this).
+
+What the tests cover, how to add a new module suite, and a record of bugs the test harness has caught are documented in [`test/README.md`](../../test/README.md). When you change a model, add or update its test under `test/mods/<module>/` so the change comes with a regression net.
+
+## Useful commands while developing
+
+- **`npm install`** — install / refresh dependencies.
+- **`npm test`** — run automated tests (calls into the `test/` harness).
+- **`npm run fix`** — auto-fix formatting and lint issues (also runs as a pre-commit hook).
+
+## Directory map (cheat sheet)
+
+- `src/server/` — SSB sbot entry, ssb-config, secret-stack plugin wiring, vendored `packages/ssb-server`.
+- `src/backend/` — Koa HTTP entry (`backend.js`), middleware, blob handler, URL renderer, sanitizer.
+- `src/models/` — per-module data access. Factory functions that receive `cooler` (and sometimes `tribeCrypto`, `tribesModel`) and return query/publish methods.
+- `src/views/` — hyperaxe view functions. Pure HTML builders.
+- `src/AI/` — local LLM service (`ai_service.mjs` on port 4001) and context assembler.
+- `src/configs/` — `oasis-config.json` (module toggles, themes, language) and `config-manager.js`.
+- `src/client/assets/` — CSS, theme files, translations (11 languages), static images.
+- `docs/` — user and developer documentation (this folder).
+- `test/` — test harness (`run.sh`, `run.js`, `seed.js`, `helpers/`) and per-module test suites in `mods/`.
+- `scripts/` — build helpers (`build-deb.sh`, node_modules patcher).
+
+## Pre-commit checks
+
+The pre-commit hook runs `cspell` and `prettier`. See [`contributing.md`](./contributing.md) for what to do when a check fails (typos go in `.cspell.json`; formatting via `npm run fix`).

+ 29 - 3
install.sh

@@ -3,15 +3,41 @@
 cd src/server
 
 printf "==========================\n"
-printf "|| OASIS Installer v0.4 ||\n"
+printf "|| OASIS Installer v0.5 ||\n"
 printf "==========================\n"
 
 sudo apt-get install -y git curl tar
 
 curl -sL http://deb.nodesource.com/setup_22.x | sudo bash -
 sudo apt-get install -y nodejs
-npm install .
-npm audit fix
+
+GREEN=$'\e[32m'
+DIM=$'\e[2m'
+RESET=$'\e[0m'
+
+echo ""
+echo "Installing Node.js packages..."
+echo ""
+
+NPM_LOG=$(mktemp)
+if ! npm install . --silent --no-audit --no-fund --no-progress --loglevel=error >"$NPM_LOG" 2>&1; then
+    echo "npm install failed. Output:"
+    cat "$NPM_LOG"
+    rm -f "$NPM_LOG"
+    exit 1
+fi
+rm -f "$NPM_LOG"
+
+DEPS=$(node -e "const p=require('./package.json'); console.log(Object.keys({...(p.dependencies||{}), ...(p.devDependencies||{})}).sort().join('\n'))" 2>/dev/null)
+for dep in $DEPS; do
+    if [ -d "node_modules/$dep" ]; then
+        printf "  ${GREEN}[✓]${RESET} %s\n" "$dep"
+    fi
+done
+
+echo ""
+
+npm audit fix --silent --no-fund --no-progress >/dev/null 2>&1 || true
 
 MODEL_DIR="../AI"
 LLM_FILE="oasis-42-1-chat.Q4_K_M.gguf"

+ 20 - 2
oasis.sh

@@ -14,6 +14,15 @@ Modes:
   server          Launch only the Oasis backend in headless / pub mode.
   help, -h        Show this help message.
 
+PUB admin commands (require the sbot to be running: sh oasis.sh server):
+  whoami                   Print this PUB id
+  invite [N]               Create an invite code (default uses=1)
+  name <text>              Set this PUB display name
+  announce <host> [port]   Publish a pub address (default port=8008)
+  follow <feedId>          Follow another PUB / feed
+  status                   Show peer / replication status
+  gossip                   List known gossip peers
+
 GUI options (forwarded to the backend):
   --host=<ip>           Hostname / IP the web UI listens on (default: localhost).
                         Use 0.0.0.0 to expose on a VPS.
@@ -28,9 +37,10 @@ GUI options (forwarded to the backend):
 Examples:
   sh oasis.sh
   sh oasis.sh server
+  sh oasis.sh invite 100
+  sh oasis.sh name "My PUB"
+  sh oasis.sh announce mypub.example.com
   sh oasis.sh --host=0.0.0.0 --port=8080 --no-open
-  sh oasis.sh --public --no-open --host=0.0.0.0 --port=8080
-  sh oasis.sh --allow-host=oasis.example.com --no-open
 EOF
 }
 
@@ -49,9 +59,17 @@ case "$MODE" in
     exit 0
     ;;
   server|pub)
+    if [ -f "$CONFIG_FILE" ]; then
+      sed -i.bak 's/"aiMod": *"on"/"aiMod": "off"/' "$CONFIG_FILE"
+      sed -i.bak 's/"aiNavMod": *"on"/"aiNavMod": "off"/' "$CONFIG_FILE"
+      rm -f "$CONFIG_FILE.bak"
+    fi
     cd "$CURRENT_DIR/src/server" || exit 1
     exec node SSB_server.js start
     ;;
+  whoami|invite|name|announce|follow|status|gossip)
+    exec node "$CURRENT_DIR/scripts/oasis-pub.js" "$@"
+    ;;
   gui)
     shift
     cd "$CURRENT_DIR/src/backend" || exit 1

+ 15 - 4
scripts/build-deb.sh

@@ -2,7 +2,7 @@
 
 set -e
 
-VERSION="0.7.4"
+VERSION="0.7.8"
 PKG_NAME="oasis"
 ARCH=$(dpkg --print-architecture)
 SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)"
@@ -25,7 +25,9 @@ mkdir -p "${DEB_ROOT}${INSTALL_DIR}/src/views"
 mkdir -p "${DEB_ROOT}${INSTALL_DIR}/src/models"
 mkdir -p "${DEB_ROOT}${INSTALL_DIR}/src/client"
 mkdir -p "${DEB_ROOT}${INSTALL_DIR}/src/configs"
+mkdir -p "${DEB_ROOT}${INSTALL_DIR}/src/AI"
 mkdir -p "${DEB_ROOT}${INSTALL_DIR}/scripts"
+mkdir -p "${DEB_ROOT}${INSTALL_DIR}/docs"
 mkdir -p "${DEB_ROOT}/usr/bin"
 mkdir -p "${DEB_ROOT}/usr/share/applications"
 mkdir -p "${DEB_ROOT}/usr/share/doc/${PKG_NAME}"
@@ -35,9 +37,7 @@ echo "Copying application files..."
 
 cp -r "${SRC_DIR}/src/server/package.json" "${DEB_ROOT}${INSTALL_DIR}/src/server/"
 cp -r "${SRC_DIR}/src/server/package-lock.json" "${DEB_ROOT}${INSTALL_DIR}/src/server/" 2>/dev/null || true
-cp "${SRC_DIR}/src/server/ssb_config.js" "${DEB_ROOT}${INSTALL_DIR}/src/server/"
-cp "${SRC_DIR}/src/server/ssb_metadata.js" "${DEB_ROOT}${INSTALL_DIR}/src/server/"
-cp "${SRC_DIR}/src/server/SSB_server.js" "${DEB_ROOT}${INSTALL_DIR}/src/server/"
+cp "${SRC_DIR}/src/server/"*.js "${DEB_ROOT}${INSTALL_DIR}/src/server/"
 
 if [ -d "${SRC_DIR}/src/server/packages" ]; then
     cp -r "${SRC_DIR}/src/server/packages" "${DEB_ROOT}${INSTALL_DIR}/src/server/"
@@ -53,9 +53,20 @@ find "${DEB_ROOT}${INSTALL_DIR}/src/client" -name ".ruff_cache" -type d -exec rm
 for f in oasis-config.json server-config.json snh-invite-code.json config-manager.js shared-state.js; do
     cp "${SRC_DIR}/src/configs/${f}" "${DEB_ROOT}${INSTALL_DIR}/src/configs/"
 done
+cp "${SRC_DIR}/src/AI/"*.js "${DEB_ROOT}${INSTALL_DIR}/src/AI/" 2>/dev/null || true
+cp "${SRC_DIR}/src/AI/"*.mjs "${DEB_ROOT}${INSTALL_DIR}/src/AI/" 2>/dev/null || true
+if [ -d "${SRC_DIR}/src/AI/embeddings" ]; then
+    cp -r "${SRC_DIR}/src/AI/embeddings" "${DEB_ROOT}${INSTALL_DIR}/src/AI/"
+fi
 cp -r "${SRC_DIR}/scripts" "${DEB_ROOT}${INSTALL_DIR}/"
+if [ -d "${SRC_DIR}/docs/PUB" ]; then
+    mkdir -p "${DEB_ROOT}${INSTALL_DIR}/docs/PUB"
+    cp "${SRC_DIR}/docs/PUB/"* "${DEB_ROOT}${INSTALL_DIR}/docs/PUB/" 2>/dev/null || true
+fi
 cp "${SRC_DIR}/oasis.sh" "${DEB_ROOT}${INSTALL_DIR}/"
+cp "${SRC_DIR}/install.sh" "${DEB_ROOT}${INSTALL_DIR}/" 2>/dev/null || true
 cp "${SRC_DIR}/LICENSE" "${DEB_ROOT}${INSTALL_DIR}/"
+cp "${SRC_DIR}/README.md" "${DEB_ROOT}${INSTALL_DIR}/" 2>/dev/null || true
 
 cat > "${DEB_ROOT}/DEBIAN/control" << EOF
 Package: ${PKG_NAME}

+ 91 - 0
scripts/oasis-pub.js

@@ -0,0 +1,91 @@
+#!/usr/bin/env node
+const path = require('path');
+const ssbConfig = require(path.join(__dirname, '..', 'src', 'server', 'ssb_config'));
+const ssbClient = require(path.join(__dirname, '..', 'src', 'server', 'node_modules', 'ssb-client'));
+
+const socketPath = path.join(ssbConfig.path, 'socket');
+const publicInteger = (ssbConfig.keys.public || '').replace('.ed25519', '');
+const remote = `unix:${socketPath}~noauth:${publicInteger}`;
+
+const cmd = process.argv[2];
+const args = process.argv.slice(3);
+
+const usage = () => {
+  console.error('Usage: sh oasis.sh <command> [args]');
+  console.error('');
+  console.error('PUB admin commands (sbot must be running: sh oasis.sh server):');
+  console.error('  whoami                          Print this PUB id');
+  console.error('  invite [N]                      Create an invite code (default uses=1)');
+  console.error('  name <text>                     Set PUB display name');
+  console.error('  announce <host> [port]          Publish a pub address (default port=8008)');
+  console.error('  follow <feedId>                 Follow another PUB');
+  console.error('  status                          Show peer / replication status');
+  console.error('  gossip                          List known gossip peers');
+  process.exit(1);
+};
+
+if (!cmd) usage();
+
+const call = (fn, ...a) => new Promise((res, rej) => fn(...a, (e, r) => e ? rej(e) : res(r)));
+
+ssbClient(ssbConfig.keys, { remote, caps: ssbConfig.caps }).then(async (ssb) => {
+  try {
+    switch (cmd) {
+      case 'whoami': {
+        const r = await call(ssb.whoami.bind(ssb));
+        console.log(JSON.stringify(r, null, 2));
+        break;
+      }
+      case 'invite': {
+        const uses = Math.max(1, parseInt(args[0] || '1', 10));
+        const code = await call(ssb.invite.create.bind(ssb.invite), uses);
+        console.log(code);
+        break;
+      }
+      case 'name': {
+        const text = String(args[0] || '');
+        if (!text) { console.error('Missing name'); process.exit(1); }
+        const me = await call(ssb.whoami.bind(ssb));
+        const r = await call(ssb.publish.bind(ssb), { type: 'about', about: me.id, name: text });
+        console.log(JSON.stringify(r, null, 2));
+        break;
+      }
+      case 'announce': {
+        const host = args[0];
+        const port = parseInt(args[1] || '8008', 10);
+        if (!host) { console.error('Missing host'); process.exit(1); }
+        const me = await call(ssb.whoami.bind(ssb));
+        const r = await call(ssb.publish.bind(ssb), { type: 'pub', address: { key: me.id, host, port } });
+        console.log(JSON.stringify(r, null, 2));
+        break;
+      }
+      case 'follow': {
+        const feedId = args[0];
+        if (!feedId) { console.error('Missing feedId'); process.exit(1); }
+        const r = await call(ssb.publish.bind(ssb), { type: 'contact', contact: feedId, following: true });
+        console.log(JSON.stringify(r, null, 2));
+        break;
+      }
+      case 'status': {
+        const r = await call(ssb.status.bind(ssb));
+        console.log(JSON.stringify(r, null, 2));
+        break;
+      }
+      case 'gossip': {
+        const r = await call(ssb.gossip.peers.bind(ssb.gossip));
+        console.log(JSON.stringify(r, null, 2));
+        break;
+      }
+      default:
+        usage();
+    }
+    ssb.close();
+  } catch (e) {
+    console.error('Error:', e.message || e);
+    process.exit(1);
+  }
+}).catch((e) => {
+  console.error('Connection error:', e.message || e);
+  console.error('Is the Oasis sbot running? (sh oasis.sh server)');
+  process.exit(1);
+});

+ 24 - 2
src/AI/buildAIContext.js

@@ -39,9 +39,14 @@ function fieldsForSnippet(type, c) {
   return []
 }
 
-async function publishExchange({ q, a, ctx = [], tokens = {} }) {
+async function publishExchange({ q, a, ctx = [], tokens = {}, lang = '', tags = [], rating = 0 }) {
   const s = await openSsb()
   if (!s) return null
+  const safeLang = String(lang || '').trim().slice(0, 8).toLowerCase()
+  const safeTags = Array.isArray(tags)
+    ? Array.from(new Set(tags.map(t => String(t || '').trim().slice(0, 32)).filter(Boolean))).slice(0, 10)
+    : []
+  const safeRating = Math.max(0, Math.min(5, Math.round(Number(rating) || 0)))
   const content = {
     type: 'aiExchange',
     question: clip(String(q || ''), 2000),
@@ -49,6 +54,23 @@ async function publishExchange({ q, a, ctx = [], tokens = {} }) {
     ctx: ctx.slice(0, 12).map(x => clip(String(x || ''), 800)),
     timestamp: Date.now()
   }
+  if (safeLang) content.lang = safeLang
+  if (safeTags.length) content.tags = safeTags
+  if (safeRating > 0) content.rating = safeRating
+  return new Promise((resolve, reject) => {
+    s.publish(content, (err, res) => err ? reject(err) : resolve(res))
+  })
+}
+
+async function publishExchangeVote({ targetId, helpful = true }) {
+  const s = await openSsb()
+  if (!s || !targetId) return null
+  const content = {
+    type: 'aiExchangeVote',
+    target: String(targetId),
+    helpful: !!helpful,
+    timestamp: Date.now()
+  }
   return new Promise((resolve, reject) => {
     s.publish(content, (err, res) => err ? reject(err) : resolve(res))
   })
@@ -101,4 +123,4 @@ async function getBestTrainedAnswer(question) {
   })
 }
 
-module.exports = { fieldsForSnippet, buildContext, clip, publishExchange, getBestTrainedAnswer }
+module.exports = { fieldsForSnippet, buildContext, clip, publishExchange, publishExchangeVote, getBestTrainedAnswer }

+ 36 - 11
src/AI/routes_index.js

@@ -14,13 +14,29 @@ const ROUTES = [
   { path: '/inhabitants?filter=TOP%20ACTIVITY', mod: 'inhabitantsMod', description: 'top activity, most recently active inhabitants, fresh users, recently online' },
   { path: '/inhabitants?filter=contacts', mod: 'inhabitantsMod', description: 'my contacts, who I follow, my network, friends list, mutuals' },
   { path: '/inhabitants?filter=GALLERY', mod: 'inhabitantsMod', description: 'gallery of inhabitants, all avatars, visual list, photos' },
-  { path: '/tribes',        mod: 'tribeMod',   description: 'tribes, groups, communities, private rooms, sub-tribes, governance' },
+  { path: '/tribes',        mod: 'tribeMod',   description: 'tribes, groups, communities, private rooms, sub-tribes, governance, public tribes, browse all visible tribes' },
+  { path: '/tribes?filter=mine', mod: 'tribeMod', description: 'my tribes, tribes I created, tribes I authored, my groups' },
+  { path: '/tribes?filter=membership', mod: 'tribeMod', description: 'tribes I am a member of, my memberships, joined tribes, where I belong' },
+  { path: '/larp',          mod: 'larpMod',    description: 'larp, role playing, nine houses, academia, solaris, arrakis, terraverde, unsystem, dogma, helix, quark, hermandad, governance cycle, ruling house, will test' },
+  { path: '/larp?filter=houses', mod: 'larpMod', description: 'list of houses, all houses, browse houses, every house, choose a house, house directory, house grid' },
+  { path: '/larp?filter=rules',  mod: 'larpMod', description: 'larp faq, rules, governance wall, starting house, will test, invitation code, larp emblem' },
+  { path: '/larp/academia',    mod: 'larpMod', description: 'ACADEMIA house, aca, education, teachers, coordinators, newcomers, talent discovery, selection process, nosce te ipsum, take will test, leave house, return to academia' },
+  { path: '/larp/solaris',     mod: 'larpMod', description: 'SolarIS house, sol, governance, law, diplomacy, politics, rulers, lawyers, diplomats, communication, mediation' },
+  { path: '/larp/arrakis',     mod: 'larpMod', description: 'ARRakis house, arr, engineers, scientists, technicians, technology, invention, problem solving, building, prototypes, repairs, automation' },
+  { path: '/larp/terraverde',  mod: 'larpMod', description: 'TERRA.VErDE house, ter, farmers, ecologists, doctors, nutritionists, healthcare, food, nature, climate, biosphere, regeneration' },
+  { path: '/larp/unsystem',    mod: 'larpMod', description: 'UNSYSTem house, uns, chaos agents, trolls, punks, sabotage, tactical chaos, provocation, anti-system' },
+  { path: '/larp/dogma',       mod: 'larpMod', description: 'DogmA house, dog, thinkers, journalists, philosophers, information control, knowledge, archive, narratives, ai, memory' },
+  { path: '/larp/helix',       mod: 'larpMod', description: 'HeliX house, hlx, clowns, influencers, musicians, priests, entertainment, culture, humor, celebration, ritual, joy, festival' },
+  { path: '/larp/quark',       mod: 'larpMod', description: 'QuarK house, quk, athletes, soldiers, street people, protection, security, defense, survival, family, mutual aid' },
+  { path: '/larp/hermandad',   mod: 'larpMod', description: 'HERmanDAD house, hrm, architects, builders, investors, industrialists, construction, development, sustainability, logistics, production, supply chain' },
+  { path: '/larp/test',        mod: 'larpMod', description: 'will test, psychological test, take house test, assigned to house, find my house, profile questions' },
   { path: '/chats',         mod: 'chatMod',    description: 'chats, messaging, encrypted rooms, group conversations' },
   { path: '/pads',          mod: 'padMod',     description: 'pads, collaborative editor, shared notes, encrypted documents' },
   { path: '/calendars',     mod: 'calendarMod', description: 'calendar, events by date, schedule, reminders, recurring dates' },
   { path: '/maps',          mod: 'mapMod',     description: 'maps, locations, markers, geography, places' },
   { path: '/events',        mod: 'eventMod',   description: 'events, agenda, meetups, gatherings, RSVP' },
-  { path: '/agenda',        mod: 'agendaMod',  description: 'agenda, scheduled items, upcoming, my dates' },
+  { path: '/agenda',        mod: 'agendaMod',  description: 'agenda, scheduled items, upcoming, my dates, my tasks events transfers projects jobs market reports tribes' },
+  { path: '/agenda?filter=discarded', mod: 'agendaMod', description: 'discarded agenda items, archived agenda, removed from agenda, hidden tasks' },
   { path: '/tasks',         mod: 'taskMod',    description: 'tasks, todo, assignments, work items, priorities' },
   { path: '/projects',      mod: 'projectMod', description: 'projects, milestones, backers, crowdfunding, bounties' },
   { path: '/jobs',          mod: 'jobMod',     description: 'jobs, work, hiring, salaries, vacancies, applications' },
@@ -33,10 +49,15 @@ const ROUTES = [
   { path: '/courts',        mod: 'courtsMod',  description: 'courts, judges, accusations, mediators, justice, disputes' },
   { path: '/votations',     mod: 'votationsMod', description: 'votations, polls, surveys, multi-option votes' },
   { path: '/votes',         mod: 'votesMod',   description: 'votes, ballots, decisions, polling, voting' },
-  { path: '/opinions',      mod: 'opinionsMod', description: 'opinions, reactions, ratings, sentiment, expressing views' },
-  { path: '/trending',      mod: 'trendingMod', description: 'trending, popular, hot, top voted, what is being discussed' },
+  { path: '/opinions',      mod: 'opinionsMod', description: 'opinions, reactions, ratings, sentiment, expressing views, interesting useful funny boring sad joyful angry confused inspiring frustrating curious sympathetic challenged surprised exited categories' },
+  { path: '/trending',      mod: 'trendingMod', description: 'trending, popular, hot, top voted, what is being discussed, most opinions, top spread, viral content' },
+  { path: '/activity',      mod: null,         description: 'activity, recent actions, what is happening, my history, others actions, feed votes, opinions activity, spread activity' },
+  { path: '/activity?filter=mine', mod: null, description: 'my activity, my actions, what I did, my history, what I published' },
+  { path: '/activity?filter=today', mod: null, description: 'today activity, last 24h, recent today' },
   { path: '/reports',       mod: 'reportsMod', description: 'reports, bug reports, abuse, incidents, severity, confirmations' },
   { path: '/audios',        mod: 'audioMod',   description: 'audios, music, podcasts, voice recordings, sound files' },
+  { path: '/audios?filter=bcs', mod: 'audioMod', description: 'BCS audios, blockchain sonification audios, melody compositions published by inhabitants, transcode source audio, embedded steganography' },
+  { path: '/audios?filter=favorites', mod: 'audioMod', description: 'favorite audios, starred audios, saved music' },
   { path: '/videos',        mod: 'videoMod',   description: 'videos, films, clips, recordings, watch' },
   { path: '/images',        mod: 'imageMod',   description: 'images, photos, pictures, gallery, memes' },
   { path: '/documents',     mod: 'documentMod', description: 'documents, PDFs, files, papers, references' },
@@ -44,8 +65,10 @@ const ROUTES = [
   { path: '/torrents',      mod: 'torrentMod', description: 'torrents, magnet links, file sharing, downloads' },
   { path: '/tags',          mod: 'tagsMod',    description: 'tags, hashtags, topics, categories, labels' },
   { path: '/search',        mod: null,         description: 'search, find, query, lookup' },
-  { path: '/inbox',         mod: null,         description: 'inbox, notifications, mentions, alerts, messages addressed to me' },
-  { path: '/pm',            mod: 'privateMessageMod', description: 'private messages, direct messages, DMs, encrypted PM' },
+  { path: '/inbox',         mod: null,         description: 'inbox, notifications, mentions, alerts, messages addressed to me, received PM' },
+  { path: '/inbox?filter=sent', mod: null,     description: 'sent messages, outgoing PM, my sent private messages, what I wrote' },
+  { path: '/inbox?filter=reminders', mod: null, description: 'reminders, task reminders, calendar reminders, automatic notifications' },
+  { path: '/pm',            mod: 'privateMessageMod', description: 'private messages, direct messages, DMs, encrypted PM, compose new PM' },
   { path: '/publish',       mod: null,         description: 'publish, write, create post, new entry, compose' },
   { path: '/games',         mod: 'gameMod',    description: 'games, play, mini-games, scoring, fun' },
   { path: '/pixelia',       mod: 'pixeliaMod', description: 'pixelia, pixel canvas, draw, collaborative pixel art' },
@@ -55,15 +78,17 @@ const ROUTES = [
   { path: '/stats',         mod: 'statsMod',   description: 'stats, statistics, KPIs, metrics, dashboard, carbon footprint' },
   { path: '/blockchain',    mod: 'blockchainMod', description: 'blockchain, blocks, explorer, ledger, chain' },
   { path: '/peers',         mod: 'peersMod',   description: 'peers, connections, network, nodes, who am I connected to, LAN, refresh discovery, export peer list, import peer list, remove idle' },
-  { path: '/invites',       mod: 'invitesMod', description: 'invites, pub invitations, join code, follow PUB, federations, federated networks, import pubs, export pubs, unreachable pubs' },
+  { path: '/invites',       mod: 'invitesMod', description: 'invites, pub invitations, join code, follow PUB, federations, federated networks, import pubs, export pubs, unreachable pubs, redeem tribe invite code, join tribe by code, accept invitation' },
   { path: '/graphos',       mod: 'graphosMod', description: 'graphos, network map, visualization, relationship graph' },
   { path: '/modules',       mod: null,         description: 'modules, features, enable disable plugins, settings' },
   { path: '/settings',      mod: null,         description: 'settings, preferences, language, theme, configuration' },
-  { path: '/favorites',     mod: 'favoritesMod', description: 'favorites, starred items, saved content' },
+  { path: '/favorites',     mod: 'favoritesMod', description: 'favorites, starred items, saved content, my bookmarks audios videos images documents pads chats calendars maps torrents' },
+  { path: '/favorites?filter=recent', mod: 'favoritesMod', description: 'recent favorites, latest starred items' },
   { path: '/logs',          mod: 'logsMod',    description: 'logs, life log, personal records, journal, experiences' },
-  { path: '/melody',        mod: 'melodyMod',  description: 'melody, sound of my blockchain, music, generate sound, audio of blocks, sonification' },
-  { path: '/profile',       mod: null,         description: 'my profile, my avatar, my page, my identity, my data' },
-  { path: '/profile/edit',  mod: null,         description: 'edit profile, edit avatar, change name, change description, visibility prefs, sensors, eco tax toggle' },
+  { path: '/melody',        mod: 'melodyMod',  description: 'melody, sound of my blockchain, music, generate sound, audio of blocks, sonification, publish BCS, hidden message, steganography, embed text in waveform' },
+  { path: '/melody?filter=all', mod: 'melodyMod', description: 'BCS from other inhabitants, all blockchain compositions, listen to peers melodies, transcode audio of others' },
+  { path: '/profile',       mod: null,         description: 'my profile, my avatar, my page, my identity, my data, my clearnet link' },
+  { path: '/profile/edit',  mod: null,         description: 'edit profile, edit avatar, change name, change description, visibility prefs, sensors, eco tax toggle, clearnet toggle, GPG fingerprint' },
   { path: '/blockexplorer', mod: 'blockchainMod', description: 'blockexplorer, blockchain explorer, blocks, ledger, carbon footprint per block, chain history' },
   { path: '/stats?filter=ALL',  mod: 'statsMod', description: 'global stats, network kpis, total carbon footprint, total inhabitants, network size' },
   { path: '/stats?filter=MINE', mod: 'statsMod', description: 'my stats, my carbon footprint, my activity numbers, personal kpis' },

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 875 - 100
src/backend/backend.js


+ 92 - 0
src/client/assets/larp/houses.json

@@ -0,0 +1,92 @@
+{
+  "academia": {
+    "name": "ACADEMIA",
+    "short": "aca",
+    "motto": "Nosce te ipsum…",
+    "roles": "Educators, teachers, coordinators",
+    "function": "Education, understanding, talent discovery, selection process",
+    "description": "All inhabitants start here. ACADEMIA receives newcomers, provides information on the simulation mechanics, presents each house's objectives and runs the selection process.",
+    "month": 1,
+    "image": "/assets/larp/images/academia.jpg"
+  },
+  "solaris": {
+    "name": "SolarIS",
+    "short": "sol",
+    "motto": "The word makes us human. Violence, beasts.",
+    "roles": "Rulers, lawyers, diplomats",
+    "function": "Governance, law, dialogue, politics, communication",
+    "description": "Holds the political voice. Mediates disputes, drafts norms, builds coalitions through dialogue and persuasion.",
+    "month": 2,
+    "image": "/assets/larp/images/solaris.jpg"
+  },
+  "arrakis": {
+    "name": "ARRakis",
+    "short": "arr",
+    "motto": "How long do you say that it must be resolved?",
+    "roles": "Engineers, scientists, technicians",
+    "function": "Technology, invention, problem-solving, building and maintaining",
+    "description": "The makers. They keep the machinery running, prototype, repair, automate; favour pragmatic solutions over endless debate.",
+    "month": 3,
+    "image": "/assets/larp/images/arrakis.jpg"
+  },
+  "terraverde": {
+    "name": "TERRA.VErDE",
+    "short": "ter",
+    "motto": "Love nature, universe and being, because we are all one.",
+    "roles": "Farmers, ecologists, doctors, nutritionists",
+    "function": "Healthcare, food, nature, resources, climate management",
+    "description": "Stewards of the living systems. Manage food, health and the relationship with the biosphere; long timescales, regenerative principles.",
+    "month": 4,
+    "image": "/assets/larp/images/terraverde.jpg"
+  },
+  "unsystem": {
+    "name": "UNSYSTem",
+    "short": "uns",
+    "motto": "We do not make statements!",
+    "roles": "Chaos agents, trolls, punks",
+    "function": "Organization of disorder, tactical chaos",
+    "description": "Holds the right to question, sabotage, provoke. Resists capture by any other house and keeps the system uncomfortable.",
+    "month": 5,
+    "image": "/assets/larp/images/unsystem.jpg"
+  },
+  "dogma": {
+    "name": "DogmA",
+    "short": "dog",
+    "motto": "The question is not when to do it, the question is how.",
+    "roles": "Thinkers, journalists, philosophers",
+    "function": "Information control, knowledge, philosophy, memory, AI",
+    "description": "The archive and the discourse. Curates knowledge, drafts narratives, governs how information is produced and propagated.",
+    "month": 6,
+    "image": "/assets/larp/images/dogma.jpg"
+  },
+  "helix": {
+    "name": "HeliX",
+    "short": "hlx",
+    "motto": "If you do not smile, it's not my revolution.",
+    "roles": "Clowns, influencers, musicians, priests",
+    "function": "Entertainment, culture, humor, celebration, human relationships",
+    "description": "Carries culture, ritual and joy. Holds the social fabric together through art, festival and shared emotion.",
+    "month": 7,
+    "image": "/assets/larp/images/helix.jpg"
+  },
+  "quark": {
+    "name": "QuarK",
+    "short": "quk",
+    "motto": "Without security, there is no freedom!",
+    "roles": "Athletes, soldiers, street people",
+    "function": "Protection, security, defense, survival, family",
+    "description": "The guard. Trains, defends, organises mutual aid; demands clear loyalties and clear lines.",
+    "month": 8,
+    "image": "/assets/larp/images/quark.jpg"
+  },
+  "hermandad": {
+    "name": "HERmanDAD",
+    "short": "hrm",
+    "motto": "Grow. Build. Expand!",
+    "roles": "Architects, builders, investors, industrialists",
+    "function": "Construction, development, sustainability, logistics, production",
+    "description": "The builders. Plan large works, organise production lines and supply chains; measure success in things shipped and capacity created.",
+    "month": 9,
+    "image": "/assets/larp/images/hermandad.jpg"
+  }
+}

binární
src/client/assets/larp/images/academia.jpg


binární
src/client/assets/larp/images/arrakis.jpg


binární
src/client/assets/larp/images/dogma.jpg


binární
src/client/assets/larp/images/helix.jpg


binární
src/client/assets/larp/images/hermandad.jpg


binární
src/client/assets/larp/images/quark.jpg


binární
src/client/assets/larp/images/solaris.jpg


binární
src/client/assets/larp/images/terraverde.jpg


binární
src/client/assets/larp/images/unsystem.jpg


+ 354 - 19
src/client/assets/styles/style.css

@@ -289,8 +289,9 @@ nav ul li a:hover {
   max-height: 0;
   overflow: hidden;
   transition: max-height 0.25s ease-out;
-  padding-left: 0;
-  margin: 0 0 0 0.75rem;
+  padding: 0;
+  margin: 0 0.25rem;
+  box-sizing: border-box;
 }
 
 .oasis-nav-toggle:checked + .oasis-nav-header + .oasis-nav-list {
@@ -311,11 +312,12 @@ nav ul li a:hover {
   display: flex;
   align-items: center;
   gap: 0.5rem;
-  padding: 0.35rem 1.25rem 0.35rem 1.5rem;
+  padding: 0.35rem 0.75rem;
   font-size: 0.85rem;
   text-decoration: none;
   opacity: 0.85;
   transition: opacity 0.15s ease;
+  box-sizing: border-box;
 }
 
 .oasis-nav-list li a:hover {
@@ -507,6 +509,36 @@ nav ul li a:hover {
   justify-content: center;
   min-width: 0;
 }
+.top-bar-center-ainav .ai-ask-form { max-width: 100%; }
+.top-bar-center-ainav .ai-ask-input { height: 44px; font-size: 1rem; padding: 0 1rem; }
+.top-bar-center-ainav .ai-ask-btn { height: 44px; font-size: 1.1rem; padding: 0 1.1rem; }
+.ainav-only.main-content { min-height: 60vh; }
+.ainav-landing,
+.ainav-landing .ainav-landing-topbar,
+.ainav-landing .ainav-landing-center,
+.ainav-landing .ainav-landing-myid,
+.ainav-landing .top-bar-left,
+.ainav-landing .top-bar-right,
+.ainav-landing nav,
+.ainav-landing ul,
+.ainav-landing li { background: transparent !important; border: 0 !important; box-shadow: none !important; }
+.ainav-landing { display: flex; flex-direction: column; align-items: stretch; min-height: 100vh; padding: 0 24px 24px; box-sizing: border-box; }
+.ainav-landing-topbar { width: 100%; display: flex; justify-content: space-between; align-items: center; padding: 12px 0; gap: 16px; }
+.ainav-landing-topbar .top-bar-left, .ainav-landing-topbar .top-bar-right { display: flex; align-items: center; gap: 10px; }
+.ainav-landing-topbar nav ul { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; margin: 0; padding: 0; }
+.ainav-landing-topbar nav ul li { display: inline-flex; }
+.ainav-landing-center { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; padding-top: 15vh; gap: 20px; }
+.ainav-landing-myid { text-align: center; word-break: break-all; margin: -8px 0 -4px !important; padding: 0 !important; box-shadow: none !important; line-height: 1.2 !important; font-size: 0.85rem; }
+.ainav-landing-topbar nav ul li a,
+.header.ainav-only nav ul li a { background: transparent !important; border: 0 !important; box-shadow: none !important; }
+.ainav-landing-topbar nav ul li a:hover,
+.header.ainav-only nav ul li a:hover { background: transparent !important; color: inherit !important; box-shadow: none !important; filter: none !important; text-decoration: underline !important; }
+.ainav-landing-tags { width: 100%; max-width: 720px; display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; border: 0 !important; background: transparent !important; padding: 0; margin: 0; box-sizing: border-box; }
+.ainav-landing-logo { display: inline-flex; }
+.ainav-landing-logo img { width: 96px; height: 96px; border-radius: 50%; display: block; }
+.ainav-landing-form { display: flex; align-items: center; gap: 10px; width: 100%; max-width: 720px; }
+.ainav-landing-input { flex: 1; height: 56px; font-size: 1.1rem; padding: 0 1.2rem; border-radius: 6px; }
+.ainav-landing-btn { height: 56px; font-size: 1.2rem; padding: 0 1.4rem; border-radius: 6px; }
 
 .top-bar-right {
   justify-content: flex-end;
@@ -1186,10 +1218,14 @@ button.create-button:hover {
   flex: 0 0 300px;
 }
 
-.inhabitant-left a {
+.inhabitant-left > a:first-of-type {
   display: block;
   width: 100%;
 }
+.inhabitant-left .profile-sensors-box a {
+  display: inline;
+  width: auto;
+}
 
 .inhabitant-left h2 {
   margin: 4px 0 0;
@@ -1702,9 +1738,18 @@ width:100%; max-height:200px; object-fit:cover; border-radius:6px; transition:tr
 transform:scale(1.05);
  }
  
-.tribe-detail-image { 
+.tribe-detail-image {
 width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0; border-radius:6px;
  }
+.report-detail-image {
+  display: block;
+  max-width: 100%;
+  width: auto;
+  height: auto;
+  margin: 16px 0;
+  border-radius: 6px;
+  object-fit: contain;
+}
 
 .activity-image-preview {
   width: 400px;
@@ -2680,6 +2725,15 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
   font-weight: bold;
   text-decoration: underline dotted;
 }
+.card-section .user-link,
+.card-field .user-link {
+  display: inline-block;
+  max-width: 220px;
+  vertical-align: bottom;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
 .mode-buttons-row {
   display: flex;
   flex-direction: row;
@@ -3423,11 +3477,65 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .melody-help{color:#888;font-size:13px;margin:0 0 12px 0}
 .melody-notes-grid{display:flex;flex-wrap:wrap;gap:6px}
 .melody-note-chip{display:inline-flex;flex-direction:column;align-items:center;border:1px solid #333;border-radius:6px;padding:6px 10px;background:#161616;min-width:54px}
+.melody-note-chip-link{text-decoration:none;transition:border-color 0.15s,background 0.15s}
+.melody-note-chip-link:hover{border-color:#FFA500;background:#1f1500}
+.melody-note-chip-unavailable{opacity:0.45;cursor:not-allowed;border-style:dashed}
+.eco-tax-chip{display:inline-flex;align-items:baseline;gap:4px;font-size:12px;border:1px solid;border-radius:10px;padding:2px 8px;text-decoration:none}
+.eco-tax-chip-label{text-transform:uppercase;letter-spacing:.5px;font-weight:600}
+.eco-tax-chip-value{font-family:monospace}
+.eco-tax-chip-low{background:#0e2a16;border-color:#3a7f4c;color:#9aff9a}
+.eco-tax-chip-low .eco-tax-chip-label{color:#bfeac0}
+.eco-tax-chip-low .eco-tax-chip-value{color:#9aff9a}
+.eco-tax-chip-low:hover{background:#143820;border-color:#5aa066}
+.eco-tax-chip-mid{background:#2a2410;border-color:#a39134;color:#ffd95a}
+.eco-tax-chip-mid .eco-tax-chip-label{color:#ffe9a0}
+.eco-tax-chip-mid .eco-tax-chip-value{color:#ffd95a}
+.eco-tax-chip-mid:hover{background:#3a3416;border-color:#c5b14e}
+.eco-tax-chip-high{background:#3a0e0e;border-color:#b04040;color:#ff8a8a}
+.eco-tax-chip-high .eco-tax-chip-label{color:#ffbdbd}
+.eco-tax-chip-high .eco-tax-chip-value{color:#ff8a8a}
+.eco-tax-chip-high:hover{background:#511414;border-color:#e06060}
+.bank-taxes-example-chips{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:8px 0 12px 0}
+.bank-taxes-example-label{color:#bbb;font-size:13px;font-weight:600}
+.bank-taxes-summary-note{color:#bbb;font-size:13px;line-height:1.5;margin:0 0 10px 0}
+.bank-taxes-types-form{display:flex;flex-wrap:wrap;align-items:center;gap:14px;margin:0 0 10px 0;background:transparent !important;border:0 !important;padding:0 !important}
+.bank-taxes-types-label{color:#FFD700;font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap}
+.bank-taxes-type-toggle{color:#FFD700;font-size:13px;font-weight:600;display:inline-flex;flex-direction:row;align-items:center;gap:8px;cursor:pointer;white-space:nowrap}
+.bank-taxes-type-toggle input[type=checkbox]{accent-color:#FFA500;width:16px;height:16px;flex:none;margin:0}
+.bank-taxes-type-toggle-locked{cursor:not-allowed;opacity:0.85}
+.bank-taxes-type-toggle-locked input[type=checkbox]{cursor:not-allowed}
 .melody-note-name{color:#FFA500;font-family:monospace;font-size:13px;font-weight:600}
 .melody-note-type{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.5px}
 .melody-composition{margin:16px 0}
 .melody-stats{margin:16px 0}
 .melody-regen-form{margin-top:10px}
+.melody-upload-block{margin-top:18px;background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:14px 16px}
+.melody-upload-hint{color:#bbb;font-size:13px;line-height:1.5;margin:0 0 12px 0}
+.melody-upload-name-note{color:#FFD700;font-size:13px;line-height:1.5;margin:0 0 12px 0}
+.melody-upload-name-value{color:#FFD700;font-family:monospace;font-weight:700;word-break:break-all}
+.melody-upload-form{background:transparent !important;border:0 !important;padding:0 !important;display:flex;flex-direction:column;gap:6px}
+.melody-upload-form textarea,.melody-upload-form input[type=text]{width:100%;max-width:560px;font-family:inherit}
+.melody-upload-submit{width:auto;display:inline-block;padding:6px 18px;align-self:flex-start}
+.melody-upload-label{color:#FFD700;font-size:13px;font-weight:600;display:inline-block;margin-top:6px}
+.melody-bcs-list{display:flex;flex-direction:column;gap:14px;margin-top:14px;background:transparent !important;border:0 !important;padding:0 !important}
+.audio-container{display:flex;gap:8px;align-items:center;flex-wrap:wrap;background:transparent !important;border:0 !important;padding:0 !important}
+.audio-transcode-form{display:inline-block;background:transparent !important;border:0 !important;padding:0 !important;margin:0}
+.melody-bcs-card{background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:12px 16px}
+.melody-bcs-head{display:flex;flex-direction:column;gap:4px;background:transparent !important;border:0 !important;padding:0 !important}
+.melody-bcs-title{color:#FFA500;margin:0;font-size:17px}
+.melody-bcs-meta{color:#bbb;font-size:13px;background:transparent !important;border:0 !important;padding:0 !important}
+.melody-bcs-player{margin:10px 0;background:transparent !important;border:0 !important;padding:0 !important}
+.melody-bcs-desc{color:#ccc;font-size:13px;margin:6px 0 0 0;line-height:1.5}
+.melody-bcs-actions{margin-top:10px;background:transparent !important;border:0 !important;padding:0 !important}
+.transcode-meta{margin:8px 0 0 0;color:#bbb;font-size:13px}
+.transcode-stego{margin-top:14px;background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:10px 14px}
+.transcode-stego-fields{display:flex;flex-direction:column;gap:6px;background:transparent !important;border:0 !important;padding:0 !important}
+.transcode-stego-field{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px;background:transparent !important;border:0 !important;padding:0 !important;font-size:13px}
+.transcode-stego-field .card-label{color:#FFD700;text-transform:uppercase;letter-spacing:.5px}
+.transcode-stego-field .card-value{color:#ddd;font-family:monospace}
+.transcode-stego-msg{flex-direction:column;align-items:flex-start;gap:4px}
+.transcode-stego-text{color:#FFD700;font-family:monospace;line-height:1.5;margin:0;white-space:pre-wrap;word-break:break-word;background:#0e0e0e;border:1px solid #333;border-radius:4px;padding:8px 10px;width:100%}
+.transcode-composition{margin-top:14px}
 
 .pub-item{border:1;border-radius:10px;background:none}
 .snh-invite-box{border:1px solid currentColor;border-radius:8px;padding:16px;margin:12px 0;font-family:monospace}
@@ -3766,12 +3874,21 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
   display: flex;
   gap: 8px;
   flex-wrap: wrap;
+  align-items: center;
   margin: 8px 0;
 }
 
 .tribe-side-actions form {
   margin: 0;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
 }
+.tribe-side-actions form > * { margin: 0 }
+.tribe-side-actions select,
+.tribe-side-actions input[type="number"],
+.tribe-side-actions input[type="text"] { height: 36px; box-sizing: border-box }
+.tribe-side-actions button { height: 36px; box-sizing: border-box; display: inline-flex; align-items: center }
 
 .tribe-side-subtribes {
   margin: 8px 0;
@@ -3814,6 +3931,8 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .tribe-card-image-wrapper {
   position: relative;
   overflow: hidden;
+  padding: 0 !important;
+  margin: 0 !important;
 }
 
 .tribe-card-hero-image {
@@ -5300,9 +5419,133 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .peers-technical-block{margin-top:16px}
 .peers-conn-actions{margin-bottom:12px}
 .invites-pubs-actions{margin:12px 0}
-.suggested-meta{margin-top:8px;display:flex;flex-direction:column;gap:4px}
-.inbox-exposition{margin:12px 0 4px 0}
-.inbox-filters-label{color:#FFDD44;font-size:13px;font-weight:600;margin-right:8px;align-self:center}
+.larp-cycle-banner{display:flex;flex-wrap:wrap;align-items:center;gap:8px;background:#1A1A1A;border:1px solid #FFA500;border-radius:6px;padding:8px 14px;margin:12px 0;font-family:monospace}
+.larp-post-submit{width:auto;display:inline-block;padding:6px 18px;align-self:flex-start}
+.larp-cycle-label,.larp-governing-label{color:#FFD700;font-size:12px;text-transform:uppercase;letter-spacing:1px}
+.larp-cycle-value{color:#FFA500;font-weight:700;font-size:14px}
+.larp-cycle-sep{color:#555}
+.larp-governing-house{color:#FFD700;font-weight:700;text-decoration:underline}
+.larp-my-house{display:flex;align-items:center;gap:8px;margin:8px 0 16px 0;background:transparent !important;border:0 !important;padding:0 !important}
+.larp-my-house-label{color:#FFD700;font-size:12px;text-transform:uppercase;letter-spacing:1px}
+.larp-my-house-link{color:#FFA500;font-weight:700}
+.larp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px;margin-top:12px}
+.larp-card{background:#1A1A1A;border:1px solid #333;border-radius:8px;overflow:hidden;display:flex;flex-direction:column}
+.larp-card-mine{border:3px solid #FFA500}
+.larp-card-ruling{border:3px solid #ff6b6b}
+.larp-card-image-link{display:block}
+.larp-card-image{width:100%;height:160px;object-fit:cover;display:block;background:#0a0a0a}
+.larp-card-body{padding:10px 12px;display:flex;flex-direction:column;gap:4px;background:transparent !important;border:0 !important}
+.larp-card-title-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;background:transparent !important;border:0 !important;padding:0 !important}
+.larp-card-title{color:#FFA500;font-weight:700;font-size:16px;letter-spacing:0.5px}
+.larp-card-motto{color:#FFDD44;font-style:italic;font-size:13px;margin:0}
+.larp-card-roles{color:#bbb;font-size:12px;margin:0}
+.larp-card-count{color:#888;font-size:12px;margin:0}
+.larp-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px}
+.larp-badge-mine{background:#0e3320;color:#7fff7f}
+.larp-badge-ruling{background:#3a0000;color:#ff6b6b}
+.larp-detail-header{display:flex;flex-wrap:wrap;align-items:center;gap:10px;background:transparent !important;border:0 !important;padding:0 !important}
+.larp-detail-motto{color:#FFDD44;font-style:italic;font-size:15px;margin:6px 0 0 0}
+.larp-detail{display:flex;flex-wrap:wrap;gap:18px;margin-top:14px}
+.larp-detail-image-col{flex:0 0 240px}
+.larp-detail-image{width:100%;height:auto;border-radius:8px;border:2px solid #FFA500;display:block}
+.larp-detail-body{flex:1 1 320px;display:flex;flex-direction:column;gap:10px;background:transparent !important;border:0 !important;padding:0 !important}
+.larp-info-table{width:100%;border-collapse:collapse}
+.larp-info-table td{padding:4px 8px;border-bottom:1px solid #2a2a2a;vertical-align:top}
+.larp-detail-description{color:#ccc;line-height:1.5;margin:0}
+.larp-actions{margin-top:10px;background:transparent !important;border:0 !important;padding:0 !important;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
+.larp-members-block{margin-top:24px}
+.larp-members-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:6px;background:transparent !important;border:0 !important;padding:0 !important}
+.larp-member-row{padding:4px 8px;background:#1A1A1A;border:1px solid #333;border-radius:4px}
+.larp-back{margin-right:6px}
+.larp-house-badges{display:flex;flex-wrap:wrap;gap:14px;margin:8px 0 12px 0;background:transparent !important;border:0 !important;padding:0 !important}
+.larp-house-nav{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:10px 0;background:transparent !important;border:0 !important;padding:0 !important}
+.larp-house-nav-label{color:#FFD700;font-size:12px;text-transform:uppercase;letter-spacing:1px;margin-right:6px}
+.larp-house-nav-chip{display:inline-block;padding:4px 10px;border:1px solid #555;border-radius:14px;color:#ccc;font-size:12px;font-weight:600;text-decoration:none;background:#1A1A1A}
+.larp-house-nav-chip:hover{border-color:#FFA500;color:#FFA500}
+.larp-house-nav-chip.active{background:#FFA500;border-color:#FFA500;color:#000}
+.larp-detail-name{color:#FFA500;font-size:22px;margin:0 0 4px 0}
+.larp-posts-block{margin-top:24px}
+.larp-post-form{margin:12px 0;background:transparent !important;border:0 !important;padding:0 !important;display:flex;flex-direction:column;gap:6px}
+.larp-post-form textarea{width:100%;max-width:720px;font-family:inherit}
+.larp-post-label{color:#FFD700;font-size:13px;font-weight:600}
+.larp-post-public-label{color:#bbb;font-size:13px;display:inline-flex;align-items:center;gap:6px}
+.larp-posts-list{display:flex;flex-direction:column;gap:10px;background:transparent !important;border:0 !important;padding:0 !important}
+.larp-post{background:#1A1A1A;border:1px solid #2a2a2a;border-radius:6px;padding:10px 14px}
+.larp-post-private{border-left:3px solid #FFA500}
+.larp-post-head{display:flex;flex-wrap:wrap;align-items:center;gap:10px;font-size:13px;background:transparent !important;border:0 !important;padding:0 !important;margin-bottom:6px}
+.larp-post-time{color:#888;font-size:12px}
+.larp-post-badge{display:inline-block;padding:2px 8px;border-radius:10px;background:#3a2300;color:#FFD700;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px}
+.larp-post-text{color:#ddd;line-height:1.5;margin:0;white-space:pre-wrap;word-break:break-word}
+.larp-rules{margin-top:14px;display:flex;flex-direction:column;gap:18px;background:transparent !important;border:0 !important;padding:0 !important}
+.larp-rules-section{background:#1A1A1A;border:1px solid #2a2a2a;border-radius:6px;padding:12px 16px}
+.larp-rules-section h3{color:#FFA500;margin:0 0 6px 0;font-size:15px}
+.larp-rules-section p{color:#ccc;line-height:1.55;margin:6px 0}
+.larp-rules-list{display:flex;flex-direction:column;gap:6px;background:transparent !important;border:0 !important;padding:0 !important}
+.larp-rules-list strong{color:#FFD700}
+.lan-checkbox-label{display:inline-flex;align-items:center;gap:10px;color:#FFD700;font-size:14px;margin:10px 0;background:transparent !important;border:0 !important;padding:0 !important;cursor:pointer}
+.lan-checkbox-input{width:18px;height:18px;margin:0;flex:0 0 auto;cursor:pointer;accent-color:#FFA500}
+.lan-checkbox-text{line-height:1}
+.larp-sign-block{display:block;line-height:0;margin:10px auto;background:transparent !important;border:0 !important;padding:0 !important;width:180px}
+.larp-sign-large{width:180px;height:180px;object-fit:cover;border-radius:8px;border:2px solid #FFA500;display:block}
+.larp-sign-mini-block{display:inline-block;line-height:0;margin:6px 0;background:transparent !important;border:0 !important;padding:0 !important;width:64px}
+.larp-sign-mini{width:64px;height:64px;object-fit:cover;border-radius:50%;border:2px solid #FFA500;display:block}
+.larp-academia-join{margin-top:18px;background:transparent !important;border:0 !important;padding:0 !important}
+.larp-academia-join>h2{color:#FFA500;margin:0 0 8px 0;font-size:18px;text-transform:uppercase;letter-spacing:1px}
+.larp-academia-join-hint{color:#ccc;font-size:13px;margin:0 0 10px 0}
+.larp-test-cooldown-banner{color:#FFD700;background:#3a2300;border:1px solid #FFA500;border-radius:4px;padding:6px 10px;font-size:13px;margin:0 0 10px 0;display:inline-block}
+.larp-last-attempt{margin:8px 0 14px 0;background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:10px 14px}
+.larp-last-attempt-title{color:#FFA500;font-size:15px;margin:0 0 8px 0}
+.larp-last-attempt-pass{color:#7fff7f;font-weight:700}
+.larp-last-attempt-fail{color:#ff8a8a;font-weight:700}
+.larp-test-form-compact{margin:10px 0 0 0;gap:8px}
+.larp-test-form-compact .larp-test-question{padding:8px 12px}
+.larp-test-form-compact .larp-test-q-text{font-size:13px;margin:0 0 6px 0}
+.larp-test-form-compact .larp-test-option{font-size:12px;padding:2px 6px}
+.larp-will-test-hint{color:#bbb;font-size:13px;line-height:1.4;margin:4px 0 10px 0}
+.larp-academia-actions{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-top:12px;background:transparent !important;border:0 !important;padding:0 !important}
+.larp-invite-redeem-form,.larp-invite-create-form,.larp-card-invite-form{display:inline-flex;gap:6px;align-items:center;background:transparent !important;border:0 !important;padding:0 !important;margin:0}
+.larp-invite-redeem-form input[type=text],.larp-card-invite-form input[type=text]{padding:4px 8px;font-family:monospace;font-size:12px;min-width:280px;width:100%;max-width:360px;box-sizing:border-box}
+.larp-card-invite-form{margin-top:8px;flex-wrap:wrap}
+.larp-invite-banner{background:#161616;border:1px solid #FFA500;border-radius:6px;padding:10px 14px;margin:10px 0}
+.larp-invite-banner-title{color:#FFD700;margin:0 0 4px 0;font-size:14px}
+.larp-invite-banner-hint{color:#bbb;font-size:13px;margin:0 0 8px 0}
+.larp-invite-banner-code{color:#9aff9a;font-family:monospace;font-size:15px;letter-spacing:1px;margin:0;word-break:break-all}
+.larp-test-ranking{margin-top:14px;background:transparent !important;border:0 !important;padding:0 !important}
+.larp-test-ranking-title{color:#FFA500;font-size:15px;margin:0 0 8px 0}
+.larp-academia-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;background:transparent !important;border:0 !important;padding:0 !important}
+.larp-academia-thumb{display:flex;flex-direction:column;align-items:center;gap:6px;text-decoration:none;background:#1A1A1A;border:1px solid #333;border-radius:8px;padding:10px;transition:border-color 0.2s}
+.larp-academia-thumb:hover{border-color:#FFA500}
+.larp-academia-thumb-mine{border:3px solid #FFA500}
+.larp-academia-thumb-image{width:100%;max-width:128px;height:128px;object-fit:cover;border-radius:6px;border:1px solid #FFA500;display:block}
+.larp-academia-thumb-name{color:#FFA500;font-weight:700;font-size:14px;text-transform:uppercase;letter-spacing:0.5px;text-align:center}
+.larp-test-header{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start;margin:14px 0;background:transparent !important;border:0 !important;padding:0 !important}
+.larp-test-image{width:180px;height:180px;object-fit:cover;border-radius:8px;border:2px solid #FFA500;display:block;flex:0 0 180px}
+.larp-test-info{flex:1 1 320px;background:transparent !important;border:0 !important;padding:0 !important}
+.larp-test-intro{color:#ccc;line-height:1.5;margin:0 0 8px 0}
+.larp-test-meta{color:#bbb;font-size:13px;margin:0}
+.larp-test-meta strong{color:#FFD700}
+.larp-test-cooldown{background:#1A1A1A;border:1px solid #FFA500;border-radius:6px;padding:14px 18px;margin:14px 0}
+.larp-test-cooldown p{color:#FFD700;margin:0 0 8px 0;font-size:14px}
+.larp-test-form{margin:14px 0;background:transparent !important;border:0 !important;padding:0 !important;display:flex;flex-direction:column;gap:14px}
+.larp-test-question{background:#1A1A1A;border:1px solid #2a2a2a;border-radius:6px;padding:12px 16px}
+.larp-test-q-text{color:#ddd;line-height:1.5;margin:0 0 10px 0;font-size:14px}
+.larp-test-q-text strong{color:#FFA500}
+.larp-test-options{display:flex;flex-direction:column;gap:6px;background:transparent !important;border:0 !important;padding:0 !important;align-items:flex-start}
+.larp-test-option{color:#ccc;font-size:13px;display:inline-flex;flex-direction:row;align-items:center;justify-content:flex-start;gap:8px;cursor:pointer;padding:4px 6px;border-radius:3px;width:auto;max-width:100%}
+.larp-test-option:hover{background:#222}
+.larp-test-option input[type=radio]{accent-color:#FFA500;margin:0;flex:none;width:16px;height:16px}
+.larp-test-result{background:#1A1A1A;border:1px solid #FFA500;border-radius:8px;padding:18px 22px;margin:14px 0}
+.larp-test-result.passed{border-color:#FFA500}
+.larp-test-result.failed{border-color:#FFA500}
+.larp-test-result h2{margin:0 0 8px 0;font-size:18px;color:#FFA500}
+.larp-test-result.passed h2{color:#FFA500}
+.larp-test-result.failed h2{color:#FFA500}
+.larp-test-score{color:#FFD700;font-size:16px;font-weight:700;margin:0 0 4px 0}
+.larp-test-next{color:#bbb;font-size:13px;margin:0 0 12px 0}
+.suggested-meta{margin-top:8px;display:flex;flex-direction:column;gap:4px;background:transparent !important;border:0 !important;padding:0 !important}
+.inbox-exposition{margin:12px 0 4px 0;display:flex;flex-wrap:wrap;align-items:center;gap:8px}
+.inbox-filters-label{color:#FFDD44;font-size:13px;font-weight:600;align-self:center}
+.inbox-vis-toggle{background:transparent !important;border:0 !important;padding:0 !important;display:inline-block}
 .suggested-badge{display:inline-block;background:#FFA500;color:#000;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600;width:fit-content}
 .peers-import-form{margin-top:16px;display:flex;flex-direction:column;gap:8px;align-items:flex-start}
 .peers-import-form textarea{width:100%;max-width:720px;font-family:monospace;font-size:12px}
@@ -5367,16 +5610,18 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .profile-layout .tribe-side,
 .profile-layout .tribe-main{background:transparent}
 .profile-layout-single{display:flex;justify-content:center;padding:24px 0;gap:24px}
-.profile-layout-single .profile-side{width:340px;max-width:100%;flex:0 0 auto}
+.profile-layout-single .profile-side{width:560px;max-width:100%;flex:0 0 auto}
 .profile-side{align-items:center;text-align:center}
 .profile-side-header{display:flex;flex-direction:column;align-items:center;gap:6px;width:100%}
 .profile-side .inhabitant-photo-details{width:160px;height:160px;object-fit:cover;border:2px solid #ff9800;background:transparent}
 .profile-side-name{font-size:20px;margin:0;color:#FFD700;word-break:break-word}
-.profile-side-mention{font-family:monospace;font-size:12px;color:#FFD700;word-break:break-all;margin:6px 0 8px 0;line-height:1.5}
+.profile-side-mention{font-family:monospace;font-size:12px;color:#FFD700;word-break:keep-all;white-space:nowrap;overflow-x:auto;margin:6px 0 8px 0;line-height:1.5}
 .profile-side-mention a{color:#FFD700;text-decoration:none}
 .profile-side-mention strong{color:#FFD700;font-weight:700}
-.profile-side-description{color:#ddd;font-size:13px;line-height:1.5;margin:0 0 10px 0;word-break:break-word;background:transparent;padding:0;border:none}
+.profile-side-description{color:#ddd;font-size:13px;line-height:1.5;margin:0 0 10px 0;word-break:break-word;background:transparent;padding:0;border:none;text-align:left}
 .profile-side-description p{margin:0 0 6px 0}
+.profile-side-description ul,.profile-side-description ol{margin:6px 0;padding-left:20px}
+.profile-side-description li{margin:2px 0}
 .profile-side-qr{width:160px;height:160px;background:#fff;padding:6px;display:block;margin:0 auto}
 .profile-side-relationship{margin:8px 0 0 0}
 .profile-side-relationship .status{display:inline-block;margin:0 2px}
@@ -5412,9 +5657,11 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .profile-sensors-box .inhabitant-last-activity,
 .profile-sensors-box .karma-line,
 .profile-sensors-box .ubi-line,
+.profile-sensors-box .gpg-line,
 .inhabitant-left .inhabitant-last-activity,
 .inhabitant-left .karma-line,
-.inhabitant-left .ubi-line{
+.inhabitant-left .ubi-line,
+.inhabitant-left .gpg-line{
   display:flex;
   gap:6px;
   align-items:center;
@@ -5430,21 +5677,32 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .profile-sensors-box .inhabitant-last-activity strong,
 .profile-sensors-box .karma-line strong,
 .profile-sensors-box .ubi-line strong,
+.profile-sensors-box .gpg-line strong,
 .inhabitant-left .inhabitant-last-activity strong,
 .inhabitant-left .karma-line strong,
-.inhabitant-left .ubi-line strong{color:#FFDD44}
+.inhabitant-left .ubi-line strong,
+.inhabitant-left .gpg-line strong{color:#FFDD44}
 .profile-sensors-box .inhabitant-last-activity a,
 .profile-sensors-box .karma-line a,
 .profile-sensors-box .ubi-line a,
+.profile-sensors-box .gpg-line a,
 .inhabitant-left .inhabitant-last-activity a,
 .inhabitant-left .karma-line a,
-.inhabitant-left .ubi-line a{color:#FFDD44;text-decoration:none}
+.inhabitant-left .ubi-line a,
+.inhabitant-left .gpg-line a{color:#FFDD44;text-decoration:none}
+a.chip-link{text-decoration:none;display:inline-block}
+a.chip-link:hover .pm-exposition-chip{filter:brightness(1.2)}
+.gpg-edit-row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:4px}
+.gpg-edit-row .gpg-remove-btn{background:transparent;border:1px solid #FF6B6B;color:#FF6B6B;cursor:pointer;padding:4px 10px;border-radius:4px;font-family:inherit;font-size:0.9rem}
+.gpg-edit-row .gpg-remove-btn:hover{background:#FF6B6B;color:#000}
 .profile-sensors-box .inhabitant-last-activity a:hover,
 .profile-sensors-box .karma-line a:hover,
 .profile-sensors-box .ubi-line a:hover,
+.profile-sensors-box .gpg-line a:hover,
 .inhabitant-left .inhabitant-last-activity a:hover,
 .inhabitant-left .karma-line a:hover,
-.inhabitant-left .ubi-line a:hover{color:#ffa500;text-decoration:underline}
+.inhabitant-left .ubi-line a:hover,
+.inhabitant-left .gpg-line a:hover{color:#ffa500;text-decoration:underline}
 .profile-main{gap:12px}
 .profile-module-section{display:flex;flex-direction:column;gap:12px;margin:0 0 12px 0}
 .tribe-invite-code-input{width:100%;max-width:680px;padding:10px 12px;font-family:monospace;font-size:14px;letter-spacing:1px;background:#1a1a1a;color:#ffd700;border:1px solid #555;border-radius:4px;margin:12px 0}
@@ -5498,13 +5756,50 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .search-submit-row button{padding:8px 18px}
 .pm-exposition{display:flex;border:0;}
 .pm-exposition-label{color:#ffa500;font-weight:600;font-size:13px;text-transform:uppercase;letter-spacing:0.5px}
-.pm-exposition-chip{display:inline-flex;border-radius:14px;font-size:14px;font-weight:600;border:0}
-.pm-exposition-whole{background:#3a2300;color:#ffd700}
-.pm-exposition-mutuals{background:#1a3a1a;color:#7fff7f}
+.pm-exposition-chip{display:inline-flex;align-items:center;gap:4px;border-radius:10px;font-size:12px;font-weight:600;border:1px solid;padding:2px 8px}
+.pm-exposition-whole{background:#3a2300;color:#ffd700;border-color:#a39134}
+.pm-exposition-mutuals{background:#1a3a1a;color:#7fff7f;border-color:#3a7f4c}
+.pm-exposition-encrypted{background:#0e3320;color:#7fff7f;border-color:#3a7f4c}
+.pm-exposition-closed{background:#3a0e0e;color:#ff8a8a;border-color:#b04040}
+.pm-exposition-lifespan-green{background:#0e2a16;color:#9aff9a;border-color:#3a7f4c}
+.pm-exposition-lifespan-orange{background:#2a1f10;color:#ffb84d;border-color:#a37a34}
+.pm-exposition-lifespan-red{background:#2a0e0e;color:#ff7f7f;border-color:#b04040}
+.jobs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:20px}
+.jobs-grid .tribe-card,.tribe-grid .tribe-card{margin-bottom:0}
+.tribe-card .card-comments-summary{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:8px}
+.tribe-card .card-comments-summary .inline-form{margin:0}
+.jobs-info-table{table-layout:auto}
+.jobs-info-table .tribe-info-label{width:1%;white-space:nowrap}
+.jobs-info-table .tribe-info-value{white-space:normal;word-break:normal;overflow-wrap:anywhere}
+.job-section{margin-bottom:12px;padding:0}
+.job-section-title{margin:0 0 8px 0;color:#ffa500;font-size:1.05em}
+.job-card .tribe-card-members{background:transparent;padding:4px 0;text-align:left}
+.job-card .card-field{border-bottom:none}
+.job-card .confirmations-block{margin:6px 0}
+.job-meta-line{margin:0;color:#9aa3b2;font-size:12px;letter-spacing:1px;text-transform:uppercase}
+.job-price-line{margin:0;font-size:18px;font-weight:700;font-family:'JetBrains Mono',monospace}
+.job-card-view-btn{margin-top:8px}
+.job-card-view-btn form{margin:0}
+.vote-comments-section,.comments-count,.comment-form-wrapper,.comments-list,.vote-buttons-block,.vote-buttons-row,.voting-buttons,.vote-table{border:none;background:transparent;padding:0;margin:0}
+.vote-buttons-block{display:flex;flex-direction:column;gap:8px}
+.vote-buttons-row{display:flex;gap:8px;flex-wrap:wrap}
+.voting-buttons{display:flex;gap:10px;margin-top:10px;flex-wrap:wrap}
+.vote-comments-section{margin-top:16px}
+.comment-form-wrapper{margin-top:12px}
+.comments-list{margin-top:12px}
+.comments-count{margin-bottom:8px}
+.title-with-chip{display:flex;align-items:center;gap:10px;flex-wrap:wrap;background:transparent !important;border:0 !important;padding:0 !important}
+.title-with-chip h2{margin:0}
 .pm-exposition-icon{font-size:14px;line-height:1}
 .pm-exposition-text{line-height:1;}
-.shop-title-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px}
+.shop-title-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:6px;padding:0;background:transparent;border-radius:0;box-shadow:none}
 .shop-title-row h2{margin:0}
+.card-chips-row{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px 0;padding:0;border:none;background:transparent;border-radius:0;box-shadow:none}
+.tribe-card-body .job-price-line,.tribe-card-body .job-meta-line,.tribe-card-body .job-card-view-btn,.tribe-card-body .card-comments-summary,.tribe-card-body .tribe-card-members{padding:0;background:transparent;border-radius:0;box-shadow:none;border:none}
+.tribe-card-body .job-price-line{margin:4px 0}
+.tribe-card-body .job-card-view-btn{margin-top:8px;margin-bottom:0}
+.transfer-block-card{border:none;background:transparent;padding:0;margin:0 0 12px 0}
+.tribe-main .job-section{border:none;background:transparent}
 .profile-reach{display:flex;flex-direction:column;align-items:center;gap:8px;margin:12px 0;text-align:center}
 .profile-reach .shop-clearnet-url{margin:0;text-align:center}
 .profile-reach-toggle{margin:0}
@@ -5667,3 +5962,43 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
   grid-template-columns: 1fr;
   gap: 4px;
 }
+
+.ai-approve-block{display:flex;flex-direction:column;gap:8px;width:100%}
+.ai-approve-form{display:flex;flex-direction:column;gap:6px;background:transparent !important;border:0 !important;padding:0 !important}
+.ai-approve-meta{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
+.ai-approve-meta-label{color:#FFA500;font-size:12px}
+.ai-approve-meta input[type="text"]{flex:1 1 240px;padding:4px 8px}
+.ai-approve-meta select{padding:4px 8px}
+.ai-approve-custom{width:100%;font-family:inherit}
+.ai-approve-actions{display:flex;justify-content:flex-start}
+.ai-approve-reject{background:transparent !important;border:0 !important;padding:0 !important;margin:0}
+.approve-btn{background:#1e7e34;color:#fff;border:none;padding:0.45em 0.9em;border-radius:6px;cursor:pointer}
+.reject-btn{background:#a71d2a;color:#fff;border:none;padding:0.45em 0.9em;border-radius:6px;cursor:pointer}
+.ai-exchange-tag{display:inline-block;padding:1px 6px;margin-right:4px;background:#222;color:#FFD700;border-radius:8px;font-size:11px}
+.ai-exchange-vote-form{display:inline-block;margin-left:8px;background:transparent !important;border:0 !important;padding:0 !important}
+.shop-title-row,.card-chips-row,.card-tags,.tribe-card-image-wrapper,.tribe-visit-btn-wrapper,.bookmark-topbar,.bookmark-topbar-left,.bookmark-actions{border:0 !important;background:transparent !important}
+.card-date-highlight{color:#ffd740;font-size:18px;font-weight:700;margin:6px 0;letter-spacing:.5px;background:transparent !important;border:0 !important;padding:0 !important}
+.vote-buttons-row-single{display:flex;flex-wrap:wrap;gap:6px;background:transparent !important;border:0 !important;padding:0 !important}
+.vote-buttons-row-single form{margin:0;display:inline-block}
+.card-visit-btn-centered{display:flex;justify-content:center;margin-top:10px;background:transparent !important;border:0 !important;padding:0 !important}
+.card-visit-btn-centered form{margin:0}
+.tribe-card-body .voting-buttons{padding:0;background:transparent !important;border:0 !important;margin:8px 0 0 0}
+.card-assigned-list{display:flex;flex-direction:column;gap:4px;background:transparent !important;border:0 !important;padding:0 !important;margin:6px 0}
+.card-assigned-list a{display:block}
+.audio-detail-chip-row{display:flex;justify-content:flex-start;align-items:center;margin:0 0 6px 0;background:transparent !important;border:0 !important;padding:0 !important}
+.audio-detail-tags{background:transparent !important;border:0 !important;padding:0 !important;margin:8px 0}
+.audio-detail-replicate{background:transparent !important;border:0 !important;padding:0 !important;margin:8px 0}
+.card-spread-centered,.spread-row{display:flex;justify-content:center;background:transparent !important;border:0 !important;padding:0 !important;margin:8px 0}
+.card-spread-centered form,.spread-row form{margin:0}
+.card-spread-left{display:flex;justify-content:flex-start;background:transparent !important;border:0 !important;padding:0 !important;margin:8px 0}
+.card-spread-left form{margin:0}
+.progress-block,.confirmations-block{border:0 !important;background:transparent !important;padding:0 !important;margin:8px 0}
+.trending-card{background:#2c2f33;border-radius:8px;padding:16px 16px 24px;border:1px solid #444;margin-bottom:16px}
+.trending-card>.card-footer{margin-top:14px;padding-top:10px;border-top:1px solid #333;font-size:13px;color:#aaa;margin-bottom:0}
+.trending-card>.card-section{padding:16px 18px 22px;margin-bottom:14px}
+.trending-card>.card-section>:last-child{margin-bottom:0}
+.trending-dominant-sep{margin:0 8px;opacity:0.5}
+.trending-dominant-text,.trending-total-count{font-weight:700}
+.comment-file-upload{display:flex;flex-direction:column;align-items:flex-start;gap:6px;background:transparent !important;border:0 !important;padding:0 !important;margin:8px 0}
+.comment-file-upload label{display:block}
+.comment-file-upload input[type=file]{display:block}

+ 19 - 0
src/client/assets/themes/Matrix-SNH.css

@@ -457,3 +457,22 @@ a.user-link:focus {
 .tribe-subtribe-link:hover { background: #00FF00 !important; color: #000 !important; }
 .tribe-parent-image { border-color: #00FF00 !important; }
 .tribe-parent-box { background: #1A1A1A !important; }
+
+.oasis-nav-header { color: #00FF00 !important; border-color: #00FF00 !important; background: #000000 !important; }
+.oasis-nav-header:hover { color: #000000 !important; background: #00FF00 !important; }
+.oasis-nav-header .emoji, .oasis-nav-header .nav-text, .oasis-nav-arrow { color: inherit !important; }
+.oasis-nav-list li a { color: #00FF00 !important; }
+.oasis-nav-list li a:hover { color: #00FF00 !important; opacity: 1 !important; }
+.oasis-nav-list .emoji, .oasis-nav-list .nav-text { color: inherit !important; }
+nav, .sidebar-left nav, .sidebar-right nav, .sidebar-left ul, .sidebar-right ul { color: #00FF00 !important; }
+.sidebar-left a, .sidebar-right a { color: #00FF00 !important; }
+
+.ai-ask-form .ai-ask-input, .ai-ask-form .ai-ask-btn { color: #00FF00 !important; border-color: #00FF00 !important; background: #000000 !important; }
+.ai-ask-form .ai-ask-input::placeholder { color: rgba(0,255,0,0.55) !important; }
+.ai-ask-form .ai-ask-input:focus { border-color: #00FF00 !important; background: #1A1A1A !important; box-shadow: 0 0 6px rgba(0,255,0,0.4) !important; }
+.ai-ask-form .ai-ask-btn:hover { background: #00FF00 !important; color: #000000 !important; border-color: #00FF00 !important; }
+.ai-nav-results, .ai-nav-result-card { color: #00FF00 !important; border-color: #00FF00 !important; background: #000000 !important; }
+.ai-nav-result-card .card-label { color: #00FF00 !important; }
+.ai-nav-result-card a { color: #00FF00 !important; }
+.ai-nav-result-card a:hover { background: #00FF00 !important; color: #000000 !important; }
+.ai-nav-query { color: #00FF00 !important; }

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 335 - 24
src/client/assets/translations/oasis_ar.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 339 - 26
src/client/assets/translations/oasis_de.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 250 - 47
src/client/assets/translations/oasis_en.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 338 - 27
src/client/assets/translations/oasis_es.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 343 - 28
src/client/assets/translations/oasis_eu.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 347 - 26
src/client/assets/translations/oasis_fr.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 332 - 21
src/client/assets/translations/oasis_hi.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 340 - 27
src/client/assets/translations/oasis_it.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 341 - 28
src/client/assets/translations/oasis_pt.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 335 - 24
src/client/assets/translations/oasis_ru.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 327 - 16
src/client/assets/translations/oasis_zh.js


+ 55 - 45
src/client/oasis_client.js

@@ -8,53 +8,63 @@ const _ = require(path.join(__dirname, '../server/node_modules/lodash'));
 const moduleAlias = require(path.join(__dirname, '../server/node_modules/module-alias'));
 moduleAlias.addAlias('punycode', 'punycode/');
 
-const cli = (presets, defaultConfigFile) =>
-  yargs(hideBin(process.argv))
+const HELP_TEXT = `Usage: sh oasis.sh [mode] [options]
+
+Modes:
+  gui                      Launch the Oasis web GUI (default if no mode given).
+  server, pub              Launch only the Oasis Sbot (headless, PUB mode).
+  help, -h, --help         Show this help message.
+
+PUB admin commands (require the Oasis Sbot to be running):
+  whoami                   Print this Oasis ID.
+  invite [N]               Create an invite code. N = number of uses (default 1).
+  name <text>              Set this Oasis display name.
+  announce <host> [port]   Publish a pub address (default port 8008).
+  follow <feedId>          Follow another Oasis ID / feed.
+  status                   Show peer / replication status.
+  gossip                   List known gossip peers.
+
+GUI options (forwarded to the backend):
+  --host=<ip>              Hostname / IP the web UI listens on (default: localhost).
+                           Use 0.0.0.0 to expose on a VPS.
+  --port=<n>               Port for the web UI (default: 3000).
+  --allow-host=<host>      Extra hostname allowed when behind a reverse proxy.
+  --public                 Public-hosting mode: disables POST and redacts content
+                           from people who haven't opted in to public hosting.
+  --offline                Don't connect to Oasis peers or pubs.
+  --no-open                Don't auto-open a browser tab on launch (useful on VPS).
+  --debug                  Verbose logging.
+
+Examples:
+  sh oasis.sh
+  sh oasis.sh server
+  sh oasis.sh invite 100
+  sh oasis.sh name "My PUB"
+  sh oasis.sh announce mypub.example.com
+  sh oasis.sh --host=0.0.0.0 --port=8080 --no-open
+`;
+
+const cli = (presets /*, defaultConfigFile */) => {
+  const argv = process.argv.slice(2);
+  if (argv.includes('-h') || argv.includes('--help') || argv.includes('-help') || argv.includes('help')) {
+    process.stdout.write(HELP_TEXT);
+    process.exit(0);
+  }
+  return yargs(hideBin(process.argv))
     .scriptName("oasis")
     .env("OASIS")
-    .help("h")
-    .alias("h", "help")
-    .usage("Usage: $0 [options]")
-    .options("open", {
-      describe:
-        "Automatically open app in web browser. Use --no-open to disable.",
-      default: _.get(presets, "open", true),
-      type: "boolean",
-    })
-    .options("offline", {
-      describe:
-        "Don't try to connect to Oasis peers or pubs. This can be changed on the 'settings' page while Oasis is running.",
-      default: _.get(presets, "offline", false),
-      type: "boolean",
-    })
-    .options("host", {
-      describe: "Hostname for web app to listen on",
-      default: _.get(presets, "host", "localhost"),
-      type: "string",
-    })
-    .options("allow-host", {
-      describe:
-        "Extra hostname to be whitelisted (useful when running behind a proxy)",
-      default: _.get(presets, "allow-host", null),
-      type: "string",
-    })
-    .options("port", {
-      describe: "Port for web app to listen on",
-      default: _.get(presets, "port", 3000),
-      type: "number",
-    })
-    .options("public", {
-      describe:
-        "Assume Oasis is being hosted publicly, disable HTTP POST and redact messages from people who haven't given consent for public web hosting.",
-      default: _.get(presets, "public", false),
-      type: "boolean",
-    })
-    .options("debug", {
-      describe: "Use verbose output for debugging",
-      default: _.get(presets, "debug", false),
-      type: "boolean",
-    })
-    .epilog(`The defaults can be configured in ${defaultConfigFile}.`).argv;
+    .help(false)
+    .version(false)
+    .options("open", { default: _.get(presets, "open", true), type: "boolean" })
+    .options("offline", { default: _.get(presets, "offline", false), type: "boolean" })
+    .options("host", { default: _.get(presets, "host", "localhost"), type: "string" })
+    .options("allow-host", { default: _.get(presets, "allow-host", null), type: "string" })
+    .options("port", { default: _.get(presets, "port", 3000), type: "number" })
+    .options("public", { default: _.get(presets, "public", false), type: "boolean" })
+    .options("debug", { default: _.get(presets, "debug", false), type: "boolean" })
+    .parserConfiguration({ "strip-aliased": true })
+    .argv;
+};
 
 module.exports = { cli };
 

+ 12 - 2
src/configs/config-manager.js

@@ -8,6 +8,9 @@ if (!fs.existsSync(configFilePath)) {
     "themes": {
       "current": "Dark-SNH"
     },
+    "ux": {
+      "current": "blocks"
+    },
     "modules": {
       "popularMod": "on",
       "topicsMod": "on",
@@ -55,7 +58,8 @@ if (!fs.existsSync(configFilePath)) {
       "mapsMod": "on",
       "chatsMod": "on",
       "torrentsMod": "on",
-      "graphosMod": "on"
+      "graphosMod": "on",
+      "larpMod": "on"
     },
     "wallet": {
       "url": "http://localhost:7474",
@@ -75,7 +79,8 @@ if (!fs.existsSync(configFilePath)) {
     "homePage": "activity",
     "language": "en",
     "wish": "whole",
-    "pmVisibility": "whole"
+    "pmVisibility": "whole",
+    "lanBroadcasting": true
   };
   fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 2));
 }
@@ -85,6 +90,11 @@ const getConfig = () => {
   const cfg = JSON.parse(configData);
   if (!['whole', 'mutuals', 'only-lan'].includes(cfg.wish)) cfg.wish = 'whole';
   if (cfg.pmVisibility !== 'whole' && cfg.pmVisibility !== 'mutuals') cfg.pmVisibility = 'whole';
+  if (typeof cfg.ux === 'string') cfg.ux = { current: cfg.ux };
+  if (!cfg.ux || typeof cfg.ux !== 'object') cfg.ux = { current: 'blocks' };
+  if (cfg.ux.current === 'menus') cfg.ux.current = 'blocks';
+  if (cfg.ux.current !== 'blocks' && cfg.ux.current !== 'ainav') cfg.ux.current = 'blocks';
+  if (cfg.ux.current === 'ainav' && cfg.modules && cfg.modules.aiNavMod !== 'on') cfg.ux.current = 'blocks';
   return cfg;
 };
 

+ 4 - 2
src/configs/media-favorites.json

@@ -1,5 +1,7 @@
 {
-  "audios": [],
+  "audios": [
+    "%i56DcWsz99Fk+eP6pZIHCG+2c+Q7JsE+uo3LbY+f6xY=.sha256"
+  ],
   "bookmarks": [],
   "calendars": [],
   "chats": [],
@@ -10,4 +12,4 @@
   "shops": [],
   "torrents": [],
   "videos": []
-}
+}

+ 8 - 3
src/configs/oasis-config.json

@@ -2,6 +2,9 @@
   "themes": {
     "current": "Dark-SNH"
   },
+  "ux": {
+    "current": "blocks"
+  },
   "modules": {
     "popularMod": "on",
     "topicsMod": "on",
@@ -49,7 +52,8 @@
     "mapsMod": "on",
     "chatsMod": "on",
     "torrentsMod": "on",
-    "graphosMod": "on"
+    "graphosMod": "on",
+    "larpMod": "on"
   },
   "wallet": {
     "url": "http://localhost:7474",
@@ -68,6 +72,7 @@
   },
   "homePage": "activity",
   "language": "en",
-  "wish": "only-lan",
-  "pmVisibility": "whole"
+  "wish": "whole",
+  "pmVisibility": "whole",
+  "lanBroadcasting": true
 }

+ 2 - 4
src/configs/server-config.json

@@ -3,7 +3,7 @@
     "level": "notice"
   },
   "caps": {
-    "shs": "zTmidAb7t+tKi7W93FIHbOvlbd936x6G/vm8e8Td//A="
+    "shs": "H5EC+V5BU9s0lWxCkt4z8a095Sj8a6TgiLKPYi1JD7s="
   },
   "pub": false,
   "local": true,
@@ -27,9 +27,7 @@
     "feeds": []
   },
   "connections": {
-    "seeds": [
-      "net:solarnethub.com:8008~shs:mGrevRCSX4E5dLgmflWBc50Qkn/1RXUAtDaGHOJ8xB4=.ed25519"
-    ],
+    "seeds": [],
     "incoming": {
       "net": [
         {

+ 7 - 1
src/configs/shared-state.js

@@ -7,6 +7,8 @@ let _inboxUnread = null;
 let _lastSyncTs = null;
 let _ecoValue = null;
 let _lastActivity = null;
+let _maxBlockBytes = 0;
+let _inhabitantCount = 0;
 module.exports = {
   getInboxCount: () => _inboxCount,
   setInboxCount: (n) => { _inboxCount = n; },
@@ -25,5 +27,9 @@ module.exports = {
   getEcoValue: () => _ecoValue,
   setEcoValue: (v) => { _ecoValue = v; },
   getLastActivity: () => _lastActivity,
-  setLastActivity: (a) => { _lastActivity = a; }
+  setLastActivity: (a) => { _lastActivity = a; },
+  getMaxBlockBytes: () => _maxBlockBytes,
+  setMaxBlockBytes: (n) => { if (Number(n) > _maxBlockBytes) _maxBlockBytes = Number(n); },
+  getInhabitantCount: () => _inhabitantCount,
+  setInhabitantCount: (n) => { _inhabitantCount = Math.max(0, Number(n) || 0); }
 };

+ 2 - 2
src/configs/snh-invite-code.json

@@ -2,6 +2,6 @@
   "name": "SNH \"La Plaza\"",
   "description": "A shared place to begin a utopia ...",
   "url": "https://pub.solarnethub.com",
-  "code": "solarnethub.com:8008:@mGrevRCSX4E5dLgmflWBc50Qkn/1RXUAtDaGHOJ8xB4=.ed25519~jc1zvYi1f5R5fv228U31fwOjjdwBg0sTGuj5fz8mW5g=",
-  "createdAt": "2026-03-04T00:00:00.000Z"
+  "code": "pub.solarnethub.com:8008:@0qSCyK3xyL71X4qKkmf84Cb2riP6OeUqxCvbP2Z6HWs=.ed25519~7mC1c1gBJe8pmnWI9eIUYpFtEPUOiuHAxbzfxP/f0Ak=",
+  "createdAt": "2026-05-24T00:00:00.000Z"
 }

binární
src/models/activity_model.js


+ 42 - 9
src/models/agenda_model.js

@@ -5,6 +5,7 @@ const moment = require('../server/node_modules/moment');
 
 const agendaConfigPath = path.join(__dirname, '../configs/agenda-config.json');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 function readAgendaConfig() {
@@ -18,7 +19,7 @@ function writeAgendaConfig(cfg) {
   fs.writeFileSync(agendaConfigPath, JSON.stringify(cfg, null, 2));
 }
 
-module.exports = ({ cooler, calendarsModel }) => {
+module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel }) => {
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
 
@@ -33,7 +34,7 @@ module.exports = ({ cooler, calendarsModel }) => {
           pull.collect((err, msgs) => {
             if (err) return reject(err);
 
-            const tomb = new Set();
+            const tomb = buildValidatedTombstoneSet(msgs);
             const nodes = new Map();
             const parent = new Map();
             const child = new Map();
@@ -43,8 +44,9 @@ module.exports = ({ cooler, calendarsModel }) => {
               const v = m.value;
               const c = v?.content;
               if (!c) continue;
-              if (c.type === 'tombstone' && c.target) { tomb.add(c.target); continue; }
+              if (c.type === 'tombstone') continue;
               if (c.type !== targetType) continue;
+              if (c.encryptedPayload) continue;
               nodes.set(k, { key: k, ts: v.timestamp || 0, content: c });
               if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k); }
             }
@@ -140,16 +142,47 @@ module.exports = ({ cooler, calendarsModel }) => {
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
 
+      const normalize = (arr) => arr.map(it => ({
+        ...it,
+        key: it.id || it.key,
+        createdAt: it.createdAt || new Date().toISOString(),
+        tipId: it.tipId || it.id || it.key
+      }));
+
+      const eventsViaModel = eventsModel && typeof eventsModel.listAll === 'function'
+        ? eventsModel.listAll(null, 'all').then(normalize).catch(() => [])
+        : fetchItems('event');
+
+      const calendarsViaModel = calendarsModel && typeof calendarsModel.listAll === 'function'
+        ? calendarsModel.listAll({ filter: 'all', viewerId: userId }).then(normalize).catch(() => [])
+        : fetchItems('calendar');
+
+      const tasksViaModel = tasksModel && typeof tasksModel.listAll === 'function'
+        ? tasksModel.listAll().then(normalize).catch(() => [])
+        : fetchItems('task');
+
+      const marketViaModel = marketModel && typeof marketModel.listAllItems === 'function'
+        ? marketModel.listAllItems('all').then(normalize).catch(() => [])
+        : fetchItems('market');
+
+      const jobsViaModel = jobsModel && typeof jobsModel.listJobs === 'function'
+        ? jobsModel.listJobs('ALL', userId).then(normalize).catch(() => [])
+        : fetchItems('job');
+
+      const projectsViaModel = projectsModel && typeof projectsModel.listProjects === 'function'
+        ? projectsModel.listProjects('all').then(normalize).catch(() => [])
+        : fetchItems('project');
+
       const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll] = await Promise.all([
-        fetchItems('task'),
-        fetchItems('event'),
+        tasksViaModel,
+        eventsViaModel,
         fetchItems('transfer'),
         fetchItems('tribe'),
-        fetchItems('market'),
+        marketViaModel,
         fetchItems('report'),
-        fetchItems('job'),
-        fetchItems('project'),
-        fetchItems('calendar')
+        jobsViaModel,
+        projectsViaModel,
+        calendarsViaModel
       ]);
 
       const tasks = tasksAll.filter(c => Array.isArray(c.assignees) && c.assignees.includes(userId)).map(t => ({ ...t, type: 'task' }));

+ 70 - 10
src/models/audios_model.js

@@ -1,4 +1,5 @@
 const pull = require("../server/node_modules/pull-stream");
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const { getConfig } = require("../configs/config-manager.js");
 const categories = require("../backend/opinion_categories");
 
@@ -40,7 +41,7 @@ module.exports = ({ cooler }) => {
     });
 
   const buildIndex = (messages) => {
-    const tomb = new Set();
+    const tomb = buildValidatedTombstoneSet(messages);
     const nodes = new Map();
     const parent = new Map();
     const child = new Map();
@@ -51,15 +52,14 @@ module.exports = ({ cooler }) => {
       const c = v.content;
       if (!c) continue;
 
-      if (c.type === "tombstone" && c.target) {
-        tomb.add(c.target);
-        continue;
-      }
+      if (c.type === "tombstone") continue;
 
       if (c.type !== "audio") continue;
 
       const ts = v.timestamp || m.timestamp || 0;
-      nodes.set(k, { key: k, ts, c });
+      let sizeBytes = 0;
+      try { sizeBytes = Buffer.byteLength(JSON.stringify(v), 'utf8'); } catch (_) { sizeBytes = 0; }
+      nodes.set(k, { key: k, ts, c, sizeBytes });
 
       if (c.replaces) {
         parent.set(k, c.replaces);
@@ -94,20 +94,26 @@ module.exports = ({ cooler }) => {
   const buildAudio = (node, rootId, viewerId) => {
     const c = node.c || {};
     const voters = safeArr(c.opinions_inhabitants);
+    const composition = Array.isArray(c.bcsComposition) ? c.bcsComposition : null;
+    const tagsArr = safeArr(c.tags);
+    const isBcs = (composition && composition.length > 0) || tagsArr.some(t => String(t).toLowerCase() === 'bcs');
     return {
       key: node.key,
       rootId,
       url: c.url,
       createdAt: c.createdAt || new Date(node.ts).toISOString(),
       updatedAt: c.updatedAt || null,
-      tags: safeArr(c.tags),
+      tags: tagsArr,
       author: c.author,
       title: c.title || "",
       description: c.description || "",
       mapUrl: c.mapUrl || "",
       opinions: c.opinions || {},
       opinions_inhabitants: voters,
-      hasVoted: viewerId ? voters.includes(viewerId) : false
+      hasVoted: viewerId ? voters.includes(viewerId) : false,
+      bcsComposition: composition,
+      isBcs,
+      sizeBytes: node.sizeBytes || 0
     };
   };
 
@@ -164,6 +170,60 @@ module.exports = ({ cooler }) => {
       });
     },
 
+    async createBcsAudio(blobId, title, description, composition) {
+      const ssbClient = await openSsb();
+      const now = new Date().toISOString();
+      const cleanComposition = safeArr(composition)
+        .map(n => ({
+          t: String(n.type || n.t || ''),
+          n: String(n.name || n.n || ''),
+          d: Number(n.durMs || n.d || 0),
+          id: typeof n.id === 'string' ? n.id : (typeof n.key === 'string' ? n.key : null)
+        }))
+        .filter(n => n.t && n.n);
+
+      const baseContent = {
+        type: "audio",
+        url: blobId,
+        createdAt: now,
+        updatedAt: null,
+        author: ssbClient.id,
+        tags: ["bcs"],
+        title: title || "",
+        description: description || "",
+        mapUrl: "",
+        bcsComposition: [],
+        opinions: {},
+        opinions_inhabitants: []
+      };
+
+      const SSB_MAX_BYTES = 8000;
+      const wrapEnvelope = (content) => ({
+        previous: "%0000000000000000000000000000000000000000000=.sha256",
+        sequence: 999999,
+        author: ssbClient.id,
+        timestamp: Date.now(),
+        hash: "sha256",
+        content,
+        signature: "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000==.sig.ed25519"
+      });
+      const fitted = [];
+      for (const item of cleanComposition) {
+        fitted.push(item);
+        const probe = wrapEnvelope({ ...baseContent, bcsComposition: fitted });
+        if (Buffer.byteLength(JSON.stringify(probe, null, 2), 'utf8') > SSB_MAX_BYTES) {
+          fitted.pop();
+          break;
+        }
+      }
+
+      const content = { ...baseContent, bcsComposition: fitted };
+
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
+      });
+    },
+
     async updateAudioById(id, blobMarkdown, tagsRaw, title, description, mapUrl) {
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
@@ -241,8 +301,8 @@ module.exports = ({ cooler }) => {
       else if (filter === "recent") list = list.filter((a) => new Date(a.createdAt).getTime() >= now - 86400000);
       else if (filter === "top") {
         list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
-      } else if (filter === "blockchain") {
-        list = list.filter((a) => safeArr(a.tags).some((t) => String(t).toLowerCase() === "blockchain"));
+      } else if (filter === "bcs") {
+        list = list.filter((a) => a.isBcs === true);
       }
 
       if (q) {

+ 229 - 22
src/models/banking_model.js

@@ -4,6 +4,7 @@ const path = require("path");
 const pull = require("../server/node_modules/pull-stream");
 const { getConfig } = require("../configs/config-manager.js");
 const { config } = require("../server/SSB_server.js");
+const sharedState = require("../configs/shared-state.js");
 
 const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
 
@@ -14,8 +15,7 @@ const DEFAULT_RULES = {
   alpha: 0.2,
   reserveMin: 500,
   capPerEpoch: 2000,
-  caps: { M_max: 3, T_max: 1.5, P_max: 2, cap_user_epoch: 50, w_min: 0.2, w_max: 6 },
-  coeffs: { a1: 0.6, a2: 0.4, a3: 0.3, a4: 0.5, b1: 0.5, b2: 1.0 },
+  caps: { cap_user_epoch: 50, floor_user: 1, w_min: 0.2, w_max: 6 },
   graceDays: 30
 };
 
@@ -27,6 +27,15 @@ const ECO_HISTORY_PATH = path.join(STORAGE_DIR, "banking-eco-history.json");
 const ECO_HISTORY_MAX = 500;
 const ECO_HISTORY_MIN_GAP_MS = 5 * 60 * 1000;
 
+const ECOIN_PER_GRAM_CO2 = 0.1;
+const ECOIN_PER_DAY_OF_HISTORY = 0.001;
+const ONE_DAY_MS = 86400000;
+const ONE_MIB = 1024 * 1024;
+const carbonGramsFromBytes = (b) => (Number(b) || 0) / ONE_MIB * 0.095;
+const ecoinTaxFromGrams = (g) => (Number(g) || 0) * ECOIN_PER_GRAM_CO2;
+const ecoinTaxFromBytes = (b) => ecoinTaxFromGrams(carbonGramsFromBytes(b));
+const archTaxFromDays = (days) => Math.max(0, Number(days) || 0) * ECOIN_PER_DAY_OF_HISTORY;
+
 function ensureStoreFiles() {
   if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true });
   if (!fs.existsSync(EPOCHS_PATH)) fs.writeFileSync(EPOCHS_PATH, "[]");
@@ -584,6 +593,152 @@ async function getCarbonGramsForUser(userId) {
   });
 }
 
+async function getBytesForUser(userId) {
+  const ssb = await openSsb();
+  if (!ssb || !userId) return 0;
+  return new Promise((resolve) => {
+    let bytes = 0;
+    pull(
+      ssb.createUserStream({ id: userId }),
+      pull.drain(
+        (m) => { try { bytes += Buffer.byteLength(JSON.stringify(m && m.value), 'utf8'); } catch (_) {} },
+        () => resolve(bytes)
+      )
+    );
+  });
+}
+
+let _ecoTaxStatsCache = null;
+const ECO_TAX_STATS_TTL_MS = 60 * 1000;
+
+async function calculateEcoTaxStatsInternal() {
+  const ssb = await (async () => {
+    try { return await openSsb(); } catch (_) { return null; }
+  })();
+  if (!ssb) return null;
+  return new Promise((resolve) => {
+    let totalBytes = 0;
+    let totalBlocks = 0;
+    let maxBlockBytes = 0;
+    let oldestTs = Infinity;
+    let newestTs = 0;
+    pull(
+      ssb.createLogStream({}),
+      pull.drain(
+        (m) => {
+          try {
+            const v = m && m.value;
+            if (!v) return;
+            totalBlocks += 1;
+            const size = Buffer.byteLength(JSON.stringify(v), 'utf8');
+            totalBytes += size;
+            if (size > maxBlockBytes) maxBlockBytes = size;
+            const ts = Number(v.timestamp || 0);
+            if (ts && ts < oldestTs) oldestTs = ts;
+            if (ts && ts > newestTs) newestTs = ts;
+          } catch (_) {}
+        },
+        () => {
+          const totalGramsCO2 = carbonGramsFromBytes(totalBytes);
+          const totalEcoinTax = ecoinTaxFromGrams(totalGramsCO2);
+          const spanMs = (newestTs && oldestTs && oldestTs !== Infinity) ? Math.max(1, newestTs - oldestTs) : 1;
+          const spanDays = spanMs / 86400000;
+          const annualEcoinTax = spanDays > 0 ? totalEcoinTax * (365 / spanDays) : totalEcoinTax;
+          const monthlyEcoinTax = annualEcoinTax / 12;
+          try { sharedState.setMaxBlockBytes(maxBlockBytes); } catch (_) {}
+          const ecoTaxes = {
+            lifetime: Number(totalEcoinTax.toFixed(6)),
+            annual: Number(annualEcoinTax.toFixed(6)),
+            monthly: Number(monthlyEcoinTax.toFixed(6))
+          };
+          const archLifetime = archTaxFromDays(spanDays);
+          const archAnnual = archTaxFromDays(365);
+          const archMonthly = archTaxFromDays(365 / 12);
+          const archTaxes = {
+            lifetime: Number(archLifetime.toFixed(6)),
+            annual: Number(archAnnual.toFixed(6)),
+            monthly: Number(archMonthly.toFixed(6))
+          };
+          const byType = { eco: ecoTaxes, arch: archTaxes };
+          const totals = {
+            lifetimeEcoinTax: Number(Object.values(byType).reduce((s, t) => s + (t.lifetime || 0), 0).toFixed(6)),
+            annualEcoinTax: Number(Object.values(byType).reduce((s, t) => s + (t.annual || 0), 0).toFixed(6)),
+            monthlyEcoinTax: Number(Object.values(byType).reduce((s, t) => s + (t.monthly || 0), 0).toFixed(6))
+          };
+          resolve({
+            totalBlocks,
+            totalBytes,
+            maxBlockBytes,
+            totalGramsCO2: Number(totalGramsCO2.toFixed(6)),
+            totalEcoinTax: ecoTaxes.lifetime,
+            annualEcoinTax: ecoTaxes.annual,
+            monthlyEcoinTax: ecoTaxes.monthly,
+            byType,
+            totals,
+            spanDays: Number(spanDays.toFixed(3)),
+            oldestTs: oldestTs === Infinity ? 0 : oldestTs,
+            newestTs,
+            ecoinPerGramCO2: ECOIN_PER_GRAM_CO2,
+            ecoinPerDayOfHistory: ECOIN_PER_DAY_OF_HISTORY
+          });
+        }
+      )
+    );
+  });
+}
+
+async function calculateEcoTaxStats() {
+  const now = Date.now();
+  if (_ecoTaxStatsCache && (now - _ecoTaxStatsCache.ts) < ECO_TAX_STATS_TTL_MS) {
+    return _ecoTaxStatsCache.value;
+  }
+  const value = await calculateEcoTaxStatsInternal().catch(() => null);
+  if (value) _ecoTaxStatsCache = { ts: now, value };
+  return value || {
+    totalBlocks: 0, totalBytes: 0, totalGramsCO2: 0,
+    totalEcoinTax: 0, annualEcoinTax: 0, monthlyEcoinTax: 0,
+    byType: {
+      eco: { lifetime: 0, annual: 0, monthly: 0 },
+      arch: { lifetime: 0, annual: 0, monthly: 0 }
+    },
+    totals: { lifetimeEcoinTax: 0, annualEcoinTax: 0, monthlyEcoinTax: 0 },
+    spanDays: 0, oldestTs: 0, newestTs: 0,
+    ecoinPerGramCO2: ECOIN_PER_GRAM_CO2,
+    ecoinPerDayOfHistory: ECOIN_PER_DAY_OF_HISTORY
+  };
+}
+
+async function getUserEcoinTax(userId) {
+  const bytes = await getBytesForUser(userId).catch(() => 0);
+  return ecoinTaxFromBytes(bytes);
+}
+
+async function getUserFirstBlockTs(userId) {
+  const ssb = await openSsb();
+  if (!ssb || !userId) return 0;
+  return new Promise((resolve) => {
+    let first = 0;
+    pull(
+      ssb.createUserStream({ id: userId, limit: 1 }),
+      pull.drain(
+        (m) => { if (m && m.value && m.value.timestamp && !first) first = Number(m.value.timestamp); },
+        () => resolve(first || 0)
+      )
+    );
+  });
+}
+
+async function getUserArchTax(userId) {
+  const firstTs = await getUserFirstBlockTs(userId).catch(() => 0);
+  if (!firstTs) return 0;
+  const stats = await calculateEcoTaxStats().catch(() => null);
+  const newestTs = stats && Number.isFinite(stats.newestTs) && stats.newestTs > 0
+    ? stats.newestTs
+    : Date.now();
+  const ageDays = Math.max(0, (newestTs - firstTs) / ONE_DAY_MS);
+  return archTaxFromDays(ageDays);
+}
+
 async function getUserEngagementScore(userId) {
   const ssb = await openSsb();
   const uid = resolveUserId(userId);
@@ -663,9 +818,9 @@ async function getLastPublishedTimestamp(userId) {
 }
 
   function computePoolVars(pubBal, rules) {
-    const alphaCap = (rules.alpha || DEFAULT_RULES.alpha) * pubBal;
-    const available = Math.max(0, pubBal - (rules.reserveMin || DEFAULT_RULES.reserveMin));
-    const rawMin = Math.min(available, (rules.capPerEpoch || DEFAULT_RULES.capPerEpoch), alphaCap);
+    const alphaCap = (rules.alpha ?? DEFAULT_RULES.alpha) * pubBal;
+    const available = Math.max(0, pubBal - (rules.reserveMin ?? DEFAULT_RULES.reserveMin));
+    const rawMin = Math.min(available, (rules.capPerEpoch ?? DEFAULT_RULES.capPerEpoch), alphaCap);
     const pool = clamp(rawMin, 0, Number.MAX_SAFE_INTEGER);
     return { pubBal, alphaCap, available, rawMin, pool };
   }
@@ -675,9 +830,10 @@ async function getLastPublishedTimestamp(userId) {
     const pv = computePoolVars(pubBal, rules);
     const addresses = await listAddressesMerged();
     const eligible = addresses.filter(a => a.address && isValidEcoinAddress(a.address));
-    const capUser = (rules.caps && rules.caps.cap_user_epoch) || DEFAULT_RULES.caps.cap_user_epoch;
-    const wMin = (rules.caps && rules.caps.w_min) || DEFAULT_RULES.caps.w_min;
-    const wMax = (rules.caps && rules.caps.w_max) || DEFAULT_RULES.caps.w_max;
+    const capUser = rules.caps?.cap_user_epoch ?? DEFAULT_RULES.caps.cap_user_epoch;
+    const wMin = rules.caps?.w_min ?? DEFAULT_RULES.caps.w_min;
+    const wMax = rules.caps?.w_max ?? DEFAULT_RULES.caps.w_max;
+    const floorUbi = rules.caps?.floor_user ?? DEFAULT_RULES.caps.floor_user ?? 1;
     const weights = [];
     for (const entry of eligible) {
       const score = await getUserEngagementScore(entry.id);
@@ -688,17 +844,27 @@ async function getLastPublishedTimestamp(userId) {
       weights.push({ user: userId, w: clamp(1 + score / 100, wMin, wMax) });
     }
     const W = weights.reduce((acc, x) => acc + x.w, 0) || 1;
-    const floorUbi = 1;
-    const allocations = weights.map(({ user, w }) => {
-      const amount = Math.max(floorUbi, Math.min(pv.pool * w / W, capUser));
-      return {
+    const allocations = [];
+    for (const { user, w } of weights) {
+      const gross = Math.max(floorUbi, Math.min(pv.pool * w / W, capUser));
+      const surplus = Math.max(0, gross - floorUbi);
+      const userEcoTax = await getUserEcoinTax(user).catch(() => 0);
+      const userArchTax = await getUserArchTax(user).catch(() => 0);
+      const userTotalTax = userEcoTax + userArchTax;
+      const taxedSurplus = Math.max(0, surplus - userTotalTax);
+      const amount = floorUbi + taxedSurplus;
+      allocations.push({
         id: `alloc:${epochId}:${user}`,
         epoch: epochId,
         user,
         weight: Number(w.toFixed(6)),
+        gross: Number(gross.toFixed(6)),
+        ecoTax: Number((userEcoTax || 0).toFixed(6)),
+        archTax: Number((userArchTax || 0).toFixed(6)),
+        totalTax: Number((userTotalTax || 0).toFixed(6)),
         amount: Number(amount.toFixed(6))
-      };
-    });
+      });
+    }
     const snapshot = JSON.stringify({ epochId, pool: pv.pool, weights, allocations, rules }, null, 2);
     const hash = crypto.createHash("sha256").update(snapshot).digest("hex");
     return { epoch: { id: epochId, pool: Number(pv.pool.toFixed(6)), weightsSum: Number(W.toFixed(6)), rules, hash }, allocations };
@@ -721,7 +887,7 @@ async function getLastPublishedTimestamp(userId) {
         concept: `UBI ${eid}`,
         status: "UNCLAIMED",
         createdAt: new Date().toISOString(),
-        deadline: new Date(Date.now() + (rules.graceDays || DEFAULT_RULES.graceDays) * 86400000).toISOString(),
+        deadline: new Date(Date.now() + ((rules.graceDays ?? DEFAULT_RULES.graceDays) * 86400000)).toISOString(),
         tags: ["UBI", `epoch:${eid}`],
         opinions: {}
       };
@@ -836,7 +1002,7 @@ async function getLastPublishedTimestamp(userId) {
   async function publishPubAvailability() {
     if (!isPubNode()) return;
     const balance = await safeGetBalance("pub");
-    const floor = Math.max(1, DEFAULT_RULES?.caps?.floor_user || 1);
+    const floor = Math.max(1, DEFAULT_RULES?.caps?.floor_user ?? 1);
     const available = Number(balance) >= floor;
     const ssb = await openSsb();
     if (!ssb || !ssb.publish) return;
@@ -868,7 +1034,7 @@ async function getLastPublishedTimestamp(userId) {
     let allocations;
     if (isPubNode()) {
       pubBalance = await safeGetBalance("pub");
-      const floor = Math.max(1, DEFAULT_RULES?.caps?.floor_user || 1);
+      const floor = Math.max(1, DEFAULT_RULES?.caps?.floor_user ?? 1);
       ubiAvailable = Number(pubBalance) >= floor;
       try { await publishPubAvailability(); } catch (_) {}
       const all = await transfersRepo.listByTag("UBI");
@@ -911,7 +1077,24 @@ async function getLastPublishedTimestamp(userId) {
     };
     const exchange = await calculateEcoinValue();
     const exchangeHistory = readEcoHistory();
-    return { summary, allocations, epochs, rules: DEFAULT_RULES, addresses, exchange, exchangeHistory };
+    let taxStats = null;
+    let userEcoinTax = 0;
+    if (filter === 'taxes' || filter === 'exchange' || filter === 'overview') {
+      try { taxStats = await calculateEcoTaxStats(); } catch (_) { taxStats = null; }
+      try { userEcoinTax = await getUserEcoinTax(uid); } catch (_) { userEcoinTax = 0; }
+    }
+    const taxRules = { ecoinPerGramCO2: ECOIN_PER_GRAM_CO2, gramsCO2PerMiB: 0.095, ecoinPerDayOfHistory: ECOIN_PER_DAY_OF_HISTORY };
+    let userArchTax = 0;
+    if (filter === 'taxes' || filter === 'exchange' || filter === 'overview') {
+      try { userArchTax = await getUserArchTax(uid); } catch (_) { userArchTax = 0; }
+    }
+    const userTotalTax = (userEcoinTax || 0) + (userArchTax || 0);
+    return {
+      summary, allocations, epochs, rules: DEFAULT_RULES, taxRules, addresses, exchange, exchangeHistory, taxStats,
+      userEcoinTax: Number((userEcoinTax || 0).toFixed(6)),
+      userArchTax: Number((userArchTax || 0).toFixed(6)),
+      userTotalTax: Number(userTotalTax.toFixed(6))
+    };
   }
 
   async function getAllocationById(id) {
@@ -1012,16 +1195,34 @@ async function getLastPublishedTimestamp(userId) {
       const pool = pv.pool || 0;
       const addresses = await listAddressesMerged();
       const eligible = addresses.filter(a => a.address && isValidEcoinAddress(a.address));
-      const totalW = eligible.length > 0 ? eligible.length + eligible.length * (karmaScore / 100) : 1;
-      const userW = 1 + karmaScore / 100;
+      const wMin = DEFAULT_RULES.caps?.w_min ?? 0.2;
+      const wMax = DEFAULT_RULES.caps?.w_max ?? 6;
+      const userW = clamp(1 + karmaScore / 100, wMin, wMax);
+      const otherUsers = Math.max(0, eligible.length - 1);
+      const totalW = Math.max(1, userW + otherUsers);
       const cap = DEFAULT_RULES.caps?.cap_user_epoch ?? 50;
-      estimatedUBI = Math.min(pool * (userW / Math.max(1, totalW)), cap);
+      const floor = DEFAULT_RULES.caps?.floor_user ?? 1;
+      estimatedUBI = eligible.length > 0
+        ? Math.max(floor, Math.min(pool * (userW / totalW), cap))
+        : 0;
     } catch (_) {}
+    const uid = resolveUserId(userId);
+    const userEcoinTax = await getUserEcoinTax(uid).catch(() => 0);
+    const userArchTax = await getUserArchTax(uid).catch(() => 0);
+    const userTotalTax = userEcoinTax + userArchTax;
+    const estimatedUBIBeforeTax = estimatedUBI;
+    const baseFloor = DEFAULT_RULES.caps?.floor_user ?? 1;
+    const surplus = Math.max(0, estimatedUBI - baseFloor);
+    estimatedUBI = baseFloor + Math.max(0, surplus - userTotalTax);
     const claimHistory = await getUbiClaimHistory(userId).catch(() => ({ lastClaimedDate: null, totalClaimed: 0 }));
     return {
       ecoValue,
       karmaScore,
       estimatedUBI,
+      estimatedUBIBeforeTax: Number(estimatedUBIBeforeTax.toFixed(6)),
+      userEcoinTax: Number(userEcoinTax.toFixed(6)),
+      userArchTax: Number(userArchTax.toFixed(6)),
+      userTotalTax: Number(userTotalTax.toFixed(6)),
       lastClaimedDate: claimHistory.lastClaimedDate,
       totalClaimed: claimHistory.totalClaimed
     };
@@ -1167,6 +1368,12 @@ async function getLastPublishedTimestamp(userId) {
     setUserAddress,
     listAddressesMerged,
     calculateEcoinValue,
-    getBankingData
+    calculateEcoTaxStats,
+    getUserEcoinTax,
+    getUserArchTax,
+    getUserFirstBlockTs,
+    getBankingData,
+    ECOIN_PER_GRAM_CO2,
+    ECOIN_PER_DAY_OF_HISTORY
   };
 };

+ 3 - 4
src/models/blockchain_model.js

@@ -1,6 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const config = require('../server/ssb_config');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
@@ -137,7 +138,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
         )
       );
 
-      const tombstoned = new Set();
+      const tombstoned = buildValidatedTombstoneSet(results);
       const idToBlock = new Map();
       const referencedAsReplaces = new Set();
 
@@ -177,7 +178,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
         }
 
         if (c.type === 'tombstone' && c.target) {
-          tombstoned.add(c.target);
           idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c, size: Buffer.byteLength(JSON.stringify(msg.value), 'utf8') });
           continue;
         }
@@ -301,7 +301,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       );
 
       const me = userId || config.keys.id;
-      const tombstoned = new Set();
+      const tombstoned = buildValidatedTombstoneSet(results);
       const idToBlock = new Map();
       const referencedAsReplaces = new Set();
       const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
@@ -330,7 +330,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
           continue;
         }
         if (c.type === 'tombstone' && c.target) {
-          tombstoned.add(c.target);
           idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c, size: Buffer.byteLength(JSON.stringify(msg.value), 'utf8') });
           continue;
         }

+ 8 - 7
src/models/bookmarking_model.js

@@ -2,6 +2,7 @@ const pull = require("../server/node_modules/pull-stream");
 const moment = require("../server/node_modules/moment");
 const { getConfig } = require("../configs/config-manager.js");
 const categories = require("../backend/opinion_categories");
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
@@ -48,7 +49,7 @@ module.exports = ({ cooler }) => {
     });
 
   const buildIndex = (messages) => {
-    const tomb = new Set();
+    const tomb = buildValidatedTombstoneSet(messages);
     const nodes = new Map();
     const parent = new Map();
     const child = new Map();
@@ -59,15 +60,14 @@ module.exports = ({ cooler }) => {
       const c = v.content;
       if (!c) continue;
 
-      if (c.type === "tombstone" && c.target) {
-        tomb.add(c.target);
-        continue;
-      }
+      if (c.type === 'tombstone') continue;
 
       if (c.type !== "bookmark") continue;
 
       const ts = v.timestamp || m.timestamp || 0;
-      nodes.set(k, { key: k, ts, c });
+      let sizeBytes = 0;
+      try { sizeBytes = Buffer.byteLength(JSON.stringify(v), "utf8"); } catch (_) { sizeBytes = 0; }
+      nodes.set(k, { key: k, ts, c, sizeBytes });
 
       if (c.replaces) {
         parent.set(k, c.replaces);
@@ -115,7 +115,8 @@ module.exports = ({ cooler }) => {
       opinions: c.opinions || {},
       opinions_inhabitants: voters,
       author: c.author,
-      hasVoted: viewerId ? voters.includes(viewerId) : false
+      hasVoted: viewerId ? voters.includes(viewerId) : false,
+      sizeBytes: node.sizeBytes || 0
     };
   };
 

+ 94 - 6
src/models/calendars_model.js

@@ -1,6 +1,7 @@
 const pull = require("../server/node_modules/pull-stream")
 const crypto = require("crypto")
 const { getConfig } = require("../configs/config-manager.js")
+const { buildValidatedTombstoneSet } = require('./tombstone_validator')
 const logLimit = getConfig().ssbLogStream?.limit || 1000
 const INVITE_CODE_BYTES = 16
 
@@ -45,6 +46,49 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
   }
   const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0)
 
+  const rotateCalendarKey = async (rootId, remainingMembers) => {
+    if (!ownCrypto || !tribeCrypto || !rootId) return
+    const existing = lookupKey(rootId)
+    if (!existing) return
+    const newKey = ownCrypto.generateTribeKey()
+    const newGen = ownCrypto.addNewKey(rootId, newKey)
+    if (!Array.isArray(remainingMembers) || !remainingMembers.length) return
+    const ssbClient = await openSsb()
+    const ssbKeys = require("../server/node_modules/ssb-keys")
+    const memberKeys = {}
+    for (const m of remainingMembers) {
+      try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {}
+    }
+    if (Object.keys(memberKeys).length) {
+      await new Promise((resolve) => {
+        ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: newGen, memberKeys }, () => resolve())
+      })
+    }
+  }
+
+  const ingestOwnTribeKeys = async () => {
+    if (!ownCrypto) return
+    try {
+      const ssbClient = await openSsb()
+      const ssbKeys = require("../server/node_modules/ssb-keys")
+      const config = require("../server/ssb_config")
+      const msgs = await readAll(ssbClient)
+      for (const m of msgs) {
+        const c = m.value && m.value.content
+        if (!c || c.type !== "tribe-keys") continue
+        const memberKeys = c.memberKeys
+        if (!memberKeys || typeof memberKeys !== "object") continue
+        const boxed = memberKeys[ssbClient.id]
+        if (!boxed) continue
+        try {
+          const unboxed = ssbKeys.unbox(boxed, config.keys)
+          const key = typeof unboxed === "string" ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null)
+          if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key)
+        } catch (_) {}
+      }
+    } catch (_) {}
+  }
+
   const readAll = async (ssbClient) =>
     new Promise((resolve, reject) =>
       pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
@@ -163,6 +207,31 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
   return {
     type: "calendar",
 
+    async ingestKeys() { await ingestOwnTribeKeys() },
+
+    async pruneOrphanKeys() {
+      if (!ownCrypto || typeof ownCrypto.getAllRootIds !== "function") return 0
+      try {
+        const ssbClient = await openSsb()
+        const messages = await readAll(ssbClient)
+        const live = new Set()
+        const tomb = buildValidatedTombstoneSet(messages)
+        for (const m of messages) {
+          const c = m.value && m.value.content
+          if (!c) continue
+          if (c.type === "calendar") live.add(m.key)
+        }
+        const all = ownCrypto.getAllRootIds()
+        let removed = 0
+        for (const rid of all) {
+          if (!live.has(rid) || tomb.has(rid)) {
+            try { ownCrypto.dropKey(rid); removed += 1 } catch (_) {}
+          }
+        }
+        return removed
+      } catch (_) { return 0 }
+    },
+
     async resolveRootId(id) {
       const ssbClient = await openSsb()
       const messages = await readAll(ssbClient)
@@ -235,7 +304,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
         if (validStatus === "OPEN") {
           try {
             const pubCode = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
-            const ek = tribeCrypto.encryptForInvite(calKey, pubCode)
+            const inviteSalt = tribeCrypto.generateInviteSalt()
+            const ek = tribeCrypto.encryptForInvite(calKey, pubCode, inviteSalt)
             const tipId = await this.resolveCurrentId(calendarId)
             const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
             const dec = decryptCalendarRoot(item.content, calendarId)
@@ -247,7 +317,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
               tags: Array.isArray(dec.tags) ? dec.tags : [],
               author: userId,
               participants: [userId],
-              invites: [{ code: pubCode, ek, gen: 1, public: true }],
+              invites: [{ code: pubCode, ek, salt: inviteSalt, gen: 1, public: true }],
               createdAt: dec.createdAt,
               updatedAt: new Date().toISOString(),
               replaces: tipId
@@ -418,11 +488,28 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
       if (item.content.tribeId) updated = await encryptIfTribe(updated)
       else updated = encryptStandalone(updated, rootId)
       const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
+      try { await rotateCalendarKey(rootId, updated.participants) } catch (_) {}
       const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
       await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
       return result
     },
 
+    async findCalendarByLinkText(linkSubstring) {
+      if (!linkSubstring) return null
+      const ssbClient = await openSsb()
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+      await decryptIndexNodes(idx)
+      for (const node of idx.nodes.values()) {
+        const c = node && node.c
+        if (!c || c.type !== "calendarNote") continue
+        if (typeof c.text === "string" && c.text.includes(linkSubstring)) {
+          return c.calendarId || null
+        }
+      }
+      return null
+    },
+
     async getCalendarById(id) {
       const ssbClient = await openSsb()
       const messages = await readAll(ssbClient)
@@ -798,8 +885,9 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
       const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
       let invite = code
       if (tribeCrypto && !cal.tribeId) {
-        const ekChain = tribeCrypto.encryptChainForInvite([cal.rootId], code)
-        if (ekChain) invite = { code, ekChain, gen: lookupGen(cal.rootId) }
+        const inviteSalt = tribeCrypto.generateInviteSalt()
+        const ekChain = tribeCrypto.encryptChainForInvite([cal.rootId], code, inviteSalt)
+        if (ekChain) invite = { code, ekChain, salt: inviteSalt, gen: lookupGen(cal.rootId) }
       }
       const tipId = await this.resolveCurrentId(calendarId)
       const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
@@ -848,7 +936,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
       let calKey = null
       if (tribeCrypto && typeof matchedInvite === "object") {
         if (matchedInvite.ekChain) {
-          const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code)
+          const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt)
           if (Array.isArray(chain) && chain.length) {
             for (const entry of chain) {
               if (Array.isArray(entry.keys) && entry.keys.length) {
@@ -860,7 +948,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
             calKey = chain[0].key
           }
         } else if (matchedInvite.ek) {
-          calKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code)
+          calKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt)
           ownCrypto.setKey(matched.rootId, calKey, matchedInvite.gen || 1)
         }
       }

+ 93 - 16
src/models/chats_model.js

@@ -1,6 +1,7 @@
 const pull = require("../server/node_modules/pull-stream")
 const crypto = require("crypto")
 const { getConfig } = require("../configs/config-manager.js")
+const { buildValidatedTombstoneSet } = require('./tombstone_validator')
 const logLimit = getConfig().ssbLogStream?.limit || 1000
 
 const safeArr = (v) => (Array.isArray(v) ? v : [])
@@ -27,6 +28,49 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
   }
   const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0)
 
+  const rotateChatKey = async (rootId, remainingMembers) => {
+    if (!ownCrypto || !tribeCrypto || !rootId) return
+    const existing = lookupKey(rootId)
+    if (!existing) return
+    const newKey = ownCrypto.generateTribeKey()
+    const newGen = ownCrypto.addNewKey(rootId, newKey)
+    if (!Array.isArray(remainingMembers) || !remainingMembers.length) return
+    const ssbClient = await openSsb()
+    const ssbKeys = require("../server/node_modules/ssb-keys")
+    const memberKeys = {}
+    for (const m of remainingMembers) {
+      try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {}
+    }
+    if (Object.keys(memberKeys).length) {
+      await new Promise((resolve) => {
+        ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: newGen, memberKeys }, () => resolve())
+      })
+    }
+  }
+
+  const ingestOwnTribeKeys = async () => {
+    if (!ownCrypto) return
+    try {
+      const ssbClient = await openSsb()
+      const ssbKeys = require("../server/node_modules/ssb-keys")
+      const config = require("../server/ssb_config")
+      const msgs = await readAll(ssbClient)
+      for (const m of msgs) {
+        const c = m.value && m.value.content
+        if (!c || c.type !== "tribe-keys") continue
+        const memberKeys = c.memberKeys
+        if (!memberKeys || typeof memberKeys !== "object") continue
+        const boxed = memberKeys[ssbClient.id]
+        if (!boxed) continue
+        try {
+          const unboxed = ssbKeys.unbox(boxed, config.keys)
+          const key = typeof unboxed === "string" ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null)
+          if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key)
+        } catch (_) {}
+      }
+    } catch (_) {}
+  }
+
   const getTribeKeysFor = async (tribeId) => {
     if (!tribeCrypto || !tribesModel || !tribeId) return []
     try {
@@ -97,11 +141,17 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
     if (rawC.type !== "chat") return null
 
     let c = rawC
+    let undecryptable = false
     if (tribeCrypto && c.encryptedPayload) {
       const keyChainSets = resolveKeyChainSets(rootId)
       c = tribeCrypto.decryptContent(c, keyChainSets)
+      undecryptable = !!c._undecryptable
     }
 
+    const invites = safeArr(c.invites)
+    const hasPublicInvite = invites.some(inv => typeof inv === "object" && inv && inv.public === true)
+    const inferredStatus = c.status || (undecryptable ? (hasPublicInvite ? "OPEN" : "INVITE-ONLY") : "OPEN")
+
     return {
       key: node.key,
       rootId,
@@ -109,15 +159,16 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
       description: c.description || "",
       image: c.image || null,
       category: c.category || "",
-      status: c.status || "OPEN",
+      status: inferredStatus,
       tags: safeArr(c.tags),
       members: safeArr(c.members),
-      invites: safeArr(c.invites),
+      invites,
       author: c.author || node.author,
       createdAt: c.createdAt || new Date(node.ts).toISOString(),
       updatedAt: c.updatedAt || null,
       encrypted: !!c.encrypted,
-      tribeId: c.tribeId || null
+      tribeId: c.tribeId || null,
+      undecryptable
     }
   }
 
@@ -394,6 +445,8 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
         if (!node || node.c.type !== "chat") continue
         const chat = buildChat(node, rootId)
         if (!chat) continue
+        const isMember = chat.author === uid || safeArr(chat.members).includes(uid)
+        if (chat.undecryptable && !isMember && chat.status === "INVITE-ONLY") continue
         items.push(chat)
       }
 
@@ -430,14 +483,15 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
       let invite = code
 
       if (tribeCrypto) {
-        const ekChain = tribeCrypto.encryptChainForInvite([chat.rootId], code)
+        const inviteSalt = tribeCrypto.generateInviteSalt()
+        const ekChain = tribeCrypto.encryptChainForInvite([chat.rootId], code, inviteSalt)
         if (ekChain) {
-          invite = { code, ekChain, gen: lookupGen(chat.rootId) }
+          invite = { code, ekChain, salt: inviteSalt, gen: lookupGen(chat.rootId) }
         } else {
           const chatKey = lookupKey(chat.rootId)
           if (chatKey) {
-            const ek = tribeCrypto.encryptForInvite(chatKey, code)
-            invite = { code, ek, gen: lookupGen(chat.rootId) }
+            const ek = tribeCrypto.encryptForInvite(chatKey, code, inviteSalt)
+            invite = { code, ek, salt: inviteSalt, gen: lookupGen(chat.rootId) }
           }
         }
       }
@@ -480,7 +534,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
       let chatKey = null
       if (tribeCrypto && typeof matchedInvite === "object") {
         if (matchedInvite.ekChain) {
-          const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code)
+          const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt)
           if (Array.isArray(chain) && chain.length) {
             for (const entry of chain) {
               if (Array.isArray(entry.keys) && entry.keys.length) {
@@ -492,7 +546,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
             chatKey = chain[0].key
           }
         } else if (matchedInvite.ek) {
-          chatKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code)
+          chatKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt)
           ownCrypto.setKey(matchedChat.rootId, chatKey, matchedInvite.gen || 1)
         }
       }
@@ -551,6 +605,32 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
       if (chat.author === userId) throw new Error("Author cannot leave their own chat")
       const members = chat.members.filter(m => m !== userId)
       await this.updateChatById(chatId, { members, invites: chat.invites, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags }, { skipAuthorCheck: true })
+      try { await rotateChatKey(chat.rootId, members) } catch (_) {}
+    },
+
+    async ingestKeys() { await ingestOwnTribeKeys() },
+
+    async pruneOrphanKeys() {
+      if (!ownCrypto || typeof ownCrypto.getAllRootIds !== "function") return 0
+      try {
+        const ssbClient = await openSsb()
+        const messages = await readAll(ssbClient)
+        const live = new Set()
+        for (const m of messages) {
+          const c = m.value && m.value.content
+          if (!c) continue
+          if (c.type === "chat") live.add(m.key)
+        }
+        const tomb = buildValidatedTombstoneSet(messages)
+        const all = ownCrypto.getAllRootIds()
+        let removed = 0
+        for (const rid of all) {
+          if (!live.has(rid) || tomb.has(rid)) {
+            try { ownCrypto.dropKey(rid); removed += 1 } catch (_) {}
+          }
+        }
+        return removed
+      } catch (_) { return 0 }
     },
 
     async sendMessage(chatId, text, image = null) {
@@ -585,14 +665,11 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
         let encKey = null
         if (chat.tribeId) encKey = await getTribeFirstKeyFor(chat.tribeId)
         if (!encKey) encKey = lookupKey(chat.rootId)
-        if (encKey) {
-          content.encryptedText = tribeCrypto.encryptWithKey(safeText(text), encKey)
-          if (chat.tribeId) content.tribeId = chat.tribeId
-        } else {
-          content.text = safeText(text)
-        }
+        if (!encKey) throw new Error(`Missing chat key for ${chat.rootId} — cannot send message`)
+        content.encryptedText = tribeCrypto.encryptWithKey(safeText(text), encKey)
+        if (chat.tribeId) content.tribeId = chat.tribeId
       } else {
-        content.text = safeText(text)
+        throw new Error('Chat crypto unavailable — cannot send message')
       }
 
       return new Promise((resolve, reject) => {

+ 3 - 3
src/models/courts_model.js

@@ -1,6 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const CASE_ANSWER_DAYS = 7;
@@ -81,15 +82,14 @@ module.exports = ({ cooler, services = {}, tribeCrypto }) => {
 
   async function listByType(type) {
     const msgs = await readLog();
-    const tomb = new Set();
+    const tomb = buildValidatedTombstoneSet(msgs);
     const rep = new Map();
     const map = new Map();
     for (const m of msgs) {
       const k = m.key || m.id;
       const c = m.value?.content || m.content;
       if (!c) continue;
-      if (c.type === 'tombstone' && c.target) tomb.add(c.target);
-      if (c.type === type) {
+if (c.type === type) {
         if (c.replaces) rep.set(c.replaces, k);
         map.set(k, { id: k, ...c });
       }

+ 12 - 20
src/models/crypto.js

@@ -435,15 +435,11 @@ module.exports = (configPath, namespace = 'tribes') => {
   const createHelpers = (tribesModel) => ({
     async encryptIfTribe(content) {
       if (!content || !content.tribeId || !tribesModel) return content;
-      try {
-        const rootId = await tribesModel.getRootId(content.tribeId);
-        const key = getKey(rootId);
-        if (!key) return content;
-        const body = { k: content.type, ...content };
-        return wrapMsg(body, key);
-      } catch (_) {
-        return content;
-      }
+      const rootId = await tribesModel.getRootId(content.tribeId);
+      const key = getKey(rootId);
+      if (!key) throw new Error(`Missing tribe key for ${rootId} — cannot publish encrypted content`);
+      const body = { k: content.type, ...content };
+      return wrapMsg(body, key);
     },
     async decryptIfTribe(content) {
       if (!content || !tribesModel) return content;
@@ -489,7 +485,7 @@ module.exports = (configPath, namespace = 'tribes') => {
         if (!r || !r.body) continue;
         const inner = r.body;
         if (inner.k === 'tombstone' && inner.target) {
-          const flat = { type: 'tombstone', target: inner.target, deletedAt: inner.deletedAt, author: inner.author };
+          const flat = { type: 'tombstone', target: inner.target, deletedAt: inner.deletedAt, author: inner.author, _rootId: r.rootId };
           out.push({ ...m, value: { ...m.value, content: flat } });
         } else if (kSet.has(inner.k)) {
           const flat = { ...inner, type: inner.k, _decrypted: true, _rootId: r.rootId };
@@ -500,16 +496,12 @@ module.exports = (configPath, namespace = 'tribes') => {
       return out;
     },
     async encryptTombstone(target, tribeId, author) {
-      const tombstone = { type: 'tombstone', target, deletedAt: new Date().toISOString(), author };
-      if (!tribeId || !tribesModel) return tombstone;
-      try {
-        const rootId = await tribesModel.getRootId(tribeId);
-        const key = getKey(rootId);
-        if (!key) return tombstone;
-        return wrapMsg({ k: 'tombstone', target, deletedAt: tombstone.deletedAt, author }, key);
-      } catch (_) {
-        return tombstone;
-      }
+      const deletedAt = new Date().toISOString();
+      if (!tribeId || !tribesModel) return { type: 'tombstone', target, deletedAt, author };
+      const rootId = await tribesModel.getRootId(tribeId);
+      const key = getKey(rootId);
+      if (!key) throw new Error(`Missing tribe key for ${rootId} — cannot publish encrypted tombstone`);
+      return wrapMsg({ k: 'tombstone', target, deletedAt, author }, key);
     }
   });
 

+ 2 - 5
src/models/cv_model.js

@@ -1,5 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 const extractBlobId = str => {
@@ -148,11 +149,7 @@ module.exports = ({ cooler }) => {
           pull.collect((err, msgs) => {
             if (err) return reject(err);
 
-            const tombstoned = new Set(
-              msgs
-                .filter(m => m.value?.content?.type === 'tombstone' && m.value.content.target)
-                .map(m => m.value.content.target)
-            );
+            const tombstoned = buildValidatedTombstoneSet(msgs);
 
             const cvMsgs = msgs
               .filter(m =>

+ 8 - 7
src/models/documents_model.js

@@ -1,6 +1,7 @@
 const pull = require("../server/node_modules/pull-stream");
 const { getConfig } = require("../configs/config-manager.js");
 const categories = require("../backend/opinion_categories");
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const mediaFavorites = require("../backend/media-favorites");
 
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
@@ -41,7 +42,7 @@ module.exports = ({ cooler }) => {
     });
 
   const buildIndex = (messages) => {
-    const tomb = new Set();
+    const tomb = buildValidatedTombstoneSet(messages);
     const nodes = new Map();
     const parent = new Map();
     const child = new Map();
@@ -52,15 +53,14 @@ module.exports = ({ cooler }) => {
       const c = v.content;
       if (!c) continue;
 
-      if (c.type === "tombstone" && c.target) {
-        tomb.add(c.target);
-        continue;
-      }
+      if (c.type === "tombstone") continue;
 
       if (c.type !== "document") continue;
 
       const ts = v.timestamp || m.timestamp || 0;
-      nodes.set(k, { key: k, ts, c });
+      let sizeBytes = 0;
+      try { sizeBytes = Buffer.byteLength(JSON.stringify(v), "utf8"); } catch (_) { sizeBytes = 0; }
+      nodes.set(k, { key: k, ts, c, sizeBytes });
 
       if (c.replaces) {
         parent.set(k, c.replaces);
@@ -105,7 +105,8 @@ module.exports = ({ cooler }) => {
       title: c.title || "",
       description: c.description || "",
       opinions: c.opinions || {},
-      opinions_inhabitants: safeArr(c.opinions_inhabitants)
+      opinions_inhabitants: safeArr(c.opinions_inhabitants),
+      sizeBytes: node.sizeBytes || 0
     };
   };
 

+ 294 - 25
src/models/events_model.js

@@ -1,13 +1,57 @@
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
+const crypto = require('crypto');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
-module.exports = ({ cooler }) => {
+module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
   const me = async () => (await openSsb()).id;
 
+  const ownCrypto = eventCrypto || tribeCrypto;
+  const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null;
+
+  const readAll = async (ssbClient) =>
+    new Promise((resolve, reject) =>
+      pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
+    );
+
+  const ingestOwnTribeKeys = async () => {
+    if (!ownCrypto) return;
+    try {
+      const ssbClient = await openSsb();
+      const ssbKeys = require("../server/node_modules/ssb-keys");
+      const cfg = require("../server/ssb_config");
+      const msgs = await readAll(ssbClient);
+      for (const m of msgs) {
+        const c = m.value && m.value.content;
+        if (!c || c.type !== "tribe-keys") continue;
+        const memberKeys = c.memberKeys;
+        if (!memberKeys || typeof memberKeys !== "object") continue;
+        const boxed = memberKeys[ssbClient.id];
+        if (!boxed) continue;
+        try {
+          const unboxed = ssbKeys.unbox(boxed, cfg.keys);
+          const key = typeof unboxed === "string" ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null);
+          if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key);
+        } catch (_) {}
+      }
+    } catch (_) {}
+  };
+
+  const decryptEventContent = (rawContent, eventId) => {
+    if (!rawContent) return null;
+    if (!rawContent.encryptedPayload) return rawContent;
+    if (!ownCrypto || !tribeCrypto) return { ...rawContent, _undecryptable: true };
+    const keys = (ownCrypto.getKeys && ownCrypto.getKeys(eventId)) || [];
+    if (!keys.length) return { ...rawContent, _undecryptable: true };
+    const dec = tribeCrypto.decryptContent(rawContent, keys.map(k => [k]));
+    if (!dec || dec._undecryptable) return { ...rawContent, _undecryptable: true };
+    return { ...dec, _decrypted: true };
+  };
+
   const uniq = (arr) => Array.from(new Set((Array.isArray(arr) ? arr : []).filter(x => typeof x === 'string' && x.trim().length)));
 
   const normalizePrivacy = (v) => {
@@ -38,6 +82,27 @@ module.exports = ({ cooler }) => {
   return {
     type: 'event',
 
+    async ingestKeys() { await ingestOwnTribeKeys(); },
+
+    async resolveRootId(id) {
+      const ssbClient = await openSsb();
+      const messages = await readAll(ssbClient);
+      const replaces = new Map();
+      for (const m of messages) {
+        const c = m.value && m.value.content;
+        if (!c || c.type !== 'event') continue;
+        if (c.replaces) replaces.set(m.key, c.replaces);
+      }
+      let cur = id;
+      const seen = new Set();
+      while (replaces.has(cur)) {
+        if (seen.has(cur)) break;
+        seen.add(cur);
+        cur = replaces.get(cur);
+      }
+      return cur;
+    },
+
     async createEvent(title, description, date, location, price = 0, url = "", attendees = [], tagsRaw = [], isPublic, mapUrl = "", clearnetPublic = false) {
       const ssbClient = await openSsb();
       const userId = await me();
@@ -53,7 +118,8 @@ module.exports = ({ cooler }) => {
         ? tagsRaw.filter(Boolean)
         : String(tagsRaw || '').split(',').map(s => s.trim()).filter(Boolean);
 
-      const content = {
+      const visibility = normalizePrivacy(isPublic);
+      const plainContent = {
         type: 'event',
         title,
         description,
@@ -66,47 +132,166 @@ module.exports = ({ cooler }) => {
         createdAt: new Date().toISOString(),
         organizer: userId,
         status: 'OPEN',
-        isPublic: normalizePrivacy(isPublic),
+        isPublic: visibility,
         mapUrl: String(mapUrl || "").trim(),
-        clearnetPublic: clearnetPublic === true || clearnetPublic === 'true' || clearnetPublic === 'on'
+        clearnetPublic: clearnetPublic === true || clearnetPublic === 'true' || clearnetPublic === 'on',
+        opinions: {},
+        opinions_inhabitants: []
       };
 
-      return new Promise((resolve, reject) => {
+      const shouldEncrypt = visibility === 'private' && ownCrypto && tribeCrypto;
+      let eventKey = null;
+      let content = plainContent;
+      if (shouldEncrypt) {
+        eventKey = ownCrypto.generateTribeKey();
+        content = tribeCrypto.encryptContent(plainContent, [eventKey], true);
+      }
+
+      const result = await new Promise((resolve, reject) => {
         ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res));
       });
+
+      if (eventKey) {
+        ownCrypto.setKey(result.key, eventKey, 1);
+        try {
+          const ssbKeys = require("../server/node_modules/ssb-keys");
+          const memberKeys = {};
+          for (const m of attendeeList) {
+            try { memberKeys[m] = tribeCrypto.boxKeyForMember(eventKey, m, ssbKeys); } catch (_) {}
+          }
+          if (Object.keys(memberKeys).length) {
+            await new Promise((resolve) => {
+              ssbClient.publish({ type: "tribe-keys", tribeId: result.key, generation: 1, memberKeys }, () => resolve());
+            });
+          }
+        } catch (_) {}
+      }
+      return result;
+    },
+
+    async generateInvite(eventId) {
+      if (!ownCrypto || !tribeCrypto) throw new Error("Event crypto unavailable");
+      const ssbClient = await openSsb();
+      const userId = await me();
+      const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, m) => err || !m || !m.content ? rej(new Error("Error retrieving event")) : res(m)));
+      const rid = await this.resolveRootId(eventId);
+      const c = ev.content && ev.content.encryptedPayload ? decryptEventContent(ev.content, rid) : ev.content;
+      if (c && c._undecryptable) throw new Error("Event is encrypted and cannot be decrypted");
+      if (c.organizer !== userId) throw new Error("Only the organizer can generate invites");
+      if (normalizePrivacy(c.isPublic) !== 'private') throw new Error("Only private events use invitation codes");
+      const key = lookupKey(rid);
+      if (!key) throw new Error("Missing event key — cannot generate invite");
+      const code = crypto.randomBytes(16).toString('hex');
+      const inviteSalt = tribeCrypto.generateInviteSalt();
+      const ek = tribeCrypto.encryptForInvite(key, code, inviteSalt);
+      const inviteRef = { code, ek, salt: inviteSalt, gen: 1, rootId: rid };
+      await new Promise((resolve, reject) => {
+        ssbClient.publish({ type: 'event-invite', target: rid, ek, salt: inviteSalt, codeHash: tribeCrypto.hashInviteCode(code, inviteSalt) }, (err) => err ? reject(err) : resolve());
+      });
+      return { code, eventId: rid };
+    },
+
+    async joinByInvite(code) {
+      if (!ownCrypto || !tribeCrypto) throw new Error("Event crypto unavailable");
+      const ssbClient = await openSsb();
+      const userId = await me();
+      const messages = await readAll(ssbClient);
+      let matched = null;
+      for (const m of messages) {
+        const c = m.value && m.value.content;
+        if (!c || c.type !== 'event-invite') continue;
+        try {
+          const hash = tribeCrypto.hashInviteCode(code, c.salt);
+          if (hash === c.codeHash) { matched = c; break; }
+        } catch (_) {}
+      }
+      if (!matched) throw new Error("Invalid or expired invite code");
+      const eventKey = tribeCrypto.decryptFromInvite(matched.ek, code, matched.salt);
+      if (!eventKey) throw new Error("Could not decrypt invite");
+      ownCrypto.addNewKey(matched.target, eventKey);
+      await this.toggleAttendee(matched.target);
+      return { ok: true, eventId: matched.target };
     },
 
     async toggleAttendee(eventId) {
       const ssbClient = await openSsb();
       const userId = await me();
       const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, ev) => err || !ev || !ev.content ? rej(new Error("Error retrieving event")) : res(ev)));
-      const c = ev.content;
+      const rid = await this.resolveRootId(eventId);
+      const c = ev.content && ev.content.encryptedPayload ? decryptEventContent(ev.content, rid) : ev.content;
+      if (c && c._undecryptable) throw new Error("Event is encrypted and cannot be decrypted");
 
       const status = deriveStatus(c);
       if (status === 'CLOSED') throw new Error("Cannot attend a closed event");
 
       let attendees = uniq(c.attendees || []);
       const idx = attendees.indexOf(userId);
-      if (idx !== -1) attendees.splice(idx, 1); else attendees.push(userId);
+      const isLeaving = idx !== -1;
+      if (isLeaving) attendees.splice(idx, 1); else attendees.push(userId);
       attendees = uniq(attendees);
 
-      const updated = {
+      const isPrivate = normalizePrivacy(c.isPublic) === 'private';
+      const isOrganizer = c.organizer === userId;
+      let updated = {
         ...c,
         attendees,
         updatedAt: new Date().toISOString(),
         replaces: eventId
       };
+      if (isPrivate && ownCrypto && tribeCrypto) {
+        const key = lookupKey(rid);
+        if (!key) throw new Error("Missing event key — cannot update encrypted event");
+        updated = tribeCrypto.encryptContent(updated, [key], true);
+      }
 
-      return new Promise((resolve, reject) => {
+      const result = await new Promise((resolve, reject) => {
         ssbClient.publish(updated, (err2, res2) => err2 ? reject(err2) : resolve(res2));
       });
+
+      if (isPrivate && !isLeaving && ownCrypto && tribeCrypto) {
+        try {
+          const key = lookupKey(rid);
+          if (key) {
+            const ssbKeys = require("../server/node_modules/ssb-keys");
+            const memberKeys = {};
+            try { memberKeys[userId] = tribeCrypto.boxKeyForMember(key, userId, ssbKeys); } catch (_) {}
+            if (memberKeys[userId]) {
+              await new Promise((resolve) => {
+                ssbClient.publish({ type: "tribe-keys", tribeId: rid, generation: 1, memberKeys }, () => resolve());
+              });
+            }
+          }
+        } catch (_) {}
+      }
+
+      if (isPrivate && isLeaving && !isOrganizer && ownCrypto && tribeCrypto) {
+        try {
+          const newKey = ownCrypto.generateTribeKey();
+          const newGen = ownCrypto.addNewKey(rid, newKey);
+          const ssbKeys = require("../server/node_modules/ssb-keys");
+          const memberKeys = {};
+          for (const m of attendees) {
+            try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys); } catch (_) {}
+          }
+          if (Object.keys(memberKeys).length) {
+            await new Promise((resolve) => {
+              ssbClient.publish({ type: "tribe-keys", tribeId: rid, generation: newGen, memberKeys }, () => resolve());
+            });
+          }
+        } catch (_) {}
+      }
+
+      return result;
     },
 
     async deleteEventById(eventId) {
       const ssbClient = await openSsb();
       const userId = await me();
       const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, ev) => err || !ev || !ev.content ? rej(new Error("Error retrieving event")) : res(ev)));
-      if (ev.content.organizer !== userId) throw new Error("Only the organizer can delete this event");
+      const rid = await this.resolveRootId(eventId);
+      const c = ev.content && ev.content.encryptedPayload ? decryptEventContent(ev.content, rid) : ev.content;
+      if (c && c._undecryptable) throw new Error("Event is encrypted and cannot be decrypted");
+      if (c.organizer !== userId) throw new Error("Only the organizer can delete this event");
       const tombstone = { type: 'tombstone', target: eventId, deletedAt: new Date().toISOString(), author: userId };
       return new Promise((resolve, reject) => {
         ssbClient.publish(tombstone, (err, res) => err ? reject(err) : resolve(res));
@@ -116,7 +301,9 @@ module.exports = ({ cooler }) => {
     async getEventById(eventId) {
       const ssbClient = await openSsb();
       const msg = await new Promise((res, rej) => ssbClient.get(eventId, (err, msg) => err || !msg || !msg.content ? rej(new Error("Error retrieving event")) : res(msg)));
-      const c = msg.content;
+      const rid = await this.resolveRootId(eventId).catch(() => eventId);
+      const c = msg.content && msg.content.encryptedPayload ? decryptEventContent(msg.content, rid) : msg.content;
+      if (c && c._undecryptable) throw new Error("Event is encrypted and cannot be decrypted with available keys");
 
       const status = deriveStatus(c);
 
@@ -136,7 +323,10 @@ module.exports = ({ cooler }) => {
         status,
         isPublic: normalizePrivacy(c.isPublic),
         mapUrl: c.mapUrl || "",
-        clearnetPublic: !!c.clearnetPublic
+        clearnetPublic: !!c.clearnetPublic,
+        encrypted: normalizePrivacy(c.isPublic) === 'private',
+        opinions: c.opinions || {},
+        opinions_inhabitants: Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : []
       };
     },
 
@@ -144,9 +334,10 @@ module.exports = ({ cooler }) => {
       const ssbClient = await openSsb();
       const userId = await me();
       const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, ev) => err || !ev || !ev.content ? rej(new Error("Error retrieving event")) : res(ev)));
-      if (ev.content.organizer !== userId) throw new Error("Only the organizer can update this event");
-
-      const c = ev.content;
+      const rid = await this.resolveRootId(eventId);
+      const c = ev.content && ev.content.encryptedPayload ? decryptEventContent(ev.content, rid) : ev.content;
+      if (c && c._undecryptable) throw new Error("Event is encrypted and cannot be decrypted");
+      if (c.organizer !== userId) throw new Error("Only the organizer can update this event");
       const status = deriveStatus(c);
       if (status === 'CLOSED') throw new Error("Cannot edit a closed event");
 
@@ -162,7 +353,7 @@ module.exports = ({ cooler }) => {
 
       if (moment(date).isBefore(moment().startOf('minute'))) throw new Error("Cannot set an event in the past");
 
-      const updated = {
+      let updated = {
         ...c,
         title: updatedData.title ?? c.title,
         description: updatedData.description ?? c.description,
@@ -178,9 +369,63 @@ module.exports = ({ cooler }) => {
         replaces: eventId
       };
 
-      return new Promise((resolve, reject) => {
+      const wasPrivate = normalizePrivacy(c.isPublic) === 'private';
+      const isPrivate = updated.isPublic === 'private';
+      let newKey = null;
+      if (isPrivate && ownCrypto && tribeCrypto) {
+        let key = lookupKey(rid);
+        if (!key) {
+          newKey = ownCrypto.generateTribeKey();
+          ownCrypto.setKey(rid, newKey, 1);
+          key = newKey;
+        }
+        updated = tribeCrypto.encryptContent(updated, [key], true);
+      }
+
+      const result = await new Promise((resolve, reject) => {
         ssbClient.publish(updated, (err2, res2) => err2 ? reject(err2) : resolve(res2));
       });
+
+      if (newKey && tribeCrypto) {
+        try {
+          const ssbKeys = require("../server/node_modules/ssb-keys");
+          const memberKeys = {};
+          for (const m of (Array.isArray(c.attendees) ? c.attendees : [userId])) {
+            try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys); } catch (_) {}
+          }
+          if (Object.keys(memberKeys).length) {
+            await new Promise((resolve) => {
+              ssbClient.publish({ type: "tribe-keys", tribeId: rid, generation: 1, memberKeys }, () => resolve());
+            });
+          }
+        } catch (_) {}
+      }
+
+      return result;
+    },
+
+    async createOpinion(id, category) {
+      const categories = require('../backend/opinion_categories');
+      if (!categories.includes(category)) throw new Error('Invalid opinion category');
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const rid = await this.resolveRootId(id).catch(() => id);
+      const ev = await new Promise((res, rej) => ssbClient.get(id, (err, m) => (err || !m || !m.content) ? rej(new Error('Event not found')) : res(m)));
+      const c = ev.content && ev.content.encryptedPayload ? decryptEventContent(ev.content, rid) : ev.content;
+      if (c && c._undecryptable) throw new Error('Event is encrypted and cannot be decrypted');
+      const list = Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : [];
+      if (list.includes(userId)) throw new Error('Already opined');
+      const opinions = Object.assign({}, c.opinions || {});
+      opinions[category] = (opinions[category] || 0) + 1;
+      let updated = { ...c, opinions, opinions_inhabitants: list.concat(userId), updatedAt: new Date().toISOString(), replaces: id };
+      const isPrivate = normalizePrivacy(c.isPublic) === 'private';
+      if (isPrivate && ownCrypto && tribeCrypto) {
+        const key = lookupKey(rid);
+        if (key) updated = tribeCrypto.encryptContent(updated, [key], true);
+      }
+      const tombstone = { type: 'tombstone', target: id, deletedAt: new Date().toISOString(), author: userId };
+      await new Promise((res, rej) => ssbClient.publish(tombstone, err => err ? rej(err) : res()));
+      return new Promise((res, rej) => ssbClient.publish(updated, (err, result) => err ? rej(err) : res(result)));
     },
 
     async listAll(author = null, filter = 'all') {
@@ -191,21 +436,42 @@ module.exports = ({ cooler }) => {
           ssbClient.createLogStream({ limit: logLimit }),
           pull.collect((err, results) => {
             if (err) return reject(new Error("Error listing events: " + err.message));
-            const tombstoned = new Set();
+            const tombstoned = buildValidatedTombstoneSet(results);
             const replaces = new Map();
             const byId = new Map();
+            const replacesChain = new Map();
+            for (const r of results) {
+              const c = r.value && r.value.content;
+              if (c && c.type === 'event' && c.replaces) replacesChain.set(r.key, c.replaces);
+            }
+            const findRoot = (id) => {
+              let cur = id;
+              const seen = new Set();
+              while (replacesChain.has(cur)) {
+                if (seen.has(cur)) break;
+                seen.add(cur);
+                cur = replacesChain.get(cur);
+              }
+              return cur;
+            };
 
             for (const r of results) {
               const k = r.key;
-              const c = r.value && r.value.content;
-              if (!c) continue;
+              const rawC = r.value && r.value.content;
+              if (!rawC) continue;
 
-              if (c.type === 'tombstone' && c.target) {
-                tombstoned.add(c.target);
-                continue;
+              if (rawC.type === 'tombstone') continue;
+
+              if (rawC.type !== 'event' && !(rawC.encryptedPayload && rawC.type === 'event')) {
+                // either it's not an event, or it's encrypted but type field should still say 'event'
+                if (rawC.type !== 'event' && !rawC.encryptedPayload) continue;
               }
 
-              if (c.type === 'event') {
+              if (rawC.type === 'event' || rawC.encryptedPayload) {
+                const rid = findRoot(k);
+                const c = rawC.encryptedPayload ? decryptEventContent(rawC, rid) : rawC;
+                if (!c || c._undecryptable) continue;
+
                 if (c.replaces) replaces.set(c.replaces, k);
                 if (author && c.organizer !== author) continue;
 
@@ -225,7 +491,10 @@ module.exports = ({ cooler }) => {
                   organizer: c.organizer || '',
                   status,
                   isPublic: normalizePrivacy(c.isPublic),
-                  mapUrl: c.mapUrl || ""
+                  mapUrl: c.mapUrl || "",
+                  encrypted: normalizePrivacy(c.isPublic) === 'private',
+                  opinions: c.opinions || {},
+                  opinions_inhabitants: Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : []
                 });
               }
             }

+ 18 - 14
src/models/feed_model.js

@@ -1,6 +1,7 @@
 const pull = require("../server/node_modules/pull-stream");
 const { getConfig } = require("../configs/config-manager.js");
 const categories = require("../backend/opinion_categories");
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 const FEED_TEXT_MIN = Number(getConfig().feed?.minLength ?? 1);
@@ -40,7 +41,7 @@ module.exports = ({ cooler }) => {
 
     const forward = new Map();
     const replacedIds = new Set();
-    const tombstoned = new Set();
+    const tombstoned = buildValidatedTombstoneSet(messages);
     const feedsById = new Map();
     const actions = [];
 
@@ -48,10 +49,7 @@ module.exports = ({ cooler }) => {
       const c = msg?.value?.content;
       const k = msg?.key;
       if (!c || !k) continue;
-      if (c.type === "tombstone" && c.target) {
-        tombstoned.add(c.target);
-        continue;
-      }
+      if (c.type === 'tombstone') continue;
       if (c.type === "feed") {
         feedsById.set(k, msg);
         if (c.replaces) {
@@ -179,23 +177,29 @@ module.exports = ({ cooler }) => {
     if (!c || c.type !== "feed") throw new Error("Invalid feed");
     if (!isValidFeedText(c.text)) throw new Error("Invalid feed");
 
+    const contentVoters = new Set(Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : []);
     const existing = idx.actionsByRoot.get(tipId) || [];
     for (const a of existing) {
       const ac = a?.value?.content || {};
       if (ac.type === "feed-action" && ac.action === "vote" && a.value?.author === userId) throw new Error("Already voted");
     }
-
-    const action = {
-      type: "feed-action",
-      action: "vote",
-      category,
-      root: tipId,
-      createdAt: new Date().toISOString(),
-      author: userId
+    if (contentVoters.has(userId)) throw new Error("Already voted");
+
+    const now = new Date().toISOString();
+    const prevOpinions = c.opinions && typeof c.opinions === "object" ? c.opinions : {};
+    const updated = {
+      ...c,
+      replaces: tipId,
+      opinions: { ...prevOpinions, [category]: (Number(prevOpinions[category]) || 0) + 1 },
+      opinions_inhabitants: Array.from(contentVoters).concat(userId),
+      updatedAt: now
     };
 
+    const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
+    await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
+
     return new Promise((resolve, reject) => {
-      ssbClient.publish(action, (err, result) => (err ? reject(err) : resolve(result)));
+      ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
     });
   };
 

+ 180 - 31
src/models/forum_model.js

@@ -1,9 +1,56 @@
 const pull = require('../server/node_modules/pull-stream');
+const crypto = require('crypto');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
-module.exports = ({ cooler }) => {
+module.exports = ({ cooler, tribeCrypto, forumCrypto }) => {
   let ssb, userId;
+  const ownCrypto = forumCrypto || tribeCrypto;
+  const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null;
+
+  const decryptForumContent = (rawContent, rootId) => {
+    if (!rawContent) return rawContent;
+    if (!rawContent.encryptedPayload) return rawContent;
+    if (!ownCrypto || !tribeCrypto) return { ...rawContent, _undecryptable: true };
+    let keys = (rootId && ownCrypto.getKeys && ownCrypto.getKeys(rootId)) || [];
+    if (!keys.length && typeof ownCrypto.getAllRootIds === 'function') {
+      const seen = new Set();
+      for (const rid of ownCrypto.getAllRootIds()) {
+        const ks = ownCrypto.getKeys(rid) || [];
+        for (const k of ks) if (!seen.has(k)) { seen.add(k); keys.push(k); }
+      }
+    }
+    if (!keys.length) return { ...rawContent, _undecryptable: true };
+    const dec = tribeCrypto.decryptContent(rawContent, keys.map(k => [k]));
+    if (!dec || dec._undecryptable) return { ...rawContent, _undecryptable: true };
+    return { ...dec, _decrypted: true };
+  };
+
+  const ingestOwnTribeKeys = async () => {
+    if (!ownCrypto) return;
+    try {
+      const ssbClient = await openSsb();
+      const ssbKeys = require('../server/node_modules/ssb-keys');
+      const cfg = require('../server/ssb_config');
+      const msgs = await new Promise((res, rej) =>
+        pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((e, m) => e ? rej(e) : res(m)))
+      );
+      for (const m of msgs) {
+        const c = m.value && m.value.content;
+        if (!c || c.type !== 'tribe-keys') continue;
+        const memberKeys = c.memberKeys;
+        if (!memberKeys || typeof memberKeys !== 'object') continue;
+        const boxed = memberKeys[ssbClient.id];
+        if (!boxed) continue;
+        try {
+          const unboxed = ssbKeys.unbox(boxed, cfg.keys);
+          const key = typeof unboxed === 'string' ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null);
+          if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key);
+        } catch (_) {}
+      }
+    } catch (_) {}
+  };
 
   const openSsb = async () => {
     if (!ssb) {
@@ -15,11 +62,9 @@ module.exports = ({ cooler }) => {
 
   async function collectTombstones(ssbClient) {
     return new Promise((resolve, reject) => {
-      const tomb = new Set();
       pull(
         ssbClient.createLogStream({ limit: logLimit }),
-        pull.filter(m => m.value.content?.type === 'tombstone' && m.value.content.target),
-        pull.drain(m => tomb.add(m.value.content.target), err => err ? reject(err) : resolve(tomb))
+        pull.collect((err, msgs) => err ? reject(err) : resolve(buildValidatedTombstoneSet(msgs)))
       );
     });
   }
@@ -79,9 +124,12 @@ module.exports = ({ cooler }) => {
   }
 
   return {
-    createForum: async (category, title, text) => {
+    ingestKeys: async () => { await ingestOwnTribeKeys(); },
+
+    createForum: async (category, title, text, isPrivate = false) => {
       const ssbClient = await openSsb();
-      const content = {
+      const isPrivateFlag = isPrivate === true || isPrivate === 'true' || isPrivate === 'on';
+      const plainContent = {
         type: 'forum',
         category,
         title,
@@ -89,16 +137,40 @@ module.exports = ({ cooler }) => {
         createdAt: new Date().toISOString(),
         author: userId,
         votes: { positives: 0, negatives: 0 },
-        votes_inhabitants: []
+        votes_inhabitants: [],
+        isPrivate: isPrivateFlag
       };
-      return new Promise((resolve, reject) =>
-        ssbClient.publish(content, (err, res) => err ? reject(err) : resolve({ key: res.key, ...content }))
+      let content = plainContent;
+      let forumKey = null;
+      if (isPrivateFlag && ownCrypto && tribeCrypto) {
+        forumKey = ownCrypto.generateTribeKey();
+        content = tribeCrypto.encryptContent(plainContent, [forumKey], true);
+      }
+      const result = await new Promise((resolve, reject) =>
+        ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res))
       );
+      if (forumKey) {
+        ownCrypto.setKey(result.key, forumKey, 1);
+        try {
+          const ssbKeys = require('../server/node_modules/ssb-keys');
+          const memberKeys = { [userId]: tribeCrypto.boxKeyForMember(forumKey, userId, ssbKeys) };
+          await new Promise((resolve) => {
+            ssbClient.publish({ type: 'tribe-keys', tribeId: result.key, generation: 1, memberKeys }, () => resolve());
+          });
+        } catch (_) {}
+      }
+      return { key: result.key, ...plainContent };
     },
 
     addMessageToForum: async (forumId, message, parentId = null) => {
       const ssbClient = await openSsb();
-      const content = {
+      const rawRoot = await new Promise((res, rej) => ssbClient.get(forumId, (e, m) => e ? rej(e) : res(m)));
+      const rootRaw = rawRoot && rawRoot.content;
+      const rootDec = rootRaw && rootRaw.encryptedPayload
+        ? decryptForumContent(rootRaw, forumId)
+        : rootRaw;
+      const isPrivate = rootDec && rootDec.isPrivate === true;
+      let content = {
         ...message,
         root: forumId,
         type: 'forum',
@@ -108,11 +180,59 @@ module.exports = ({ cooler }) => {
         votes_inhabitants: []
       };
       if (parentId) content.branch = parentId;
+      if (isPrivate && ownCrypto && tribeCrypto) {
+        const key = lookupKey(forumId);
+        if (!key) throw new Error('Missing forum key — cannot reply to encrypted forum');
+        content = tribeCrypto.encryptContent(content, [key], true);
+      }
       return new Promise((resolve, reject) =>
         ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res))
       );
     },
 
+    generateInvite: async (forumId) => {
+      if (!ownCrypto || !tribeCrypto) throw new Error('Forum crypto unavailable');
+      const ssbClient = await openSsb();
+      const rawRoot = await new Promise((res, rej) => ssbClient.get(forumId, (e, m) => e ? rej(e) : res(m)));
+      if (!rawRoot || !rawRoot.content) throw new Error('Forum not found');
+      const rawC = rawRoot.content;
+      const dec = rawC && rawC.encryptedPayload ? decryptForumContent(rawC, forumId) : rawC;
+      if (dec && dec._undecryptable) throw new Error('Forum is encrypted and cannot be decrypted');
+      if (dec.author !== userId) throw new Error('Only the author can generate invites');
+      if (dec.isPrivate !== true) throw new Error('Only private forums use invitation codes');
+      const key = lookupKey(forumId);
+      if (!key) throw new Error('Missing forum key');
+      const code = crypto.randomBytes(16).toString('hex');
+      const inviteSalt = tribeCrypto.generateInviteSalt();
+      const ek = tribeCrypto.encryptForInvite(key, code, inviteSalt);
+      await new Promise((resolve, reject) => {
+        ssbClient.publish({ type: 'forum-invite', target: forumId, ek, salt: inviteSalt, codeHash: tribeCrypto.hashInviteCode(code, inviteSalt) }, (err) => err ? reject(err) : resolve());
+      });
+      return { code, forumId };
+    },
+
+    joinByInvite: async (code) => {
+      if (!ownCrypto || !tribeCrypto) throw new Error('Forum crypto unavailable');
+      const ssbClient = await openSsb();
+      const messages = await new Promise((res, rej) =>
+        pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((e, m) => e ? rej(e) : res(m)))
+      );
+      let matched = null;
+      for (const m of messages) {
+        const c = m.value && m.value.content;
+        if (!c || c.type !== 'forum-invite') continue;
+        try {
+          const hash = tribeCrypto.hashInviteCode(code, c.salt);
+          if (hash === c.codeHash) { matched = c; break; }
+        } catch (_) {}
+      }
+      if (!matched) throw new Error('Invalid or expired invite code');
+      const forumKey = tribeCrypto.decryptFromInvite(matched.ek, code, matched.salt);
+      if (!forumKey) throw new Error('Could not decrypt invite');
+      ownCrypto.addNewKey(matched.target, forumKey);
+      return { ok: true, forumId: matched.target };
+    },
+
     voteContent: async (targetId, value) => {
       const ssbClient = await openSsb();
       const whoami = await new Promise((res, rej) =>
@@ -160,12 +280,20 @@ module.exports = ({ cooler }) => {
         pull(ssbClient.createLogStream({ limit: logLimit }), 
         pull.collect((err, data) => err ? rej(err) : res(data)))
       );
-      const deleted = new Set(
-        msgs.filter(m => m.value.content?.type === 'tombstone').map(m => m.value.content.target)
-      );
+      const deleted = buildValidatedTombstoneSet(msgs);
+      const decode = (m) => {
+        const c = m.value && m.value.content;
+        if (!c) return null;
+        if (c.encryptedPayload) {
+          const dec = decryptForumContent(c, m.value.content.root || m.key);
+          return (dec && !dec._undecryptable) ? dec : null;
+        }
+        return c;
+      };
       const forums = msgs
-        .filter(m => m.value.content?.type === 'forum' && !m.value.content.root && !deleted.has(m.key))
-        .map(m => ({ ...m.value.content, key: m.key }));
+        .map(m => ({ m, c: decode(m) }))
+        .filter(({ m, c }) => c && c.type === 'forum' && !c.root && !deleted.has(m.key))
+        .map(({ m, c }) => ({ ...c, key: m.key }));
       const forumsWithVotes = await Promise.all(
         forums.map(async f => {
           const { positives, negatives } = await aggregateVotes(ssbClient, f.key);
@@ -174,10 +302,21 @@ module.exports = ({ cooler }) => {
       );
       const repliesByRoot = {};
       msgs.forEach(m => {
-        const c = m.value.content;
-        if (c?.type === 'forum' && c.root && !deleted.has(m.key)) {
-          repliesByRoot[c.root] = repliesByRoot[c.root] || [];
-          repliesByRoot[c.root].push({ key: m.key, text: c.text, author: c.author, timestamp: m.value.timestamp });
+        const cRaw = m.value && m.value.content;
+        if (!cRaw) return;
+        const root = cRaw.encryptedPayload ? null : cRaw.root;
+        if (!root) {
+          if (!cRaw.encryptedPayload) return;
+          const decReply = decryptForumContent(cRaw, null);
+          if (!decReply || decReply._undecryptable || decReply.type !== 'forum' || !decReply.root) return;
+          if (deleted.has(m.key)) return;
+          repliesByRoot[decReply.root] = repliesByRoot[decReply.root] || [];
+          repliesByRoot[decReply.root].push({ key: m.key, text: decReply.text, author: decReply.author, timestamp: m.value.timestamp });
+          return;
+        }
+        if (cRaw.type === 'forum' && root && !deleted.has(m.key)) {
+          repliesByRoot[root] = repliesByRoot[root] || [];
+          repliesByRoot[root].push({ key: m.key, text: cRaw.text, author: cRaw.author, timestamp: m.value.timestamp });
         }
       });
       const final = await Promise.all(
@@ -227,12 +366,12 @@ module.exports = ({ cooler }) => {
         pull(ssbClient.createLogStream({ limit: logLimit }), 
         pull.collect((err, data) => err ? rej(err) : res(data)))
       );
-      const deleted = new Set(
-        msgs.filter(m => m.value.content?.type === 'tombstone').map(m => m.value.content.target)
-      );
+      const deleted = buildValidatedTombstoneSet(msgs);
       const original = msgs.find(m => m.key === id && !deleted.has(m.key));
       if (!original || original.value.content?.type !== 'forum') throw new Error('Forum not found');
-      const base = original.value.content;
+      const rawBase = original.value.content;
+      const base = rawBase.encryptedPayload ? decryptForumContent(rawBase, id) : rawBase;
+      if (base && base._undecryptable) throw new Error('Forum is encrypted and cannot be decrypted with available keys');
       const { positives, negatives } = await aggregateVotes(ssbClient, id);
       return {
         ...base,
@@ -249,17 +388,27 @@ module.exports = ({ cooler }) => {
         pull(ssbClient.createLogStream({ limit: logLimit }), 
         pull.collect((err, data) => err ? rej(err) : res(data)))
       );
-      const deleted = new Set(
-        msgs.filter(m => m.value.content?.type === 'tombstone').map(m => m.value.content.target)
-      );
+      const deleted = buildValidatedTombstoneSet(msgs);
+      const decodeReply = (m) => {
+        const c = m.value && m.value.content;
+        if (!c || c.type !== 'forum') return null;
+        if (c.encryptedPayload) {
+          const dec = decryptForumContent(c, forumId);
+          if (!dec || dec._undecryptable || dec.root !== forumId) return null;
+          return { c: dec, m };
+        }
+        if (c.root !== forumId) return null;
+        return { c, m };
+      };
       const replies = msgs
-        .filter(m => m.value.content?.type === 'forum' && m.value.content.root === forumId && !deleted.has(m.key))
-        .map(m => ({
+        .map(decodeReply)
+        .filter(r => r && !deleted.has(r.m.key))
+        .map(({ c, m }) => ({
           key: m.key,
-          text: m.value.content.text,
-          author: m.value.content.author,
+          text: c.text,
+          author: c.author,
           timestamp: m.value.timestamp,
-          parent: m.value.content.branch || null
+          parent: c.branch || null
         }));
       for (let r of replies) {
         const { positives: rp, negatives: rn } = await aggregateVotes(ssbClient, r.key);

+ 8 - 7
src/models/images_model.js

@@ -1,6 +1,7 @@
 const pull = require("../server/node_modules/pull-stream");
 const { getConfig } = require("../configs/config-manager.js");
 const categories = require("../backend/opinion_categories");
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
@@ -43,7 +44,7 @@ module.exports = ({ cooler }) => {
     });
 
   const buildIndex = (messages) => {
-    const tomb = new Set();
+    const tomb = buildValidatedTombstoneSet(messages);
     const nodes = new Map();
     const parent = new Map();
     const child = new Map();
@@ -54,15 +55,14 @@ module.exports = ({ cooler }) => {
       const c = v.content;
       if (!c) continue;
 
-      if (c.type === "tombstone" && c.target) {
-        tomb.add(c.target);
-        continue;
-      }
+      if (c.type === "tombstone") continue;
 
       if (c.type !== "image") continue;
 
       const ts = v.timestamp || m.timestamp || 0;
-      nodes.set(k, { key: k, ts, c });
+      let sizeBytes = 0;
+      try { sizeBytes = Buffer.byteLength(JSON.stringify(v), "utf8"); } catch (_) { sizeBytes = 0; }
+      nodes.set(k, { key: k, ts, c, sizeBytes });
 
       if (c.replaces) {
         parent.set(k, c.replaces);
@@ -111,7 +111,8 @@ module.exports = ({ cooler }) => {
       meme: !!c.meme,
       opinions: c.opinions || {},
       opinions_inhabitants: voters,
-      hasVoted: viewerId ? voters.includes(viewerId) : false
+      hasVoted: viewerId ? voters.includes(viewerId) : false,
+      sizeBytes: node.sizeBytes || 0
     };
   };
 

+ 1 - 0
src/models/jobs_model.js

@@ -1,6 +1,7 @@
 const pull = require("../server/node_modules/pull-stream")
 const moment = require("../server/node_modules/moment")
 const { getConfig } = require("../configs/config-manager.js")
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000
 
 const norm = (s) => String(s || "").trim().toLowerCase()

+ 514 - 0
src/models/larp_model.js

@@ -0,0 +1,514 @@
+const pull = require('../server/node_modules/pull-stream');
+const fs = require('fs');
+const path = require('path');
+
+const HOUSES_PATH = path.join(__dirname, '..', 'client', 'assets', 'larp', 'houses.json');
+let HOUSES = {};
+try { HOUSES = JSON.parse(fs.readFileSync(HOUSES_PATH, 'utf8')); } catch (_) { HOUSES = {}; }
+
+const HOUSE_KEYS = ['academia','solaris','arrakis','terraverde','unsystem','dogma','helix','quark','hermandad'];
+const VALID_KEY = (k) => HOUSE_KEYS.includes(String(k || '').toLowerCase());
+
+const TEST_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000;
+
+const PROFILE_QUESTIONS = [
+  {
+    k: "larpProfileQ1",
+    q: "When you face a complex problem, what do you do first?",
+    options: [
+      { k: "larpProfileQ1O1", t: "Talk to others to find consensus",     w: { solaris: 3, hermandad: 1 } },
+      { k: "larpProfileQ1O2", t: "Build a prototype to test",            w: { arrakis: 3, hermandad: 1 } },
+      { k: "larpProfileQ1O3", t: "Research the literature",              w: { dogma: 3, terraverde: 1 } },
+      { k: "larpProfileQ1O4", t: "Disrupt the system that creates it",   w: { unsystem: 3, quark: 1 } }
+    ]
+  },
+  {
+    k: "larpProfileQ2",
+    q: "What gives meaning to your daily work?",
+    options: [
+      { k: "larpProfileQ2O1", t: "Defending the people I love",          w: { quark: 3, hermandad: 1 } },
+      { k: "larpProfileQ2O2", t: "Creating something tangible",          w: { arrakis: 2, hermandad: 2 } },
+      { k: "larpProfileQ2O3", t: "Healing or nurturing life",            w: { terraverde: 3, helix: 1 } },
+      { k: "larpProfileQ2O4", t: "Crafting words and ideas",             w: { dogma: 2, solaris: 2 } },
+      { k: "larpProfileQ2O5", t: "Making others laugh",                  w: { helix: 3, unsystem: 1 } }
+    ]
+  },
+  {
+    k: "larpProfileQ3",
+    q: "When conflict arises in your group, you…",
+    options: [
+      { k: "larpProfileQ3O1", t: "Lead the dialogue",                    w: { solaris: 3 } },
+      { k: "larpProfileQ3O2", t: "Take a side and stand firm",           w: { quark: 2, dogma: 1 } },
+      { k: "larpProfileQ3O3", t: "Crack a joke to defuse",               w: { helix: 3, unsystem: 1 } },
+      { k: "larpProfileQ3O4", t: "Question whether the conflict is real",w: { unsystem: 3, dogma: 1 } },
+      { k: "larpProfileQ3O5", t: "Look for root ecological causes",      w: { terraverde: 2, dogma: 1 } }
+    ]
+  },
+  {
+    k: "larpProfileQ4",
+    q: "Your favorite long-term project would be…",
+    options: [
+      { k: "larpProfileQ4O1", t: "Building a city",                      w: { hermandad: 3, arrakis: 1 } },
+      { k: "larpProfileQ4O2", t: "Restoring a forest",                   w: { terraverde: 3, helix: 1 } },
+      { k: "larpProfileQ4O3", t: "Writing a constitution",               w: { solaris: 2, dogma: 2 } },
+      { k: "larpProfileQ4O4", t: "Organising a festival",                w: { helix: 3, hermandad: 1 } },
+      { k: "larpProfileQ4O5", t: "Setting up a defense network",         w: { quark: 3, hermandad: 1 } },
+      { k: "larpProfileQ4O6", t: "Designing a new machine",              w: { arrakis: 3, quark: 1 } },
+      { k: "larpProfileQ4O7", t: "Curating an archive",                  w: { dogma: 3, solaris: 1 } },
+      { k: "larpProfileQ4O8", t: "Disrupting an unjust order",           w: { unsystem: 3, quark: 1 } }
+    ]
+  },
+  {
+    k: "larpProfileQ5",
+    q: "What makes a good leader?",
+    options: [
+      { k: "larpProfileQ5O1", t: "Someone who listens and mediates",     w: { solaris: 3, hermandad: 1 } },
+      { k: "larpProfileQ5O2", t: "Someone who can fight and protect",    w: { quark: 3, unsystem: 1 } },
+      { k: "larpProfileQ5O3", t: "Someone who knows history",            w: { dogma: 3, terraverde: 1 } },
+      { k: "larpProfileQ5O4", t: "Someone who makes you smile",          w: { helix: 3, hermandad: 1 } }
+    ]
+  },
+  {
+    k: "larpProfileQ6",
+    q: "Your relationship with rules:",
+    options: [
+      { k: "larpProfileQ6O1", t: "Rules emerge from dialogue and law",   w: { solaris: 3 } },
+      { k: "larpProfileQ6O2", t: "Rules should be followed strictly",    w: { dogma: 2, quark: 2 } },
+      { k: "larpProfileQ6O3", t: "Rules should be broken often",         w: { unsystem: 3, helix: 1 } },
+      { k: "larpProfileQ6O4", t: "Rules should serve life",              w: { terraverde: 3, helix: 1 } },
+      { k: "larpProfileQ6O5", t: "Rules build solid infrastructure",     w: { hermandad: 2, arrakis: 2 } }
+    ]
+  },
+  {
+    k: "larpProfileQ7",
+    q: "How do you handle information?",
+    options: [
+      { k: "larpProfileQ7O1", t: "I curate and preserve it",             w: { dogma: 3, hermandad: 1 } },
+      { k: "larpProfileQ7O2", t: "I share it through stories",           w: { helix: 2, dogma: 2 } },
+      { k: "larpProfileQ7O3", t: "I question its origins",               w: { unsystem: 3, dogma: 1 } },
+      { k: "larpProfileQ7O4", t: "I extract the useful bits",            w: { arrakis: 2, quark: 2 } },
+      { k: "larpProfileQ7O5", t: "I use it to heal",                     w: { terraverde: 3 } }
+    ]
+  },
+  {
+    k: "larpProfileQ8",
+    q: "Your idea of success is…",
+    options: [
+      { k: "larpProfileQ8O1", t: "A working machine",                    w: { arrakis: 3, hermandad: 1 } },
+      { k: "larpProfileQ8O2", t: "A peaceful community",                 w: { solaris: 2, terraverde: 2 } },
+      { k: "larpProfileQ8O3", t: "A lively festival",                    w: { helix: 3, hermandad: 1 } },
+      { k: "larpProfileQ8O4", t: "A safe family",                        w: { quark: 3, terraverde: 1 } },
+      { k: "larpProfileQ8O5", t: "An unbroken archive",                  w: { dogma: 3, hermandad: 1 } },
+      { k: "larpProfileQ8O6", t: "A cracked dogma",                      w: { unsystem: 3, dogma: 1 } },
+      { k: "larpProfileQ8O7", t: "A thriving harvest",                   w: { terraverde: 3, hermandad: 1 } },
+      { k: "larpProfileQ8O8", t: "A finished building",                  w: { hermandad: 3, arrakis: 1 } }
+    ]
+  },
+  {
+    k: "larpProfileQ9",
+    q: "When you wake up, you want to…",
+    options: [
+      { k: "larpProfileQ9O1", t: "Train your body",                      w: { quark: 3, helix: 1 } },
+      { k: "larpProfileQ9O2", t: "Read or write",                        w: { dogma: 3, solaris: 1 } },
+      { k: "larpProfileQ9O3", t: "Garden or cook",                       w: { terraverde: 3, helix: 1 } },
+      { k: "larpProfileQ9O4", t: "Tinker with something",                w: { arrakis: 3, hermandad: 1 } },
+      { k: "larpProfileQ9O5", t: "Question authority",                   w: { unsystem: 3, dogma: 1 } },
+      { k: "larpProfileQ9O6", t: "Plan a project",                       w: { hermandad: 3, solaris: 1 } },
+      { k: "larpProfileQ9O7", t: "Talk to friends",                      w: { solaris: 2, helix: 2 } },
+      { k: "larpProfileQ9O8", t: "Make art",                             w: { helix: 3, unsystem: 1 } }
+    ]
+  },
+  {
+    k: "larpProfileQ10",
+    q: "Your weakness might be:",
+    options: [
+      { k: "larpProfileQ10O1", t: "Talking too much",                    w: { solaris: 3, dogma: 1 } },
+      { k: "larpProfileQ10O2", t: "Being too pragmatic",                 w: { arrakis: 3, hermandad: 1 } },
+      { k: "larpProfileQ10O3", t: "Being too idealistic",                w: { terraverde: 3, helix: 1 } },
+      { k: "larpProfileQ10O4", t: "Being too disruptive",                w: { unsystem: 3, quark: 1 } },
+      { k: "larpProfileQ10O5", t: "Being too rigid",                     w: { dogma: 3, quark: 1 } },
+      { k: "larpProfileQ10O6", t: "Being too lighthearted",              w: { helix: 3, unsystem: 1 } },
+      { k: "larpProfileQ10O7", t: "Being too cautious",                  w: { quark: 3, hermandad: 1 } },
+      { k: "larpProfileQ10O8", t: "Being too ambitious",                 w: { hermandad: 3, arrakis: 1 } }
+    ]
+  }
+];
+
+const TEST_QUESTIONS_COUNT = PROFILE_QUESTIONS.length;
+
+const SOLAR_AGE_OFFSET = 10000000 - 2026;
+
+function computeCycle(now = new Date()) {
+  const year = now.getFullYear();
+  const monthIdx = now.getMonth();
+  const start = new Date(year, 0, 1);
+  const dayOfYear = Math.floor((now - start) / 86400000) + 1;
+  const summerSolstice = new Date(year, 5, 21);
+  const winterSolstice = new Date(year, 11, 21);
+  const solsticeNum = now < summerSolstice ? 1 : (now < winterSolstice ? 2 : 1);
+  const houseKey = HOUSE_KEYS[monthIdx % HOUSE_KEYS.length];
+  const house = HOUSES[houseKey] || { short: houseKey.slice(0, 3), name: houseKey };
+  const solarAge = year + SOLAR_AGE_OFFSET;
+  const houseCycle = Math.floor(year - 2026 + 1);
+  return {
+    day: dayOfYear,
+    solstice: solsticeNum,
+    age: solarAge,
+    houseKey,
+    houseShort: house.short || houseKey.slice(0, 3),
+    cycle: houseCycle,
+    formatted: `${dayOfYear}.${solsticeNum}.${solarAge}.${house.short || houseKey.slice(0, 3)}.${houseCycle}`
+  };
+}
+
+function getGoverningHouseKey(now = new Date()) {
+  return HOUSE_KEYS[now.getMonth() % HOUSE_KEYS.length];
+}
+
+module.exports = ({ cooler, tribesModel }) => {
+  let ssb;
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
+
+  const houseTribeTag = (houseKey) => {
+    const h = HOUSES[houseKey];
+    return `larp-${h && h.name ? h.name : houseKey}`;
+  };
+
+  async function findMyHouseTribe(houseKey) {
+    if (!tribesModel || !VALID_KEY(houseKey)) return null;
+    const client = await openSsb();
+    const me = client.id;
+    let list = [];
+    try { list = await tribesModel.listAll(); } catch (_) { return null; }
+    const tag = houseTribeTag(houseKey);
+    const candidates = list.filter(t => {
+      const tags = Array.isArray(t.tags) ? t.tags : [];
+      const members = Array.isArray(t.members) ? t.members : [];
+      return tags.includes(tag) && members.includes(me);
+    });
+    if (!candidates.length) return null;
+    candidates.sort((a, b) => new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime());
+    return candidates[0];
+  }
+
+  async function ensureHouseTribe(houseKey) {
+    if (!tribesModel || !VALID_KEY(houseKey)) return null;
+    const existing = await findMyHouseTribe(houseKey);
+    if (existing) return existing;
+    const house = HOUSES[houseKey] || {};
+    const tag = houseTribeTag(houseKey);
+    const title = house.name || houseKey;
+    const description = house.description || '';
+    const image = house.image || null;
+    const isAcademia = houseKey === 'academia';
+    const isAnonymous = !isAcademia;
+    const status = isAcademia ? 'PUBLIC' : 'PRIVATE';
+    const inviteMode = 'open';
+    try {
+      await tribesModel.createTribe(title, description, image, '', [tag], isAnonymous, inviteMode, null, status, '');
+    } catch (_) {}
+    return await findMyHouseTribe(houseKey);
+  }
+
+  async function leaveMyHouseTribe(houseKey) {
+    if (!tribesModel) return;
+    const tribe = await findMyHouseTribe(houseKey);
+    if (!tribe) return;
+    try { await tribesModel.leaveTribe(tribe.id, { force: true }); } catch (_) {}
+  }
+
+  async function publishJoin(houseKey) {
+    if (!VALID_KEY(houseKey)) throw new Error('Invalid house key');
+    const client = await openSsb();
+    let previousHouse = null;
+    try { previousHouse = await getUserHouse(client.id); } catch (_) {}
+    await new Promise((resolve, reject) => {
+      client.publish({
+        type: 'larpJoinHouse',
+        house: houseKey,
+        joinedAt: new Date().toISOString()
+      }, (err, msg) => err ? reject(err) : resolve(msg));
+    });
+    if (previousHouse && previousHouse !== houseKey) {
+      await leaveMyHouseTribe(previousHouse).catch(() => {});
+    }
+    await ensureHouseTribe(houseKey).catch(() => {});
+  }
+
+  async function getUserHouse(feedId) {
+    const client = await openSsb();
+    const target = feedId || client.id;
+    return new Promise((resolve) => {
+      let latest = null;
+      let latestTs = 0;
+      pull(
+        client.createUserStream({ id: target, reverse: true }),
+        pull.drain((m) => {
+          const c = m && m.value && m.value.content;
+          if (!c) return;
+          const ts = m.value.timestamp || 0;
+          if (c.type === 'larpJoinHouse' && VALID_KEY(c.house)) {
+            if (ts > latestTs) { latestTs = ts; latest = c.house; }
+          } else if (c.type === 'larpLeaveLarp') {
+            if (ts > latestTs) { latestTs = ts; latest = null; }
+          }
+        }, () => resolve(latest))
+      );
+    });
+  }
+
+  async function listAllMemberships() {
+    const client = await openSsb();
+    return new Promise((resolve) => {
+      const byAuthor = new Map();
+      pull(
+        client.createLogStream({ reverse: true }),
+        pull.drain((m) => {
+          const author = m && m.value && m.value.author;
+          if (!author) return;
+          const c = m.value.content;
+          if (!c) return;
+          const ts = m.value.timestamp || 0;
+          if (c.type === 'larpJoinHouse' && VALID_KEY(c.house)) {
+            const prev = byAuthor.get(author);
+            if (!prev || ts > prev.ts) byAuthor.set(author, { house: c.house, ts });
+          } else if (c.type === 'larpLeaveLarp') {
+            const prev = byAuthor.get(author);
+            if (!prev || ts > prev.ts) byAuthor.set(author, { house: null, ts });
+          }
+        }, () => {
+          const result = new Map();
+          for (const [a, v] of byAuthor.entries()) {
+            if (v.house) result.set(a, v.house);
+          }
+          resolve(result);
+        })
+      );
+    });
+  }
+
+  async function publishLeaveLarp() {
+    const client = await openSsb();
+    let previousHouse = null;
+    try { previousHouse = await getUserHouse(client.id); } catch (_) {}
+    await new Promise((resolve, reject) => {
+      client.publish({
+        type: 'larpLeaveLarp',
+        leftAt: new Date().toISOString()
+      }, (err, msg) => err ? reject(err) : resolve(msg));
+    });
+    if (previousHouse) {
+      await leaveMyHouseTribe(previousHouse).catch(() => {});
+    }
+  }
+
+  async function listHousesWithCounts() {
+    const memberships = await listAllMemberships();
+    const counts = Object.fromEntries(HOUSE_KEYS.map(k => [k, 0]));
+    for (const house of memberships.values()) {
+      if (counts[house] !== undefined) counts[house] += 1;
+    }
+    return HOUSE_KEYS.map(key => ({
+      key,
+      ...HOUSES[key],
+      memberCount: counts[key] || 0
+    }));
+  }
+
+  async function getMembersOfHouse(houseKey) {
+    if (!VALID_KEY(houseKey)) return [];
+    const memberships = await listAllMemberships();
+    const out = [];
+    for (const [author, house] of memberships.entries()) {
+      if (house === houseKey) out.push(author);
+    }
+    return out;
+  }
+
+  async function publishHousePost({ house, text }) {
+    if (!VALID_KEY(house)) throw new Error('Invalid house key');
+    const client = await openSsb();
+    const clean = String(text || '').trim().slice(0, 4000);
+    if (!clean) throw new Error('Empty post');
+    return new Promise((resolve, reject) => {
+      client.publish({
+        type: 'larpHousePost',
+        house,
+        text: clean,
+        createdAt: new Date().toISOString()
+      }, (err, msg) => err ? reject(err) : resolve(msg));
+    });
+  }
+
+  async function listHousePosts(houseKey, { viewerHouse = null, isGoverning = false } = {}) {
+    if (!VALID_KEY(houseKey)) return [];
+    const viewerIsMember = viewerHouse === houseKey;
+    if (!viewerIsMember && !isGoverning) return [];
+    const client = await openSsb();
+    const memberships = await listAllMemberships();
+    return new Promise((resolve) => {
+      const posts = [];
+      pull(
+        client.createLogStream({ reverse: true }),
+        pull.drain((m) => {
+          const c = m && m.value && m.value.content;
+          if (!c || c.type !== 'larpHousePost') return;
+          if (c.house !== houseKey) return;
+          const author = m.value.author;
+          const memberHouse = memberships.get(author) || 'academia';
+          if (memberHouse !== houseKey) return;
+          posts.push({
+            id: m.key,
+            author,
+            text: String(c.text || ''),
+            createdAt: c.createdAt || new Date(m.value.timestamp || 0).toISOString(),
+            ts: m.value.timestamp || 0
+          });
+        }, () => {
+          posts.sort((a, b) => b.ts - a.ts);
+          resolve(posts);
+        })
+      );
+    });
+  }
+
+  async function getLastTestAttempt(feedId) {
+    const client = await openSsb();
+    const target = feedId || client.id;
+    return new Promise((resolve) => {
+      let latest = null;
+      pull(
+        client.createUserStream({ id: target, reverse: true }),
+        pull.drain((m) => {
+          const c = m && m.value && m.value.content;
+          if (!c || c.type !== 'larpTestAttempt') return;
+          if (!VALID_KEY(c.house)) return;
+          const ts = m.value.timestamp || 0;
+          if (!latest || ts > latest.ts) latest = { house: c.house, ts, passed: c.passed === true, score: c.score || 0 };
+        }, () => resolve(latest))
+      );
+    });
+  }
+
+  async function canTakeTest(feedId) {
+    const last = await getLastTestAttempt(feedId);
+    if (!last) return { allowed: true, nextAt: 0, last: null };
+    const elapsed = Date.now() - last.ts;
+    if (elapsed >= TEST_COOLDOWN_MS) return { allowed: true, nextAt: 0, last };
+    return { allowed: false, nextAt: last.ts + TEST_COOLDOWN_MS, last };
+  }
+
+  function getProfileTest() {
+    return PROFILE_QUESTIONS.map(q => ({
+      key: q.k,
+      question: q.q,
+      options: q.options.map(o => ({ key: o.k, text: o.t }))
+    }));
+  }
+
+  function scoreProfileAnswers(answers, memberCounts = {}) {
+    const scores = Object.fromEntries(HOUSE_KEYS.filter(k => k !== 'academia').map(k => [k, 0]));
+    PROFILE_QUESTIONS.forEach((q, i) => {
+      const choice = Number(answers && answers[i]);
+      if (!Number.isInteger(choice) || choice < 0 || choice >= q.options.length) return;
+      const weights = q.options[choice].w || {};
+      for (const [house, weight] of Object.entries(weights)) {
+        if (scores[house] === undefined) continue;
+        scores[house] += Number(weight) || 0;
+      }
+    });
+    const ranking = Object.entries(scores).sort((a, b) => {
+      if (b[1] !== a[1]) return b[1] - a[1];
+      const ma = memberCounts[a[0]] || 0;
+      const mb = memberCounts[b[0]] || 0;
+      if (ma !== mb) return ma - mb;
+      return a[0].localeCompare(b[0]);
+    });
+    const bestHouse = ranking[0] ? ranking[0][0] : null;
+    const bestScore = ranking[0] ? ranking[0][1] : 0;
+    return { scores, ranking, bestHouse, bestScore };
+  }
+
+  async function submitProfileTest({ answers }) {
+    const client = await openSsb();
+    const can = await canTakeTest(client.id);
+    if (!can.allowed) return { ok: false, reason: 'cooldown', nextAt: can.nextAt };
+    const housesWithCounts = await listHousesWithCounts();
+    const memberCounts = Object.fromEntries(housesWithCounts.map(h => [h.key, h.memberCount || 0]));
+    const { scores, ranking, bestHouse, bestScore } = scoreProfileAnswers(answers, memberCounts);
+    const target = bestHouse || 'academia';
+    await new Promise((resolve, reject) => {
+      client.publish({
+        type: 'larpTestAttempt',
+        house: target,
+        passed: true,
+        attemptedAt: new Date().toISOString()
+      }, (err) => err ? reject(err) : resolve());
+    });
+    await publishJoin(target);
+    return { ok: true, passed: true, house: target, score: bestScore, scores, ranking };
+  }
+
+  async function createHouseInvite(houseKey) {
+    if (!VALID_KEY(houseKey)) throw new Error('Invalid house key');
+    if (houseKey === 'academia') throw new Error('ACADEMIA does not issue invites');
+    const client = await openSsb();
+    const myHouse = await getUserHouse(client.id);
+    if (myHouse !== houseKey) throw new Error('Only members can issue invites');
+    const tribe = await ensureHouseTribe(houseKey);
+    if (!tribe) throw new Error('Could not resolve house tribe');
+    if (!tribesModel) throw new Error('tribesModel unavailable');
+    const code = await tribesModel.generateInvite(tribe.id);
+    return { code, house: houseKey, tribeId: tribe.id };
+  }
+
+  async function redeemHouseInvite(rawCode) {
+    const code = String(rawCode || '').trim();
+    if (!code) return { ok: false };
+    if (!tribesModel) return { ok: false };
+    const client = await openSsb();
+    const myHouse = await getUserHouse(client.id);
+    if (myHouse && myHouse !== 'academia') return { ok: false };
+    let rootId;
+    try { rootId = await tribesModel.joinByInvite(code); } catch (_) { return { ok: false }; }
+    if (!rootId) return { ok: false };
+    let tribe = null;
+    try { tribe = await tribesModel.getTribeById(rootId); } catch (_) { tribe = null; }
+    const tags = (tribe && Array.isArray(tribe.tags)) ? tribe.tags : [];
+    const houseTag = tags.find(t => typeof t === 'string' && t.startsWith('larp-'));
+    if (!houseTag) return { ok: false };
+    const suffix = houseTag.slice('larp-'.length);
+    const houseKey = HOUSE_KEYS.find(k => (HOUSES[k] && HOUSES[k].name === suffix) || k === suffix);
+    if (!houseKey || houseKey === 'academia') return { ok: false };
+    await publishJoin(houseKey);
+    return { ok: true, house: houseKey, tribeId: rootId };
+  }
+
+  return {
+    HOUSES,
+    HOUSE_KEYS,
+    TEST_COOLDOWN_MS,
+    TEST_QUESTIONS_COUNT,
+    PROFILE_QUESTIONS,
+    computeCycle,
+    getGoverningHouseKey,
+    publishJoin,
+    publishLeaveLarp,
+    getUserHouse,
+    listHousesWithCounts,
+    getMembersOfHouse,
+    publishHousePost,
+    listHousePosts,
+    getLastTestAttempt,
+    canTakeTest,
+    getProfileTest,
+    scoreProfileAnswers,
+    submitProfileTest,
+    createHouseInvite,
+    redeemHouseInvite,
+    findMyHouseTribe,
+    ensureHouseTribe,
+    leaveMyHouseTribe,
+    getHouse: (key) => HOUSES[key] || null
+  };
+};

+ 3 - 2
src/models/logs_model.js

@@ -2,6 +2,7 @@ const pull = require('../server/node_modules/pull-stream');
 const util = require('../server/node_modules/util');
 const axios = require('../server/node_modules/axios');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
@@ -275,7 +276,7 @@ module.exports = ({ cooler }) => {
       )
     );
     const items = [];
-    const tombstoned = new Set();
+    const tombstoned = buildValidatedTombstoneSet(raw);
     const replaced = new Map();
     for (const m of raw) {
       if (!m || !m.value) continue;
@@ -290,7 +291,7 @@ module.exports = ({ cooler }) => {
       const c = v?.content;
       if (!c) continue;
       if (v.author !== userId) continue;
-      if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue; }
+      if (c.type === 'tombstone') continue;
       if (c.type !== 'log') continue;
       if (c.replaces) replaced.set(c.replaces, dec.key || keyIn);
       items.push({

+ 98 - 28
src/models/main_models.js

@@ -1,5 +1,6 @@
 "use strict";
 
+const { buildValidatedTombstoneSet } = require("./tombstone_validator");
 const debug = require("../server/node_modules/debug")("oasis");
 const { isRoot, isReply: isComment } = require("../server/node_modules/ssb-thread-schema");
 const lodash = require("../server/node_modules/lodash");
@@ -307,6 +308,8 @@ models.about = {
       ubi:      result.ubi      === true,
       wallet:   result.wallet   === true,
       ecoTax:   result.ecoTax   !== false,
+      larpSign: result.larpSign === true,
+      gpg:      result.gpg      !== false,
       clearnet: result.clearnet === true,
       clearnetShops:     result.clearnetShops     === true,
       clearnetJobs:      result.clearnetJobs      === true,
@@ -318,6 +321,7 @@ models.about = {
       clearnetImages:    result.clearnetImages    === true,
       clearnetDocuments: result.clearnetDocuments === true,
       clearnetTorrents:  result.clearnetTorrents  === true,
+      clearnetBookmarks: result.clearnetBookmarks === true,
       profileShops:      result.profileShops      === true,
       profileJobs:       result.profileJobs       === true,
       profileEvents:     result.profileEvents     === true,
@@ -327,7 +331,8 @@ models.about = {
       profileVideos:     result.profileVideos     === true,
       profileImages:     result.profileImages     === true,
       profileDocuments:  result.profileDocuments  === true,
-      profileTorrents:   result.profileTorrents   === true
+      profileTorrents:   result.profileTorrents   === true,
+      profileBookmarks:  result.profileBookmarks  === true
     };
   },
   name: async (feedId) => {
@@ -391,6 +396,20 @@ models.about = {
       })) || "";
     return raw;
   },
+  gpgFingerprint: async (feedId) => {
+    if (isPublic && (await models.about.publicWebHosting(feedId)) === false) {
+      return "";
+    }
+    const raw = await getAbout({ key: "gpgFingerprint", feedId });
+    return typeof raw === "string" ? raw : "";
+  },
+  gpgBlobId: async (feedId) => {
+    if (isPublic && (await models.about.publicWebHosting(feedId)) === false) {
+      return "";
+    }
+    const raw = await getAbout({ key: "gpgBlobId", feedId });
+    return typeof raw === "string" ? raw : "";
+  },
   _startNameWarmup() {
     const abortable = pullAbortable();
     let intervals = [];
@@ -1867,7 +1886,7 @@ const post = {
         });
       });
     },
-    publishProfileEdit: async ({ name, description, image, visibilityPrefs }) => {
+    publishProfileEdit: async ({ name, description, image, visibilityPrefs, gpgFingerprint, gpgBlob, gpgBlobId }) => {
       const ssb = await cooler.open();
       const normalizePrefs = (raw) => {
         const r = raw || {};
@@ -1878,6 +1897,8 @@ const post = {
           ubi:      r.ubi      === true,
           wallet:   r.wallet   === true,
           ecoTax:   r.ecoTax   !== false,
+          larpSign: r.larpSign === true,
+          gpg:      r.gpg      !== false,
           clearnet: r.clearnet === true,
           clearnetShops:     r.clearnetShops     === true,
           clearnetJobs:      r.clearnetJobs      === true,
@@ -1889,6 +1910,7 @@ const post = {
           clearnetImages:    r.clearnetImages    === true,
           clearnetDocuments: r.clearnetDocuments === true,
           clearnetTorrents:  r.clearnetTorrents  === true,
+          clearnetBookmarks: r.clearnetBookmarks === true,
           profileShops:      r.profileShops      === true,
           profileJobs:       r.profileJobs       === true,
           profileEvents:     r.profileEvents     === true,
@@ -1898,12 +1920,24 @@ const post = {
           profileVideos:     r.profileVideos     === true,
           profileImages:     r.profileImages     === true,
           profileDocuments:  r.profileDocuments  === true,
-          profileTorrents:   r.profileTorrents   === true
+          profileTorrents:   r.profileTorrents   === true,
+          profileBookmarks:  r.profileBookmarks  === true
         };
       };
       const prefs = visibilityPrefs ? normalizePrefs(visibilityPrefs) : undefined;
       const baseFields = { type: "about", about: ssb.id, name, description };
       if (prefs) baseFields.visibilityPrefs = prefs;
+      if (gpgFingerprint !== undefined) baseFields.gpgFingerprint = String(gpgFingerprint || "");
+      let resolvedBlobId = gpgBlobId;
+      if (gpgBlob && gpgBlob.length > 0) {
+        resolvedBlobId = await new Promise((resolve, reject) => {
+          pull(
+            pull.values([gpgBlob]),
+            ssb.blobs.add((err, id) => err ? reject(err) : resolve(id))
+          );
+        });
+      }
+      if (resolvedBlobId !== undefined) baseFields.gpgBlobId = String(resolvedBlobId || "");
       if (image && image.length > 0) {
         const megabyte = Math.pow(2, 20);
         const maxSize = 50 * megabyte;
@@ -2074,11 +2108,7 @@ const post = {
           return null;
         }
       }).filter(Boolean);
-      const tombstoneTargets = new Set(
-        decryptedMessages
-          .filter(msg => msg.value?.content?.type === 'tombstone')
-          .map(msg => msg.value.content.target)
-      );
+      const tombstoneTargets = buildValidatedTombstoneSet(decryptedMessages);
       return decryptedMessages.filter(msg => {
         if (tombstoneTargets.has(msg.key)) return false;
           const content = msg.value?.content;
@@ -2214,42 +2244,82 @@ models.lifetime = (() => {
   };
 })();
 
+const ownSpreadsByTarget = new Map();
+const ownTombstoned = new Set();
 models.spreads = {
-  /**
-   * Returns { count, voters: [{ key, name }], alreadySpread } for a given msgKey.
-   * A "spread" is a vote with value=1 referencing msgKey AND with msgKey in branch.
-   */
+  noteOwnSpread: (target, key) => {
+    if (!target || !key) return;
+    const set = ownSpreadsByTarget.get(target) || new Set();
+    set.add(key);
+    ownSpreadsByTarget.set(target, set);
+  },
+  noteOwnTombstone: (key) => {
+    if (key) ownTombstoned.add(key);
+  },
+  getCachedActiveOwnSpreadKey: (target) => {
+    if (!target) return null;
+    const set = ownSpreadsByTarget.get(target);
+    if (!set || set.size === 0) return null;
+    for (const k of Array.from(set).reverse()) {
+      if (!ownTombstoned.has(k)) return k;
+    }
+    return null;
+  },
+  forMessages: async (keys) => {
+    const out = new Map();
+    const list = Array.isArray(keys) ? keys.filter(k => typeof k === 'string' && k.startsWith('%')) : [];
+    const results = await Promise.all(list.map(k => models.spreads.forMessage(k).catch(() => null)));
+    list.forEach((k, i) => { if (results[i]) out.set(k, results[i]); });
+    return out;
+  },
   forMessage: async (msgKey) => {
     if (!msgKey || typeof msgKey !== 'string') return { count: 0, voters: [], alreadySpread: false };
     const ssb = await cooler.open();
     const myId = ssb.id;
-    return new Promise((resolve) => {
+    const refs = await new Promise((resolve) => {
       pull(
         ssb.backlinks.read({
-          query: [{ $filter: { dest: msgKey, value: { content: { type: 'vote' } } } }],
+          query: [{ $filter: { dest: msgKey } }],
           index: 'DTA',
           meta: true
         }),
         pull.filter(ref => {
           if (!ref || !ref.value || !ref.value.content) return false;
           const c = ref.value.content;
-          if (!c.vote || c.vote.link !== msgKey || Number(c.vote.value) !== 1) return false;
-          const br = Array.isArray(c.branch) ? c.branch : (typeof c.branch === 'string' ? [c.branch] : []);
-          return br.includes(msgKey);
+          if (c.type === 'spread' && c.link === msgKey) return true;
+          if (c.type === 'vote' && c.vote && c.vote.link === msgKey && Number(c.vote.value) === 1) {
+            const br = Array.isArray(c.branch) ? c.branch : (typeof c.branch === 'string' ? [c.branch] : []);
+            return br.includes(msgKey);
+          }
+          return false;
         }),
-        pull.collect(async (err, refs) => {
-          if (err) return resolve({ count: 0, voters: [], alreadySpread: false });
-          const byAuthor = new Map();
-          for (const r of refs) byAuthor.set(r.value.author, true);
-          const authors = Array.from(byAuthor.keys());
-          const voters = await Promise.all(authors.map(async (k) => ({
-            key: k,
-            name: await models.about.name(k).catch(() => k.slice(1, 9))
-          })));
-          resolve({ count: voters.length, voters, alreadySpread: authors.includes(myId) });
-        })
+        pull.collect((err, arr) => resolve(!err && arr ? arr : []))
       );
     });
+    const tombstoned = new Set(ownTombstoned);
+    await Promise.all(refs.map(r => new Promise((resolve) => {
+      pull(
+        ssb.backlinks.read({ query: [{ $filter: { dest: r.key } }], meta: true }),
+        pull.filter(t => t && t.value && t.value.content && t.value.content.type === 'tombstone' && t.value.content.target === r.key),
+        pull.collect((err, ts) => { if (!err && ts && ts.length) tombstoned.add(r.key); resolve(); })
+      );
+    })));
+    const byAuthor = new Map();
+    for (const r of refs) {
+      if (tombstoned.has(r.key)) continue;
+      byAuthor.set(r.value.author, true);
+    }
+    const ownExtra = ownSpreadsByTarget.get(msgKey);
+    if (ownExtra && ownExtra.size > 0) {
+      const hasNonTombstoned = Array.from(ownExtra).some(k => !tombstoned.has(k));
+      if (hasNonTombstoned) byAuthor.set(myId, true);
+    }
+    const authors = Array.from(byAuthor.keys());
+    const voters = await Promise.all(authors.map(async (k) => ({
+      key: k,
+      name: await models.about.name(k).catch(() => k.slice(1, 9))
+    })));
+    return { count: voters.length, voters, alreadySpread: authors.includes(myId) };
   }
 };
 

+ 123 - 10
src/models/maps_model.js

@@ -1,6 +1,7 @@
 const pull = require("../server/node_modules/pull-stream");
 const crypto = require("crypto");
 const { getConfig } = require("../configs/config-manager.js");
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const INVITE_CODE_BYTES = 16;
@@ -32,6 +33,49 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
   };
   const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0);
 
+  const rotateMapKey = async (rootId, remainingMembers) => {
+    if (!ownCrypto || !tribeCrypto || !rootId) return;
+    const existing = lookupKey(rootId);
+    if (!existing) return;
+    const newKey = ownCrypto.generateTribeKey();
+    const newGen = ownCrypto.addNewKey(rootId, newKey);
+    if (!Array.isArray(remainingMembers) || !remainingMembers.length) return;
+    const ssbClient = await openSsb();
+    const ssbKeys = require("../server/node_modules/ssb-keys");
+    const memberKeys = {};
+    for (const m of remainingMembers) {
+      try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys); } catch (_) {}
+    }
+    if (Object.keys(memberKeys).length) {
+      await new Promise((resolve) => {
+        ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: newGen, memberKeys }, () => resolve());
+      });
+    }
+  };
+
+  const ingestOwnTribeKeys = async () => {
+    if (!ownCrypto) return;
+    try {
+      const ssbClient = await openSsb();
+      const ssbKeys = require("../server/node_modules/ssb-keys");
+      const config = require("../server/ssb_config");
+      const msgs = await new Promise((resolve, reject) => pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((e, m) => e ? reject(e) : resolve(m))));
+      for (const m of msgs) {
+        const c = m.value && m.value.content;
+        if (!c || c.type !== "tribe-keys") continue;
+        const memberKeys = c.memberKeys;
+        if (!memberKeys || typeof memberKeys !== "object") continue;
+        const boxed = memberKeys[ssbClient.id];
+        if (!boxed) continue;
+        try {
+          const unboxed = ssbKeys.unbox(boxed, config.keys);
+          const key = typeof unboxed === "string" ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null);
+          if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key);
+        } catch (_) {}
+      }
+    } catch (_) {}
+  };
+
   const tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null;
   const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c;
   const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c;
@@ -223,6 +267,72 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
   return {
     type: "map",
 
+    async ingestKeys() { await ingestOwnTribeKeys() },
+
+    async pruneOrphanKeys() {
+      if (!ownCrypto || typeof ownCrypto.getAllRootIds !== "function") return 0;
+      try {
+        const ssbClient = await openSsb();
+        const messages = await new Promise((resolve, reject) => pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((e, m) => e ? reject(e) : resolve(m))));
+        const live = new Set();
+        const tomb = buildValidatedTombstoneSet(messages);
+        for (const m of messages) {
+          const c = m.value && m.value.content;
+          if (!c) continue;
+          if (c.type === "map") live.add(m.key);
+        }
+        const all = ownCrypto.getAllRootIds();
+        let removed = 0;
+        for (const rid of all) {
+          if (!live.has(rid) || tomb.has(rid)) {
+            try { ownCrypto.dropKey(rid); removed += 1; } catch (_) {}
+          }
+        }
+        return removed;
+      } catch (_) { return 0; }
+    },
+
+    async leaveMap(mapId) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const map = await this.getMapById(mapId, userId);
+      if (!map) throw new Error("Map not found");
+      if (map.author === userId) throw new Error("Author cannot leave their own map");
+      const members = (Array.isArray(map.members) ? map.members : []).filter(m => m !== userId);
+      if (!Array.isArray(map.members) || !map.members.includes(userId)) return;
+      const tipId = await this.resolveCurrentId(map.rootId || map.key);
+      const rootId = await this.resolveRootId(map.rootId || map.key);
+      const oldMsg = await getMsg(ssbClient, tipId);
+      const isWrapped = tribeCrypto && tribeCrypto.isTribeMsg(oldMsg.content);
+      const oldDecrypted = (isWrapped || (oldMsg.content && oldMsg.content.tribeId))
+        ? await decryptIfTribe(oldMsg.content)
+        : decryptMapRoot(oldMsg.content, rootId);
+      let updated = {
+        type: "map",
+        replaces: tipId,
+        title: oldDecrypted.title || "",
+        lat: oldDecrypted.lat,
+        lng: oldDecrypted.lng,
+        description: oldDecrypted.description || "",
+        markerLabel: oldDecrypted.markerLabel || "",
+        mapType: oldDecrypted.mapType,
+        tags: Array.isArray(oldDecrypted.tags) ? oldDecrypted.tags : [],
+        author: oldDecrypted.author,
+        members,
+        invites: Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : [],
+        ...(oldDecrypted.tribeId ? { tribeId: oldDecrypted.tribeId } : {}),
+        ...(oldDecrypted.image ? { image: oldDecrypted.image } : {}),
+        createdAt: oldDecrypted.createdAt,
+        updatedAt: new Date().toISOString()
+      };
+      if (oldDecrypted.tribeId) updated = await encryptIfTribe(updated);
+      else updated = encryptStandalone(updated, rootId);
+      await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r)));
+      const tomb = await tombFor(tipId, oldDecrypted.tribeId, userId);
+      await new Promise((resolve, reject) => ssbClient.publish(tomb, e => e ? reject(e) : resolve()));
+      try { await rotateMapKey(rootId, members); } catch (_) {}
+    },
+
     async resolveCurrentId(id) {
       const ssbClient = await openSsb();
       const messages = await getAllMessages(ssbClient);
@@ -273,7 +383,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
         updatedAt: now
       };
 
-      const shouldEncryptStandalone = !tribeId && tribeCrypto && (mType === "OPEN" || mType === "CLOSED");
+      const shouldEncryptStandalone = !tribeId && tribeCrypto;
       let mapKey = null;
       let content = plainContent;
       if (tribeId) {
@@ -299,7 +409,8 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
         if (mType === "OPEN") {
           try {
             const pubCode = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex");
-            const ek = tribeCrypto.encryptForInvite(mapKey, pubCode);
+            const inviteSalt = tribeCrypto.generateInviteSalt();
+            const ek = tribeCrypto.encryptForInvite(mapKey, pubCode, inviteSalt);
             let updated = {
               type: "map",
               replaces: result.key,
@@ -311,7 +422,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
               mapType: mType,
               author: userId,
               members: [userId],
-              invites: [{ code: pubCode, ek, gen: 1, public: true }],
+              invites: [{ code: pubCode, ek, salt: inviteSalt, gen: 1, public: true }],
               tags,
               ...(image ? { image } : {}),
               createdAt: now,
@@ -440,7 +551,8 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
         content = await encryptIfTribe(content);
       } else if (tribeCrypto) {
         const mapKey = lookupKey(rootId);
-        if (mapKey) content = tribeCrypto.encryptContent(content, [mapKey], true);
+        if (!mapKey) throw new Error(`Missing map key for ${rootId} — cannot publish marker`);
+        content = tribeCrypto.encryptContent(content, [mapKey], true);
       }
 
       return new Promise((resolve, reject) => {
@@ -532,8 +644,9 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
       const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex");
       let invite = code;
       if (tribeCrypto && !map.tribeId) {
-        const ekChain = tribeCrypto.encryptChainForInvite([map.rootId || map.key], code);
-        if (ekChain) invite = { code, ekChain, gen: lookupGen(map.rootId || map.key) || 1 };
+        const inviteSalt = tribeCrypto.generateInviteSalt();
+        const ekChain = tribeCrypto.encryptChainForInvite([map.rootId || map.key], code, inviteSalt);
+        if (ekChain) invite = { code, ekChain, salt: inviteSalt, gen: lookupGen(map.rootId || map.key) || 1 };
       }
       const tipId = await this.resolveCurrentId(mapId);
       const rootId = await this.resolveRootId(mapId);
@@ -563,7 +676,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
         updatedAt: new Date().toISOString()
       };
       if (effectiveTribeId) updated = await encryptIfTribe(updated);
-      else if (oldDecrypted.mapType !== "SINGLE") updated = encryptStandalone(updated, rootId);
+      else updated = encryptStandalone(updated, rootId);
       await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r)));
       const tomb1 = await tombFor(tipId, effectiveTribeId, userId);
       await new Promise((resolve, reject) => ssbClient.publish(tomb1, e => e ? reject(e) : resolve()));
@@ -589,7 +702,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
       let mapKey = null;
       if (tribeCrypto && typeof matchedInvite === "object") {
         if (matchedInvite.ekChain) {
-          const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code);
+          const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt);
           if (Array.isArray(chain) && chain.length) {
             for (const entry of chain) {
               if (Array.isArray(entry.keys) && entry.keys.length) {
@@ -601,7 +714,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
             mapKey = chain[0].key;
           }
         } else if (matchedInvite.ek) {
-          mapKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code);
+          mapKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt);
           ownCrypto.setKey(matched.rootId || matched.key, mapKey, matchedInvite.gen || 1);
         }
       }
@@ -639,7 +752,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
         updatedAt: new Date().toISOString()
       };
       if (effectiveTribeId2) updated = await encryptIfTribe(updated);
-      else if (oldDecrypted.mapType !== "SINGLE") updated = encryptStandalone(updated, rootId);
+      else updated = encryptStandalone(updated, rootId);
       await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r)));
       const tomb2 = await tombFor(tipId, effectiveTribeId2, userId);
       await new Promise((resolve, reject) => ssbClient.publish(tomb2, e => e ? reject(e) : resolve()));

+ 7 - 15
src/models/market_model.js

@@ -1,6 +1,7 @@
 const pull = require("../server/node_modules/pull-stream")
 const moment = require("../server/node_modules/moment")
 const { getConfig } = require("../configs/config-manager.js")
+const { buildValidatedTombstoneSet } = require('./tombstone_validator')
 const logLimit = getConfig().ssbLogStream?.limit || 1000
 
 const N = (s) => String(s || "").toUpperCase().replace(/\s+/g, "_")
@@ -70,17 +71,14 @@ module.exports = ({ cooler, tribeCrypto }) => {
     const ssbClient = await openSsb()
     const messages = await readAll(ssbClient)
 
-    const tomb = new Set()
+    const tomb = buildValidatedTombstoneSet(messages)
     const fwd = new Map()
     const parent = new Map()
 
     for (const m of messages) {
       const c = m.value && m.value.content
       if (!c) continue
-      if (c.type === "tombstone" && c.target) {
-        tomb.add(c.target)
-        continue
-      }
+      if (c.type === "tombstone") continue
       if (c.type !== "market") continue
       if (c.replaces) {
         fwd.set(c.replaces, m.key)
@@ -247,7 +245,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
       const userId = ssbClient.id
       const messages = await readAll(ssbClient)
 
-      const tomb = new Set()
+      const tomb = buildValidatedTombstoneSet(messages)
       const nodes = new Map()
       const parent = new Map()
       const child = new Map()
@@ -256,10 +254,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
         const k = m.key
         const c = m.value && m.value.content
         if (!c) continue
-        if (c.type === "tombstone" && c.target) {
-          tomb.add(c.target)
-          continue
-        }
+        if (c.type === "tombstone") continue
         if (c.type !== "market") continue
         nodes.set(k, { key: k, ts: (m.value && m.value.timestamp) || m.timestamp || 0, c })
         if (c.replaces) {
@@ -397,7 +392,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
       const userId = ssbClient.id
       const messages = await readAll(ssbClient)
 
-      const tomb = new Set()
+      const tomb = buildValidatedTombstoneSet(messages)
       const nodes = new Map()
       const parent = new Map()
       const child = new Map()
@@ -406,10 +401,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
         const k = m.key
         const c = m.value && m.value.content
         if (!c) continue
-        if (c.type === "tombstone" && c.target) {
-          tomb.add(c.target)
-          continue
-        }
+        if (c.type === "tombstone") continue
         if (c.type !== "market") continue
         nodes.set(k, { key: k, ts: (m.value && m.value.timestamp) || m.timestamp || 0, c })
         if (c.replaces) {

+ 65 - 1
src/models/melody_model.js

@@ -112,6 +112,70 @@ module.exports = ({ cooler }) => {
     NOTE_NAMES,
     OCTAVES,
     TYPE_TO_DEGREE,
-    getUserMelody: getUserMelodyInternal
+    getUserMelody: getUserMelodyInternal,
+    embedTextInWav,
+    extractTextFromWav
   };
 };
+
+const STEG_MAGIC = Buffer.from([0xBC, 0x53]);
+const STEG_HEADER_BITS = 32;
+
+function embedTextInWav(wavBuffer, text) {
+  if (!Buffer.isBuffer(wavBuffer) || wavBuffer.length < 44) return wavBuffer;
+  const payload = Buffer.from(String(text || ''), 'utf8');
+  if (payload.length === 0) return wavBuffer;
+  if (payload.length > 4096) return wavBuffer;
+  const lenBuf = Buffer.alloc(2);
+  lenBuf.writeUInt16BE(payload.length, 0);
+  const fullData = Buffer.concat([STEG_MAGIC, lenBuf, payload]);
+  const totalBits = fullData.length * 8;
+  const dataLen = wavBuffer.readUInt32LE(40);
+  const numSamples = Math.floor(dataLen / 2);
+  if (totalBits > numSamples) return wavBuffer;
+  const out = Buffer.from(wavBuffer);
+  for (let i = 0; i < totalBits; i++) {
+    const byteIdx = i >> 3;
+    const bitIdx = i & 7;
+    const bit = (fullData[byteIdx] >> (7 - bitIdx)) & 1;
+    const sampleOffset = 44 + (i * 2);
+    let sample = out.readInt16LE(sampleOffset);
+    sample = (sample & ~1) | bit;
+    out.writeInt16LE(sample, sampleOffset);
+  }
+  return out;
+}
+
+function readBitsAsBuffer(wavBuffer, startBit, numBits) {
+  const bytes = Math.ceil(numBits / 8);
+  const out = Buffer.alloc(bytes, 0);
+  for (let i = 0; i < numBits; i++) {
+    const sampleOffset = 44 + ((startBit + i) * 2);
+    if (sampleOffset + 2 > wavBuffer.length) return null;
+    const sample = wavBuffer.readInt16LE(sampleOffset);
+    const bit = sample & 1;
+    const byteIdx = i >> 3;
+    const bitIdx = i & 7;
+    out[byteIdx] |= bit << (7 - bitIdx);
+  }
+  return out;
+}
+
+function extractTextFromWav(wavBuffer) {
+  if (!Buffer.isBuffer(wavBuffer) || wavBuffer.length < 44 + STEG_HEADER_BITS * 2) return null;
+  if (wavBuffer.slice(0, 4).toString('ascii') !== 'RIFF') return null;
+  if (wavBuffer.slice(8, 12).toString('ascii') !== 'WAVE') return null;
+  const magic = readBitsAsBuffer(wavBuffer, 0, 16);
+  if (!magic || magic[0] !== STEG_MAGIC[0] || magic[1] !== STEG_MAGIC[1]) return null;
+  const lenBuf = readBitsAsBuffer(wavBuffer, 16, 16);
+  if (!lenBuf) return null;
+  const len = lenBuf.readUInt16BE(0);
+  if (len === 0 || len > 4096) return null;
+  const payload = readBitsAsBuffer(wavBuffer, 32, len * 8);
+  if (!payload) return null;
+  try {
+    const text = payload.toString('utf8');
+    if (!text) return null;
+    return text;
+  } catch (_) { return null; }
+}

+ 4 - 4
src/models/opinions_model.js

@@ -1,6 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const { getConfig } = require('../configs/config-manager.js');
 const categories = require('../backend/opinion_categories');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
@@ -73,7 +74,7 @@ module.exports = ({ cooler }) => {
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
       );
     });
-    const tombstoned = new Set();
+    const tombstoned = buildValidatedTombstoneSet(messages);
     const replaces = new Map();
     const byId = new Map();
 
@@ -81,9 +82,8 @@ module.exports = ({ cooler }) => {
       const key = msg.key;
       const c = msg.value?.content;
       if (!c) continue;
-      if (c.type === 'tombstone' && c.target) {
-        tombstoned.add(c.target);
-        byId.delete(c.target);
+      if (c.type === 'tombstone') {
+        if (tombstoned.has(c.target)) byId.delete(c.target);
         continue;
       }
       if (c.opinions && !tombstoned.has(key) && !['task', 'event', 'report'].includes(c.type)) {

+ 113 - 12
src/models/pads_model.js

@@ -1,6 +1,7 @@
 const pull = require("../server/node_modules/pull-stream")
 const crypto = require("crypto")
 const fs = require("fs")
+const { buildValidatedTombstoneSet } = require('./tombstone_validator')
 const path = require("path")
 const { getConfig } = require("../configs/config-manager.js")
 const logLimit = getConfig().ssbLogStream?.limit || 1000
@@ -120,16 +121,63 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
     return null
   }
 
-  const encryptForInvite = (padKeyHex, code) => {
-    const derived = crypto.scryptSync(code, INVITE_SALT, 32)
+  const encryptForInvite = (padKeyHex, code, saltHex) => {
+    const salt = saltHex ? Buffer.from(saltHex, "hex") : Buffer.from(INVITE_SALT)
+    const derived = crypto.scryptSync(code, salt, 32)
     return encryptField(padKeyHex, derived.toString("hex"))
   }
 
-  const decryptFromInvite = (encryptedKey, code) => {
-    const derived = crypto.scryptSync(code, INVITE_SALT, 32)
+  const decryptFromInvite = (encryptedKey, code, saltHex) => {
+    const salt = saltHex ? Buffer.from(saltHex, "hex") : Buffer.from(INVITE_SALT)
+    const derived = crypto.scryptSync(code, salt, 32)
     return decryptField(encryptedKey, derived.toString("hex"))
   }
 
+  const generateInviteSalt = () => crypto.randomBytes(16).toString("hex")
+
+  const rotatePadKey = async (rootId, remainingMembers) => {
+    if (!ownCrypto || !tribeCrypto || !rootId) return
+    const existing = getPadKey(rootId)
+    if (!existing) return
+    const newKey = crypto.randomBytes(32).toString("hex")
+    const newGen = ownCrypto.addNewKey(rootId, newKey)
+    if (!Array.isArray(remainingMembers) || !remainingMembers.length) return
+    const ssbClient = await openSsb()
+    const ssbKeys = require("../server/node_modules/ssb-keys")
+    const memberKeys = {}
+    for (const m of remainingMembers) {
+      try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {}
+    }
+    if (Object.keys(memberKeys).length) {
+      await new Promise((resolve) => {
+        ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: newGen, memberKeys }, () => resolve())
+      })
+    }
+  }
+
+  const ingestOwnTribeKeys = async () => {
+    if (!ownCrypto) return
+    try {
+      const ssbClient = await openSsb()
+      const ssbKeys = require("../server/node_modules/ssb-keys")
+      const config = require("../server/ssb_config")
+      const msgs = await readAll(ssbClient)
+      for (const m of msgs) {
+        const c = m.value && m.value.content
+        if (!c || c.type !== "tribe-keys") continue
+        const memberKeys = c.memberKeys
+        if (!memberKeys || typeof memberKeys !== "object") continue
+        const boxed = memberKeys[ssbClient.id]
+        if (!boxed) continue
+        try {
+          const unboxed = ssbKeys.unbox(boxed, config.keys)
+          const key = typeof unboxed === "string" ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null)
+          if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key)
+        } catch (_) {}
+      }
+    } catch (_) {}
+  }
+
   const readAll = async (ssbClient) =>
     new Promise((resolve, reject) =>
       pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
@@ -262,8 +310,9 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
       const initialInvites = []
       if (validStatus === "OPEN" && !usesTribeKey) {
         const pubCode = crypto.randomBytes(INVITE_BYTES).toString("hex")
-        const ek = encryptForInvite(keyHex, pubCode)
-        initialInvites.push({ code: pubCode, ek, gen: 1, public: true })
+        const inviteSalt = generateInviteSalt()
+        const ek = encryptForInvite(keyHex, pubCode, inviteSalt)
+        initialInvites.push({ code: pubCode, ek, salt: inviteSalt, gen: 1, public: true })
       }
 
       const content = {
@@ -319,7 +368,8 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
             if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
           }
           if (!keyHex) keyHex = getPadKey(rootId)
-          const enc = (text) => keyHex ? encryptField(text, keyHex) : text
+          if (!keyHex) throw new Error(`Missing pad key for ${rootId} — cannot update pad`)
+          const enc = (text) => encryptField(text, keyHex)
           const updated = {
             ...c,
             title: data.title !== undefined ? enc(safeText(data.title)) : c.title,
@@ -378,6 +428,55 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
       })
     },
 
+    async leavePad(padId) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const pad = await this.getPadById(padId)
+      if (!pad) throw new Error("Pad not found")
+      if (pad.author === userId) throw new Error("Author cannot leave their own pad")
+      const members = (Array.isArray(pad.members) ? pad.members : []).filter(m => m !== userId)
+      if (!Array.isArray(pad.members) || !pad.members.includes(userId)) return
+      const tipId = await this.resolveCurrentId(padId)
+      const rootId = await this.resolveRootId(padId)
+      await new Promise((resolve, reject) => {
+        ssbClient.get(tipId, (err, item) => {
+          if (err || !item?.content) return reject(new Error("Pad not found"))
+          const updated = { ...item.content, members, updatedAt: new Date().toISOString(), replaces: tipId }
+          const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
+          ssbClient.publish(tombstone, (e1) => {
+            if (e1) return reject(e1)
+            ssbClient.publish(updated, (e2) => e2 ? reject(e2) : resolve())
+          })
+        })
+      })
+      try { await rotatePadKey(rootId, members) } catch (_) {}
+    },
+
+    async ingestKeys() { await ingestOwnTribeKeys() },
+
+    async pruneOrphanKeys() {
+      if (!ownCrypto || typeof ownCrypto.getAllRootIds !== "function") return 0
+      try {
+        const ssbClient = await openSsb()
+        const messages = await readAll(ssbClient)
+        const live = new Set()
+        const tomb = buildValidatedTombstoneSet(messages)
+        for (const m of messages) {
+          const c = m.value && m.value.content
+          if (!c) continue
+          if (c.type === "pad") live.add(m.key)
+        }
+        const all = ownCrypto.getAllRootIds()
+        let removed = 0
+        for (const rid of all) {
+          if (!live.has(rid) || tomb.has(rid)) {
+            try { ownCrypto.dropKey(rid); removed += 1 } catch (_) {}
+          }
+        }
+        return removed
+      } catch (_) { return 0 }
+    },
+
     async addMemberToPad(padId, feedId) {
       const tipId = await this.resolveCurrentId(padId)
       const ssbClient = await openSsb()
@@ -481,8 +580,9 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
       const code = crypto.randomBytes(INVITE_BYTES).toString("hex")
       let invite = code
       if (keyHex) {
-        const ek = encryptForInvite(keyHex, code)
-        invite = { code, ek }
+        const inviteSalt = generateInviteSalt()
+        const ek = encryptForInvite(keyHex, code, inviteSalt)
+        invite = { code, ek, salt: inviteSalt }
       }
       const invites = [...pad.invites, invite]
       await this.updatePadById(padId, { invites })
@@ -507,7 +607,7 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
       let padKey = null
       let resolvedRootId = null
       if (typeof matchedInvite === "object" && matchedInvite.ek) {
-        padKey = decryptFromInvite(matchedInvite.ek, code)
+        padKey = decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt)
         resolvedRootId = await this.resolveRootId(matchedPad.rootId)
         setPadKey(resolvedRootId, padKey)
       }
@@ -557,15 +657,16 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
         if (tKeys.length) keyHex = tKeys[0]
       }
       if (!keyHex) keyHex = getPadKey(rootId)
+      if (!keyHex) throw new Error(`Missing pad key for ${rootId} — cannot publish pad entry`)
       const now = new Date().toISOString()
-      const encText = keyHex ? encryptField(safeText(text), keyHex) : safeText(text)
+      const encText = encryptField(safeText(text), keyHex)
       const content = {
         type: "padEntry",
         padId: rootId,
         text: encText,
         author: ssbClient.id,
         createdAt: now,
-        encrypted: !!keyHex,
+        encrypted: true,
         ...(pad && pad.tribeId ? { tribeId: pad.tribeId } : {})
       }
       return new Promise((resolve, reject) => {

+ 5 - 7
src/models/parliament_model.js

@@ -1,6 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const TERM_DAYS = 60;
@@ -91,7 +92,7 @@ module.exports = ({ cooler, services = {} }) => {
   }
 
   function listByTypeFromMsgs(msgs, type) {
-    const tomb = new Set();
+    const tomb = buildValidatedTombstoneSet(msgs);
     const rep = new Map();
     const children = new Map();
     const map = new Map();
@@ -101,10 +102,7 @@ module.exports = ({ cooler, services = {} }) => {
       const v = m.value || {};
       const c = v.content;
       if (!c) continue;
-
-      if (c.type === 'tombstone' && c.target) tomb.add(c.target);
-
-      if (c.type === type) {
+if (c.type === type) {
         if (c.replaces) {
           const oldId = c.replaces;
           const ts = normMs(v.timestamp || m.timestamp || Date.now());
@@ -1215,12 +1213,12 @@ module.exports = ({ cooler, services = {} }) => {
     } catch (_) { chainIds = [tribeId]; }
     const tribeIdSet = new Set(Array.isArray(chainIds) && chainIds.length ? chainIds : [tribeId]);
     const msgs = await tribeReadLog();
-    const tomb = new Set();
+    const tomb = buildValidatedTombstoneSet(msgs);
     const replaced = new Set();
     const items = new Map();
     for (const m of msgs) {
       const c = m.value?.content; if (!c) continue;
-      if (c.type === 'tombstone' && c.target) { tomb.add(c.target); continue; }
+      if (c.type === 'tombstone') continue;
       if (c.type !== type) continue;
       if (!tribeIdSet.has(c.tribeId)) continue;
       if (c.replaces) replaced.add(c.replaces);

+ 4 - 10
src/models/pixelia_model.js

@@ -1,5 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
@@ -19,11 +20,7 @@ module.exports = ({ cooler }) => {
       );
     });
 
-    const tombstoned = new Set(
-      messages
-        .filter(m => m.value?.content?.type === 'tombstone' && m.value?.content?.target)
-        .map(m => m.value.content.target)
-    );
+    const tombstoned = buildValidatedTombstoneSet(messages);
 
     const replaces = new Map();
     const byId = new Map();
@@ -96,7 +93,7 @@ module.exports = ({ cooler }) => {
       );
     });
 
-    const tombstoned = new Set();
+    const tombstoned = buildValidatedTombstoneSet(messages);
     const replaces = new Map();
     const byKey = new Map();
 
@@ -104,10 +101,7 @@ module.exports = ({ cooler }) => {
       const c = m.value?.content;
       const k = m.key;
       if (!c) continue;
-      if (c.type === 'tombstone' && c.target) {
-        tombstoned.add(c.target);
-        continue;
-      }
+      if (c.type === 'tombstone') continue;
       if (c.type === 'pixelia') {
         if (tombstoned.has(k)) continue;
         if (c.replaces) replaces.set(c.replaces, k);

+ 18 - 1
src/models/pm_model.js

@@ -92,6 +92,9 @@ module.exports = ({ cooler }) => {
       });
       const posts = [];
       const tombed = new Set();
+      const tombClaims = new Map();
+      const authorByKey = new Map();
+      const recpsByKey = new Map();
       for (const m of raw) {
         if (!m || !m.value) continue;
         const keyIn = m.key || m.value?.key || m.value?.hash || '';
@@ -108,11 +111,15 @@ module.exports = ({ cooler }) => {
         const k = dec?.key || keyIn;
         if (!c || c.private !== true || !k) continue;
         if (c.type === 'tombstone' && c.target) {
-          tombed.add(c.target);
+          const set = tombClaims.get(c.target) || new Set();
+          set.add(v.author);
+          tombClaims.set(c.target, set);
           continue;
         }
+        authorByKey.set(k, v.author);
         if (c.type === 'post') {
           const to = Array.isArray(c.to) ? c.to : [];
+          recpsByKey.set(k, to);
           const author = v.author;
           if (author === userId || to.includes(userId)) {
             posts.push({
@@ -123,6 +130,16 @@ module.exports = ({ cooler }) => {
           }
         }
       }
+      for (const [target, tombAuthors] of tombClaims.entries()) {
+        const origAuthor = authorByKey.get(target);
+        const origRecps = recpsByKey.get(target) || [];
+        for (const tombAuthor of tombAuthors) {
+          if (tombAuthor === origAuthor || tombAuthor === userId || origRecps.includes(tombAuthor)) {
+            tombed.add(target);
+            break;
+          }
+        }
+      }
       return posts.filter(m => m && m.key && !tombed.has(m.key));
     }
   };

+ 23 - 8
src/models/projects_model.js

@@ -1,6 +1,7 @@
 const pull = require("../server/node_modules/pull-stream")
 const moment = require("../server/node_modules/moment")
 const { getConfig } = require("../configs/config-manager.js")
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = (getConfig().ssbLogStream && getConfig().ssbLogStream.limit) || 1000
 
 module.exports = ({ cooler }) => {
@@ -104,8 +105,7 @@ module.exports = ({ cooler }) => {
     for (const m of all) {
       const c = m && m.value && m.value.content
       if (!c) continue
-      if (c.type === "tombstone" && c.target) tomb.add(c.target)
-      if (c.type === TYPE && c.replaces) forward.set(c.replaces, m.key)
+if (c.type === TYPE && c.replaces) forward.set(c.replaces, m.key)
     }
 
     let cur = id
@@ -182,7 +182,9 @@ module.exports = ({ cooler }) => {
         createdAt: new Date().toISOString(),
         updatedAt: null,
         mapUrl: String(data.mapUrl || "").trim(),
-        clearnetPublic: data.clearnetPublic === true || data.clearnetPublic === 'true' || data.clearnetPublic === 'on'
+        clearnetPublic: data.clearnetPublic === true || data.clearnetPublic === 'true' || data.clearnetPublic === 'on',
+        opinions: {},
+        opinions_inhabitants: []
       }
 
       return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
@@ -250,6 +252,22 @@ module.exports = ({ cooler }) => {
       return publishReplace(ssbClient, current.id, updated)
     },
 
+    async createOpinion(id, category) {
+      const categories = require('../backend/opinion_categories')
+      if (!categories.includes(category)) throw new Error('Invalid opinion category')
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const tip = await resolveTipId(id)
+      const project = await getById(tip)
+      const list = Array.isArray(project.opinions_inhabitants) ? project.opinions_inhabitants : []
+      if (list.includes(userId)) throw new Error('Already opined')
+      const opinions = Object.assign({}, project.opinions || {})
+      opinions[category] = (opinions[category] || 0) + 1
+      const content = { ...project, opinions, opinions_inhabitants: list.concat(userId) }
+      delete content.id
+      return publishReplace(ssbClient, tip, content)
+    },
+
     async deleteProject(id) {
       const ssbClient = await openSsb()
       const tip = await resolveTipId(id)
@@ -433,7 +451,7 @@ module.exports = ({ cooler }) => {
       const currentUserId = ssbClient.id
       const msgs = await getAllMsgs(ssbClient)
 
-      const tomb = new Set()
+      const tomb = buildValidatedTombstoneSet(msgs)
       const nodes = new Map()
       const parent = new Map()
       const child = new Map()
@@ -442,10 +460,7 @@ module.exports = ({ cooler }) => {
         const k = m && m.key
         const c = m && m.value && m.value.content
         if (!c) continue
-        if (c.type === "tombstone" && c.target) {
-          tomb.add(c.target)
-          continue
-        }
+        if (c.type === "tombstone") continue
         if (c.type !== TYPE) continue
         nodes.set(k, { key: k, ts: (m.value && m.value.timestamp) || 0, c })
         if (c.replaces) {

+ 35 - 3
src/models/reports_model.js

@@ -1,5 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 const normU = (v) => String(v || '').trim().toUpperCase();
@@ -74,7 +75,9 @@ module.exports = ({ cooler }) => {
         confirmations: [],
         severity: normalizeSeverity(severity) || 'low',
         status: 'OPEN',
-        template: normalizeTemplate(cat, template)
+        template: normalizeTemplate(cat, template),
+        opinions: {},
+        opinions_inhabitants: []
       };
 
       return new Promise((res, rej) => ssb.publish(content, (err, msg) => err ? rej(err) : res(msg)));
@@ -200,6 +203,35 @@ module.exports = ({ cooler }) => {
       return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
     },
 
+    async createOpinion(id, category) {
+      const categories = require('../backend/opinion_categories');
+      if (!categories.includes(category)) throw new Error('Invalid opinion category');
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      const report = await new Promise((res, rej) => ssb.get(id, (err, r) => err || !r || !r.content ? rej(new Error('Report not found')) : res(r)));
+      const c = report.content;
+      const list = Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : [];
+      if (list.includes(userId)) throw new Error('Already opined');
+      const opinions = Object.assign({}, c.opinions || {});
+      opinions[category] = (opinions[category] || 0) + 1;
+      const cat = normU(c.category);
+      const updated = {
+        ...c,
+        type: 'report',
+        replaces: id,
+        opinions,
+        opinions_inhabitants: list.concat(userId),
+        updatedAt: new Date().toISOString(),
+        status: normalizeStatus(c.status || 'OPEN'),
+        category: cat,
+        severity: normalizeSeverity(c.severity) || 'low',
+        template: normalizeTemplate(cat, c.template || {})
+      };
+      const tombstone = { type: 'tombstone', target: id, deletedAt: new Date().toISOString(), author: userId };
+      await new Promise((res, rej) => ssb.publish(tombstone, err => err ? rej(err) : res()));
+      return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
+    },
+
     async listAll() {
       const ssb = await openSsb();
 
@@ -209,7 +241,7 @@ module.exports = ({ cooler }) => {
           pull.collect((err, results) => {
             if (err) return reject(err);
 
-            const tombstoned = new Set();
+            const tombstoned = buildValidatedTombstoneSet(results);
             const replaced = new Map();
             const reports = new Map();
 
@@ -218,7 +250,7 @@ module.exports = ({ cooler }) => {
               const c = r && r.value && r.value.content ? r.value.content : null;
               if (!key || !c) continue;
 
-              if (c.type === 'tombstone' && c.target) tombstoned.add(c.target);
+              if (c.type === 'tombstone') continue;
 
               if (c.type === 'report') {
                 if (c.replaces) replaced.set(c.replaces, key);

+ 3 - 2
src/models/search_model.js

@@ -1,6 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
@@ -55,7 +56,7 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
       case 'votes':
         return [content?.question, content?.deadline, content?.status, ...(Object.values(content?.votes || {})), content?.totalVotes];
       case 'tribe':
-        return [content?.title, content?.description, content?.image, content?.location, ...(content?.tags || []), content?.isLARP, content?.isAnonymous, content?.members?.length, content?.createdAt, content?.author];
+        return [content?.title, content?.description, content?.image, content?.location, ...(content?.tags || []), content?.isAnonymous, content?.members?.length, content?.createdAt, content?.author];
       case 'audio':
         return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])];
       case 'image':
@@ -285,7 +286,7 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
       );
     });
 
-    const tombstoned = new Set(messages.filter(m => m.value?.content?.type === 'tombstone').map(m => m.value.content.target));
+    const tombstoned = buildValidatedTombstoneSet(messages);
     const replacesMap = new Map();
     const latestByKey = new Map();
 

+ 1 - 0
src/models/shops_model.js

@@ -1,6 +1,7 @@
 const pull = require("../server/node_modules/pull-stream")
 const { getConfig } = require("../configs/config-manager.js")
 const categories = require("../backend/opinion_categories")
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000
 
 const safeArr = (v) => (Array.isArray(v) ? v : [])

+ 7 - 9
src/models/stats_model.js

@@ -3,6 +3,7 @@ const os = require('os');
 const fs = require('fs');
 const path = require('path');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 const STORAGE_DIR = path.join(__dirname, "..", "configs");
@@ -291,11 +292,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       if (c.type && HIDDEN_ENVELOPE_TYPES.has(c.type)) return false;
       return true;
     });
-    const tombTargets = new Set(
-      allMsgs
-        .filter(m => m.value.content && m.value.content.type === 'tombstone' && m.value.content.target)
-        .map(m => m.value.content.target)
-    );
+    const tombTargets = buildValidatedTombstoneSet(allMsgs);
 
     const scopedMsgs = filter === 'MINE' ? allMsgs.filter(m => m.value.author === userId) : allMsgs;
 
@@ -522,7 +519,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
     const myMsgsAll = allMsgs.filter(m => m.value.author === userId);
     const myShare = allMsgs.length ? (myMsgsAll.length / allMsgs.length) * 100 : 0;
     const avgMsgsPerInhabitant = inhabitants ? allMsgs.length / inhabitants : 0;
-    const tombstoneRatio = allMsgs.length ? (allMsgs.filter(m => m.value.content.type === 'tombstone').length / allMsgs.length) * 100 : 0;
+    const validatedTombstoneCount = tombTargets.size;
+    const tombstoneRatio = allMsgs.length ? (validatedTombstoneCount / allMsgs.length) * 100 : 0;
 
     const totalsTs = allMsgs.map(m => m.value.timestamp || 0).filter(Boolean).sort((a, b) => a - b);
     const networkSpanDays = totalsTs.length >= 2 ? (totalsTs[totalsTs.length - 1] - totalsTs[0]) / 86400000 : 0;
@@ -552,8 +550,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       tribePublicNames,
       tribePublicCount,
       tribePrivateCount,
-      userTombstoneCount: scopedMsgs.filter(m => m.value.content.type === 'tombstone').length,
-      networkTombstoneCount: allMsgs.filter(m => m.value.content.type === 'tombstone').length,
+      userTombstoneCount: scopedMsgs.filter(m => m.value.content.type === 'tombstone' && m.value.author === userId).length,
+      networkTombstoneCount: validatedTombstoneCount,
       folderSize: formatSize(folderSize),
       statsBlockchainSize: formatSize(flumeSize),
       statsBlobsSize: formatSize(blobsSize),
@@ -588,7 +586,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
         topAuthors
       },
       tombstoneKPIs: {
-        networkTombstoneCount: allMsgs.filter(m => m.value.content.type === 'tombstone').length,
+        networkTombstoneCount: validatedTombstoneCount,
         ratio: tombstoneRatio
       },
       networkKPIs: {

+ 2 - 6
src/models/tags_model.js

@@ -1,5 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler, padsModel, tribesModel }) => {
@@ -107,12 +108,7 @@ module.exports = ({ cooler, padsModel, tribesModel }) => {
         );
       });
 
-      const tombstoned = new Set(
-        messages
-          .filter(m => m?.value?.content?.type === 'tombstone')
-          .map(m => m.value.content.target)
-          .filter(Boolean)
-      );
+      const tombstoned = buildValidatedTombstoneSet(messages);
 
       const replacesMap = new Map();
       const latestByKey = new Map();

+ 25 - 6
src/models/tasks_model.js

@@ -1,6 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler, pmModel }) => {
@@ -51,7 +52,9 @@ module.exports = ({ cooler, pmModel }) => {
         assignees: [userId],
         createdAt: new Date().toISOString(),
         status: 'OPEN',
-        author: userId
+        author: userId,
+        opinions: {},
+        opinions_inhabitants: []
       };
 
       return new Promise((res, rej) => ssb.publish(content, (err, msg) => err ? rej(err) : res(msg)));
@@ -169,6 +172,23 @@ module.exports = ({ cooler, pmModel }) => {
       return { id: taskId, ...c, status };
     },
 
+    async createOpinion(id, category) {
+      const categories = require('../backend/opinion_categories');
+      if (!categories.includes(category)) throw new Error('Invalid opinion category');
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      const task = await new Promise((res, rej) => ssb.get(id, (err, m) => err || !m || !m.content ? rej(new Error('Task not found')) : res(m)));
+      const c = task.content;
+      const list = Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : [];
+      if (list.includes(userId)) throw new Error('Already opined');
+      const opinions = Object.assign({}, c.opinions || {});
+      opinions[category] = (opinions[category] || 0) + 1;
+      const updated = { ...c, opinions, opinions_inhabitants: list.concat(userId), updatedAt: new Date().toISOString(), replaces: id };
+      const tombstone = { type: 'tombstone', target: id, deletedAt: new Date().toISOString(), author: userId };
+      await new Promise((res, rej) => ssb.publish(tombstone, err => err ? rej(err) : res()));
+      return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
+    },
+
     async toggleAssignee(taskId) {
       const ssb = await openSsb();
       const userId = ssb.id;
@@ -188,15 +208,14 @@ module.exports = ({ cooler, pmModel }) => {
         pull(ssb.createLogStream({ limit: logLimit }),
           pull.collect((err, results) => {
             if (err) return reject(err);
-            const tombstoned = new Set();
+            const tombstoned = buildValidatedTombstoneSet(results);
             const replaced = new Map();
             const tasks = new Map();
 
             for (const r of results) {
               const { key, value: { content: c } } = r;
               if (!c) continue;
-              if (c.type === 'tombstone') tombstoned.add(c.target);
-              if (c.type === 'task') {
+if (c.type === 'task') {
                 if (c.replaces) replaced.set(c.replaces, key);
                 const status = c.status === 'OPEN' && moment(c.endTime).isBefore(now) ? 'CLOSED' : c.status;
                 tasks.set(key, { id: key, ...c, status });
@@ -221,13 +240,13 @@ module.exports = ({ cooler, pmModel }) => {
       );
       const now = Date.now();
       const sent = new Set();
-      const tombstoned = new Set();
+      const tombstoned = buildValidatedTombstoneSet(messages);
       const replaced = new Set();
       const tasks = new Map();
       for (const m of messages) {
         const c = m.value && m.value.content;
         if (!c) continue;
-        if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue; }
+        if (c.type === 'tombstone') continue;
         if (c.type === 'taskReminderSent' && c.target) { sent.add(c.target); continue; }
         if (c.type === 'task') {
           if (c.replaces) replaced.add(c.replaces);

+ 39 - 0
src/models/tombstone_validator.js

@@ -0,0 +1,39 @@
+function buildValidatedTombstoneSet(messages) {
+  if (!Array.isArray(messages)) return new Set();
+  const tombClaims = new Map();
+  const targetAuthors = new Map();
+  const targetRoots = new Map();
+  for (const m of messages) {
+    if (!m || !m.value) continue;
+    const v = m.value;
+    const c = v.content;
+    if (!c) continue;
+    if (typeof c === 'object' && c.type === 'tombstone' && typeof c.target === 'string') {
+      const ts = v.timestamp || 0;
+      const prev = tombClaims.get(c.target);
+      if (!prev || ts > prev.ts) tombClaims.set(c.target, { author: v.author, ts, rootId: c._rootId || null });
+      continue;
+    }
+    if (m.key) {
+      targetAuthors.set(m.key, v.author);
+      if (typeof c === 'object' && c._rootId) targetRoots.set(m.key, c._rootId);
+    }
+  }
+  const out = new Set();
+  for (const [target, { author, rootId }] of tombClaims.entries()) {
+    if (targetAuthors.get(target) !== author) continue;
+    if (rootId) {
+      const targetRoot = targetRoots.get(target);
+      if (targetRoot !== rootId) continue;
+    }
+    out.add(target);
+  }
+  return out;
+}
+
+function isTombstoneFromAuthor(tombstoneMsg, targetAuthor) {
+  if (!tombstoneMsg || !tombstoneMsg.value) return false;
+  return tombstoneMsg.value.author === targetAuthor;
+}
+
+module.exports = { buildValidatedTombstoneSet, isTombstoneFromAuthor };

+ 5 - 2
src/models/torrents_model.js

@@ -71,7 +71,9 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       if (c.type !== "torrent") continue;
 
       const ts = v.timestamp || m.timestamp || 0;
-      nodes.set(k, { key: k, ts, c });
+      let sizeBytes = 0;
+      try { sizeBytes = Buffer.byteLength(JSON.stringify(v), "utf8"); } catch (_) { sizeBytes = 0; }
+      nodes.set(k, { key: k, ts, c, sizeBytes });
       authorByKey.set(k, v.author);
 
       if (c.replaces) {
@@ -128,7 +130,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       opinions_inhabitants: voters,
       hasVoted: viewerId ? voters.includes(viewerId) : false,
       tribeId: c.tribeId || null,
-      encrypted: !!undec
+      encrypted: !!undec,
+      sizeBytes: node.sizeBytes || 0
     };
   };
 

+ 1 - 0
src/models/transfers_model.js

@@ -2,6 +2,7 @@ const pull = require("../server/node_modules/pull-stream")
 const moment = require("../server/node_modules/moment")
 const { getConfig } = require("../configs/config-manager.js")
 const categories = require("../backend/opinion_categories")
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000
 
 const isValidId = (to) => /^@[A-Za-z0-9+/]+={0,2}\.ed25519$/.test(String(to || ""))

+ 2 - 1
src/models/trending_model.js

@@ -2,6 +2,7 @@ const pull = require('../server/node_modules/pull-stream');
 const { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const opinionCategories = require('../backend/opinion_categories');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 
 module.exports = ({ cooler }) => {
   let ssb;
@@ -34,7 +35,7 @@ module.exports = ({ cooler }) => {
       );
     });
 
-    const tombstoned = new Set();
+    const tombstoned = buildValidatedTombstoneSet(messages);
     const replaces = new Map();
     const itemsById = new Map();
 

+ 13 - 9
src/models/tribes_model.js

@@ -1,13 +1,14 @@
 const pull = require('../server/node_modules/pull-stream');
 const crypto = require('crypto');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const tribeLogLimit = Math.max(logLimit, 100000);
 
 const INVITE_CODE_BYTES = 16;
 const VALID_INVITE_MODES = ['strict', 'open'];
 
-const STRUCTURAL_FIELDS = ['title', 'description', 'image', 'location', 'tags', 'isLARP', 'isAnonymous', 'inviteMode', 'status', 'parentTribeId', 'mapUrl'];
+const STRUCTURAL_FIELDS = ['title', 'description', 'image', 'location', 'tags', 'isAnonymous', 'inviteMode', 'status', 'parentTribeId', 'mapUrl'];
 
 module.exports = ({ cooler, tribeCrypto }) => {
   let ssb;
@@ -94,7 +95,6 @@ module.exports = ({ cooler, tribeCrypto }) => {
           image: c.image,
           location: c.location,
           tags: c.tags,
-          isLARP: c.isLARP,
           isAnonymous: c.isAnonymous,
           members: c.members,
           invites: c.invites,
@@ -262,7 +262,6 @@ module.exports = ({ cooler, tribeCrypto }) => {
       image: c.image || null,
       location: c.location || null,
       tags: Array.isArray(c.tags) ? c.tags : [],
-      isLARP: !!c.isLARP,
       isAnonymous: c.isAnonymous !== false,
       members: Array.isArray(c.members) ? c.members : [],
       invites: Array.isArray(c.invites) ? c.invites : [],
@@ -292,7 +291,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
   return {
     type: 'tribe',
 
-    async createTribe(title, description, image, location, tagsRaw = [], isLARP = false, isAnonymous = true, inviteMode = 'strict', parentTribeId = null, status = 'OPEN', mapUrl = '') {
+    async createTribe(title, description, image, location, tagsRaw = [], isAnonymous = true, inviteMode = 'strict', parentTribeId = null, status = 'OPEN', mapUrl = '') {
       if (!VALID_INVITE_MODES.includes(inviteMode)) throw new Error('Invalid invite mode. Must be "strict" or "open"');
       const client = await openSsb();
       const userId = client.id;
@@ -310,7 +309,6 @@ module.exports = ({ cooler, tribeCrypto }) => {
         image: blobId,
         location,
         tags,
-        isLARP: Boolean(isLARP),
         isAnonymous: isPrivate,
         members: [userId],
         invites: [],
@@ -481,7 +479,6 @@ module.exports = ({ cooler, tribeCrypto }) => {
         image: updatedContent.image !== undefined ? updatedContent.image : tribe.image,
         location: updatedContent.location !== undefined ? updatedContent.location : tribe.location,
         tags: updatedContent.tags !== undefined ? updatedContent.tags : tribe.tags,
-        isLARP: updatedContent.isLARP !== undefined ? !!updatedContent.isLARP : tribe.isLARP,
         isAnonymous: updatedContent.isAnonymous !== undefined ? updatedContent.isAnonymous : tribe.isAnonymous,
         members: updatedContent.members !== undefined ? updatedContent.members : tribe.members,
         invites: updatedContent.invites !== undefined ? updatedContent.invites : tribe.invites,
@@ -564,7 +561,9 @@ module.exports = ({ cooler, tribeCrypto }) => {
       return code;
     },
 
-    async joinByInvite(code) {
+    async joinByInvite(rawCode) {
+      const code = String(rawCode || '').trim();
+      if (!code) throw new Error('Invalid or expired invite code');
       const client = await openSsb();
       const userId = client.id;
       const msgs = await streamLog();
@@ -620,16 +619,21 @@ module.exports = ({ cooler, tribeCrypto }) => {
       );
     },
 
-    async leaveTribe(tribeId) {
+    async leaveTribe(tribeId, opts = {}) {
       const client = await openSsb();
       const userId = client.id;
       const tribe = await this.getTribeById(tribeId);
       if (!tribe) throw new Error('Tribe not found');
-      if (tribe.author === userId) throw new Error('Tribe author cannot leave their own tribe');
+      const isAuthor = tribe.author === userId;
+      if (isAuthor && !opts.force) throw new Error('Tribe author cannot leave their own tribe');
       const members = Array.isArray(tribe.members) ? [...tribe.members] : [];
       const idx = members.indexOf(userId);
       if (idx === -1) throw new Error('User is not a member of this tribe');
       members.splice(idx, 1);
+      if (isAuthor && members.length === 0) {
+        await this.publishTombstone(tribeId).catch(() => {});
+        return;
+      }
       await this.updateTribeById(tribeId, { members });
       if (members.length > 0) {
         await this.rotateTribeKey(tribeId, members).catch(() => {});

+ 8 - 7
src/models/videos_model.js

@@ -1,5 +1,6 @@
 const pull = require("../server/node_modules/pull-stream");
 const { getConfig } = require("../configs/config-manager.js");
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const categories = require("../backend/opinion_categories");
 
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
@@ -43,7 +44,7 @@ module.exports = ({ cooler }) => {
     });
 
   const buildIndex = (messages) => {
-    const tomb = new Set();
+    const tomb = buildValidatedTombstoneSet(messages);
     const nodes = new Map();
     const parent = new Map();
     const child = new Map();
@@ -54,15 +55,14 @@ module.exports = ({ cooler }) => {
       const c = v.content;
       if (!c) continue;
 
-      if (c.type === "tombstone" && c.target) {
-        tomb.add(c.target);
-        continue;
-      }
+      if (c.type === "tombstone") continue;
 
       if (c.type !== "video") continue;
 
       const ts = v.timestamp || m.timestamp || 0;
-      nodes.set(k, { key: k, ts, c });
+      let sizeBytes = 0;
+      try { sizeBytes = Buffer.byteLength(JSON.stringify(v), "utf8"); } catch (_) { sizeBytes = 0; }
+      nodes.set(k, { key: k, ts, c, sizeBytes });
 
       if (c.replaces) {
         parent.set(k, c.replaces);
@@ -110,7 +110,8 @@ module.exports = ({ cooler }) => {
       mapUrl: c.mapUrl || "",
       opinions: c.opinions || {},
       opinions_inhabitants: voters,
-      hasVoted: viewerId ? voters.includes(viewerId) : false
+      hasVoted: viewerId ? voters.includes(viewerId) : false,
+      sizeBytes: node.sizeBytes || 0
     };
   };
 

+ 3 - 5
src/models/votes_model.js

@@ -1,5 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const { getConfig } = require('../configs/config-manager.js');
 const categories = require('../backend/opinion_categories');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
@@ -23,7 +24,7 @@ module.exports = ({ cooler }) => {
   }
 
   function buildIndex(messages) {
-    const tombstoned = new Set();
+    const tombstoned = buildValidatedTombstoneSet(messages);
     const replaced = new Map();
     const votes = new Map();
     const parent = new Map();
@@ -34,10 +35,7 @@ module.exports = ({ cooler }) => {
       const c = v && v.content;
       if (!c) continue;
 
-      if (c.type === 'tombstone' && c.target) {
-        tombstoned.add(c.target);
-        continue;
-      }
+      if (c.type === 'tombstone') continue;
 
       if (c.type !== TYPE) continue;
 

+ 38 - 5
src/server/SSB_server.js

@@ -12,10 +12,35 @@ const { printMetadata } = require('./ssb_metadata');
 
 (() => {
   const realErr = console.error;
-  const SHS_NOISE = /shs\.server: client hello invalid|they dailed a wrong number|client hello invalid/i;
+  const SHS_NOISE = /shs\.server:|they dailed a wrong number|client hello invalid|invalid challenge|wrong application cap/i;
+  const EBT_NOISE = /stream ended with:\s*\d+\s+but wanted:\s*\d+/i;
+  const isEbtReplicateException = (args) =>
+    args.length >= 2 &&
+    typeof args[0] === 'string' &&
+    /rpc\.ebt\.replicate exception/i.test(args[0]) &&
+    args[1] && typeof args[1].message === 'string' && EBT_NOISE.test(args[1].message);
+  const parsePeer = (addr) => {
+    if (typeof addr !== 'string') return 'unknown';
+    const m = /net:(.+?):(\d+)(?:~|$)/.exec(addr);
+    if (!m) return addr;
+    return `${m[1].replace(/^::ffff:/, '')}:${m[2]}`;
+  };
+  const logRejection = (peer) => {
+    const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
+    realErr.call(console, `[${ts}] REJECTED    ${peer} (wrong SHS cap)`);
+  };
   console.error = function (...args) {
-    if (args.length >= 2 && args[0] === 'server error, from' && typeof args[1] === 'string' && args[1].includes('~shs:')) return;
-    if (args.length >= 1 && args[0] && typeof args[0].message === 'string' && SHS_NOISE.test(args[0].message)) return;
+    if (args.length >= 2 && args[0] === 'server error, from' && typeof args[1] === 'string' && args[1].includes('~shs:')) {
+      logRejection(parsePeer(args[1]));
+      return;
+    }
+    if (args.length >= 1 && args[0] && typeof args[0].message === 'string' && SHS_NOISE.test(args[0].message)) {
+      logRejection(parsePeer(args[0].address));
+      return;
+    }
+    if (args.length >= 1 && args[0] && typeof args[0].message === 'string' && EBT_NOISE.test(args[0].message)) return;
+    if (isEbtReplicateException(args)) return;
+    if (args.length >= 1 && typeof args[0] === 'string' && /rpc\.ebt\.replicate exception:.*stream ended with/i.test(args[0])) return;
     return realErr.apply(console, args);
   };
 })();
@@ -36,7 +61,7 @@ const Server = SecretStack({ caps })
   .use(require('ssb-search'))
   .use(require('ssb-private'))
   .use(require('ssb-friend-pub'))
-  .use(require('ssb-invite-client'))
+  .use(config.pub ? require('ssb-invite') : require('ssb-invite-client'))
   .use(require('ssb-logging'))
   .use(require('ssb-replication-scheduler'))
   .use(require('ssb-partial-replication'))
@@ -54,7 +79,15 @@ if (!config.pub) {
   Server.use(require('./lanRouter'));
 }
 
-if (config.autofollow?.enabled !== false) {
+if (config.autofollow && typeof config.autofollow === 'object' && !Array.isArray(config.autofollow)) {
+  if (config.autofollow.enabled === false) {
+    config.autofollow = null;
+  } else {
+    const feeds = Array.isArray(config.autofollow.feeds) ? config.autofollow.feeds : (Array.isArray(config.autofollow.suggestions) ? config.autofollow.suggestions : []);
+    config.autofollow = feeds.filter(f => typeof f === 'string' && f.length > 0);
+  }
+}
+if (config.autofollow && (Array.isArray(config.autofollow) ? config.autofollow.length > 0 : true)) {
   Server.use(require('ssb-autofollow'));
 }
 

+ 11 - 0
src/server/lanRouter.js

@@ -1,8 +1,17 @@
 const pull = require('./node_modules/pull-stream');
 const Ref = require('./node_modules/ssb-ref');
+const fs = require('fs');
+const path = require('path');
 
 const staged = new Set();
 
+function readOasisConfig() {
+  try {
+    const p = path.join(__dirname, '..', 'configs', 'oasis-config.json');
+    return JSON.parse(fs.readFileSync(p, 'utf8')) || {};
+  } catch (_) { return {}; }
+}
+
 function stagePeer(ssb, address, key, eagerReplicate) {
   if (!address || !key || key === ssb.id) return;
   if (staged.has(address)) return;
@@ -45,6 +54,8 @@ function handleDiscovery(ssb, d, opts) {
 
 function startRouter(ssb, opts) {
   if (!ssb.lan || typeof ssb.lan.discoveredPeers !== 'function') return;
+  const oasisCfg = readOasisConfig();
+  if (oasisCfg.lanBroadcasting === false) return;
   try { ssb.lan.start(); } catch (_) {}
   pull(
     ssb.lan.discoveredPeers(),

+ 12 - 2
src/server/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.7.7",
+  "version": "0.7.8",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@krakenslab/oasis",
-      "version": "0.7.6",
+      "version": "0.7.8",
       "hasInstallScript": true,
       "license": "AGPL-3.0",
       "dependencies": {
@@ -45,6 +45,7 @@
         "node-llama-cpp": "^3.10.0",
         "non-private-ip": "^2.2.0",
         "open": "^8.4.2",
+        "openpgp": "^6.3.0",
         "packet-stream": "^2.0.6",
         "packet-stream-codec": "^1.2.0",
         "pdfjs-dist": "^5.2.133",
@@ -10776,6 +10777,15 @@
         "opencollective-postinstall": "index.js"
       }
     },
+    "node_modules/openpgp": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/openpgp/-/openpgp-6.3.0.tgz",
+      "integrity": "sha512-pLzCU8IgyKXPSO11eeharQkQ4GzOKNWhXq79pQarIRZEMt1/ssyr+MIuWBv1mNoenJLg04gvPx+fi4gcKZ4bag==",
+      "license": "LGPL-3.0+",
+      "engines": {
+        "node": ">= 18.0.0"
+      }
+    },
     "node_modules/ora": {
       "version": "9.3.0",
       "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz",

+ 2 - 1
src/server/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.7.7",
+  "version": "0.7.8",
   "description": "Oasis - Social Networking Utopia",
   "repository": {
     "type": "git",
@@ -56,6 +56,7 @@
     "node-llama-cpp": "^3.10.0",
     "non-private-ip": "^2.2.0",
     "open": "^8.4.2",
+    "openpgp": "^6.3.0",
     "packet-stream": "^2.0.6",
     "packet-stream-codec": "^1.2.0",
     "pdfjs-dist": "^5.2.133",

+ 21 - 12
src/views/AI_view.js

@@ -1,4 +1,4 @@
-const { div, h2, p, section, button, form, textarea, br, span, input } = require("../server/node_modules/hyperaxe");
+const { div, h2, p, section, button, form, textarea, br, span, input, label, select, option } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
 const { renderUrl } = require('../backend/renderUrl');
 
@@ -112,21 +112,30 @@ exports.aiView = (history = [], userPrompt = '') => {
                   : null,
               entry.trainStatus === 'approved' || entry.trainStatus === 'rejected'
                 ? null
-                : div({ style: 'display:flex; flex-direction:column; gap:8px; width:100%;' },
-                    div({ style: 'display:flex; gap:8px; flex-wrap:wrap;' },
-                      form({ method: 'POST', action: '/ai/approve', style: 'display:inline-block;' },
-                        input({ type: 'hidden', name: 'ts', value: String(entry.timestamp) }),
-                        button({ type: 'submit', class: 'approve-btn', style: 'background:#1e7e34;color:#fff;border:none;padding:0.45em 0.9em;border-radius:6px;cursor:pointer;' }, i18n.aiApproveTrain)
+                : div({ class: 'ai-approve-block' },
+                    form({ method: 'POST', action: '/ai/approve', class: 'ai-approve-form' },
+                      input({ type: 'hidden', name: 'ts', value: String(entry.timestamp) }),
+                      div({ class: 'ai-approve-meta' },
+                        label({ class: 'ai-approve-meta-label' }, i18n.aiApproveTagsLabel || 'Tags (comma-separated)'),
+                        input({ type: 'text', name: 'tags', placeholder: i18n.aiApproveTagsPlaceholder || 'e.g. oasis, governance, ecology', maxlength: '160' }),
+                        label({ class: 'ai-approve-meta-label' }, i18n.aiApproveRatingLabel || 'Rating'),
+                        select({ name: 'rating' },
+                          option({ value: '0' }, '—'),
+                          option({ value: '1' }, '★'),
+                          option({ value: '2' }, '★★'),
+                          option({ value: '3' }, '★★★'),
+                          option({ value: '4' }, '★★★★'),
+                          option({ value: '5' }, '★★★★★')
+                        )
                       ),
-                      form({ method: 'POST', action: '/ai/reject', style: 'display:inline-block;' },
-                        input({ type: 'hidden', name: 'ts', value: String(entry.timestamp) }),
-                        button({ type: 'submit', class: 'reject-btn', style: 'background:#a71d2a;color:#fff;border:none;padding:0.45em 0.9em;border-radius:6px;cursor:pointer;' }, i18n.aiRejectTrain)
+                      textarea({ name: 'custom', rows: 3, placeholder: i18n.aiCustomAnswerPlaceholder, class: 'ai-approve-custom' }),
+                      div({ class: 'ai-approve-actions' },
+                        button({ type: 'submit', class: 'approve-btn' }, i18n.aiApproveTrain)
                       )
                     ),
-                    form({ method: 'POST', action: '/ai/approve', style: 'display:flex; flex-direction:column; gap:6px;' },
+                    form({ method: 'POST', action: '/ai/reject', class: 'ai-approve-reject' },
                       input({ type: 'hidden', name: 'ts', value: String(entry.timestamp) }),
-                      textarea({ name: 'custom', rows: 3, placeholder: i18n.aiCustomAnswerPlaceholder, style: 'width:100%;' }),
-                      button({ type: 'submit', class: 'approve-custom-btn', style: 'align-self:flex-start;background:#0d6efd;color:#fff;border:none;padding:0.45em 0.9em;border-radius:6px;cursor:pointer;' }, i18n.aiApproveCustomTrain)
+                      button({ type: 'submit', class: 'reject-btn' }, i18n.aiRejectTrain)
                     )
                   )
             )

+ 128 - 55
src/views/activity_view.js

@@ -1,5 +1,19 @@
 const { div, h2, p, section, button, form, a, input, img, textarea, br, span, video: videoHyperaxe, audio: audioHyperaxe, table, tr, td, th } = require("../server/node_modules/hyperaxe");
-const { template, i18n, userLink, userLinkLabel } = require('./main_views');
+const { template, i18n, userLink, userLinkLabel, renderSpreadButton } = require('./main_views');
+const opinionCategories = require('../backend/opinion_categories');
+
+const OPINION_TYPES = new Set(['bookmark','votes','feed','image','audio','video','document','torrent','transfer']);
+const OPINION_ROUTES = {
+  feed:        (id) => `/feed/opinions/${encodeURIComponent(id)}`,
+  bookmark:    (id) => `/bookmarks/opinions/${encodeURIComponent(id)}`,
+  image:       (id) => `/images/opinions/${encodeURIComponent(id)}`,
+  audio:       (id) => `/audios/opinions/${encodeURIComponent(id)}`,
+  torrent:     (id) => `/torrents/opinions/${encodeURIComponent(id)}`,
+  video:       (id) => `/videos/opinions/${encodeURIComponent(id)}`,
+  document:    (id) => `/documents/opinions/${encodeURIComponent(id)}`,
+  votes:       (id) => `/votes/opinions/${encodeURIComponent(id)}`,
+  transfer:    (id) => `/transfers/opinions/${encodeURIComponent(id)}`
+};
 const moment = require("../server/node_modules/moment");
 const { renderUrl } = require('../backend/renderUrl');
 const { getConfig } = require("../configs/config-manager.js");
@@ -214,7 +228,7 @@ function buildActivityItemsWithPostThreads(deduped, allActions) {
 }
 
 exports.renderActionCards = renderActionCards;
-function renderActionCards(actions, userId, allActions) {
+function renderActionCards(actions, userId, allActions, spreadMap = new Map()) {
   const all = Array.isArray(allActions) ? allActions : actions;
   const byIdAll = new Map();
   for (const a0 of all) {
@@ -505,7 +519,8 @@ function renderActionCards(actions, userId, allActions) {
     }
 
     if (type === 'tribe') {
-      const { title, image, description, location, tags, isLARP, inviteMode, isAnonymous, members } = content;
+      const { title, image, description, location, tags, inviteMode, isAnonymous } = content;
+      if (isAnonymous === true) { skip = true; }
       const validTags = Array.isArray(tags) ? tags : [];
       cardBody.push(
         div({ class: 'card-section tribe' },
@@ -515,11 +530,9 @@ function renderActionCards(actions, userId, allActions) {
            div({ style: 'display:flex; gap:.6em; flex-wrap:wrap;' },
             location ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.tribeLocationLabel.toUpperCase()) + ':'), span({ class: 'card-value' }, ...renderUrl(location))) : "",
             typeof isAnonymous === 'boolean' ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeIsAnonymousLabel+ ':'), span({ class: 'card-value' }, isAnonymous ? i18n.tribePrivate : i18n.tribePublic)) : "",
-            inviteMode ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.tribeModeLabel) + ':'), span({ class: 'card-value' }, inviteMode.toUpperCase())) : "",
-            typeof isLARP === 'boolean' ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeLARPLabel+ ':'), span({ class: 'card-value' }, isLARP ? i18n.tribeYes : i18n.tribeNo)) : ""
-         ), 
-          Array.isArray(members) ? h2(`${i18n.tribeMembersCount}: ${members.length}`) : "",
-          image  
+            inviteMode ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.tribeModeLabel) + ':'), span({ class: 'card-value' }, inviteMode.toUpperCase())) : ""
+         ),
+          image
             ? renderMediaBlob(image, '/assets/images/default-tribe.png')
             : img({ src: '/assets/images/default-tribe.png', class: 'feed-image tribe-image' }),
           p({ class: 'tribe-description' }, ...renderUrl(description || '')),
@@ -1276,12 +1289,33 @@ function renderActionCards(actions, userId, allActions) {
     }
       
     if (type === 'aiExchange') {
-      const { ctx } = content;
+      const { ctx, lang, tags, rating } = content;
+      const helpful = Number(action.helpfulVotes || 0);
       cardBody.push(
         div({ class: 'card-section ai-exchange' },
           Array.isArray(ctx) && ctx.length
             ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.aiSnippetsLearned || 'Snippets learned') + ':'), span({ class: 'card-value' }, String(ctx.length)))
-            : ""
+            : null,
+          lang
+            ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.aiExchangeLang || 'Language') + ':'), span({ class: 'card-value' }, String(lang).toUpperCase()))
+            : null,
+          Array.isArray(tags) && tags.length
+            ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.aiExchangeTags || 'Tags') + ':'),
+                span({ class: 'card-value' }, tags.map(t => span({ class: 'ai-exchange-tag' }, '#' + t))))
+            : null,
+          rating > 0
+            ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.aiExchangeRating || 'Rating') + ':'), span({ class: 'card-value' }, '★'.repeat(rating) + '☆'.repeat(Math.max(0, 5 - rating))))
+            : null,
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, (i18n.aiExchangeHelpful || 'Helpful') + ':'),
+            span({ class: 'card-value' }, String(helpful)),
+            form({ method: 'POST', action: '/ai/exchange/vote', class: 'ai-exchange-vote-form' },
+              input({ type: 'hidden', name: 'target', value: action.id }),
+              input({ type: 'hidden', name: 'helpful', value: 'yes' }),
+              input({ type: 'hidden', name: 'returnTo', value: '/activity' }),
+              button({ type: 'submit', class: 'filter-btn' }, i18n.aiExchangeMarkHelpful || '+1 helpful')
+            )
+          )
         )
       );
     }
@@ -1511,31 +1545,71 @@ function renderActionCards(actions, userId, allActions) {
       return null;
     }
 
-    return div({ class: 'card card-rpg' },
-      div({ class: 'card-header' },
-        h2({ class: 'card-label' }, headerText),
-        type !== 'feed' && type !== 'aiExchange' && type !== 'bankWallet' && (!action.tipId || action.tipId === action.id)
-          ? (
-              isParliamentTarget
-                ? form(
-                    { method: "GET", action: "/parliament" },
-                    input({ type: "hidden", name: "filter", value: parliamentFilter }),
-                    button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-                  )
-                : isCourtsTarget
-                  ? form(
-                      { method: "GET", action: "/courts" },
-                      input({ type: "hidden", name: "filter", value: courtsFilter }),
-                      button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-                    )
-                  : form(
-                      { method: "GET", action: viewHref },
-                      button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-                    )
-            )
-          : ''
+    const viewDetailsForm = type !== 'feed' && type !== 'aiExchange' && type !== 'bankWallet' && (!action.tipId || action.tipId === action.id)
+      ? (
+          isParliamentTarget
+            ? form(
+                { method: "GET", action: "/parliament" },
+                input({ type: "hidden", name: "filter", value: parliamentFilter }),
+                button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+              )
+            : isCourtsTarget
+              ? form(
+                  { method: "GET", action: "/courts" },
+                  input({ type: "hidden", name: "filter", value: courtsFilter }),
+                  button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+                )
+              : form(
+                  { method: "GET", action: viewHref },
+                  button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+                )
+        )
+      : null;
+    if (viewDetailsForm && cardBody.length > 0 && cardBody[0] && typeof cardBody[0].insertBefore === 'function') {
+      const host = cardBody[0];
+      const spacer = br();
+      const firstChild = host.childNodes && host.childNodes[0];
+      if (firstChild) {
+        host.insertBefore(spacer, firstChild);
+        host.insertBefore(viewDetailsForm, spacer);
+      } else {
+        host.appendChild(viewDetailsForm);
+        host.appendChild(spacer);
+      }
+    } else if (viewDetailsForm) {
+      cardBody.unshift(div({ class: 'card-section' }, viewDetailsForm, br()));
+    }
+    return div({ class: 'trending-card' },
+      div({ class: 'card-chips-row' },
+        span({ class: 'pm-exposition-chip pm-exposition-whole' },
+          span({ class: 'pm-exposition-text' }, String(type || '').toUpperCase())
+        )
       ),
-      div({ class: 'card-body' }, ...cardBody),
+      ...cardBody,
+      (() => {
+        if (!OPINION_TYPES.has(type)) return null;
+        const routeFn = OPINION_ROUTES[type];
+        if (!routeFn) return null;
+        const ops = (action.value?.content?.opinions) || (action.content?.opinions) || {};
+        return div({ class: 'voting-buttons' },
+          opinionCategories.map(cat =>
+            form({ method: 'POST', action: `${routeFn(action.id)}/${cat}` },
+              button({ class: 'vote-btn' }, `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${ops[cat] || 0}]`)
+            )
+          )
+        );
+      })(),
+      (() => {
+        const SPREADABLE = new Set([
+          'post','audio','video','image','document','torrent','bookmark',
+          'event','calendar','task','votes','vote','market','shop','shopProduct',
+          'project','transfer','job','report',
+          'chat','chatMessage','pad','padEntry','forum','map'
+        ]);
+        if (!SPREADABLE.has(type)) return null;
+        const btn = renderSpreadButton(action.id, spreadMap.get(action.id));
+        return btn ? div({ class: 'card-spread-left' }, btn) : null;
+      })(),
       p({ class: 'card-footer' },
         span({ class: 'date-link' }, `${date} ${i18n.performed} `),
         userLink(action.author)
@@ -1611,7 +1685,8 @@ function getViewDetailsAction(type, action) {
   }
 }
 
-exports.activityView = (actions, filter, userId, q = '') => {
+exports.activityView = (actions, filter, userId, q = '', extras = {}) => {
+  const spreadMap = (extras && extras.spreadMap) || new Map();
   const title = filter === 'mine' ? i18n.yourActivity : i18n.globalActivity;
   const desc = i18n.activityDesc;
 
@@ -1620,18 +1695,18 @@ exports.activityView = (actions, filter, userId, q = '') => {
     { type: 'all',       label: i18n.allButton },
     { type: 'mine',      label: i18n.mineButton },
     { type: 'report',    label: i18n.typeReport },
-    { type: 'karmaScore',label: i18n.typeKarmaScore },
+    { type: 'aiExchange',label: i18n.typeAiExchange },
     { type: 'about',     label: i18n.typeAbout },
     { type: 'tribe',     label: i18n.typeTribe },
     { type: 'parliament',label: i18n.typeParliament },
     { type: 'courts',    label: i18n.typeCourts },
     { type: 'votes',     label: i18n.typeVotes },
-    { type: 'calendar',  label: i18n.typeCalendar || 'Calendar' },
     { type: 'event',     label: i18n.typeEvent },
+    { type: 'calendar',  label: i18n.typeCalendar },
     { type: 'task',      label: i18n.typeTask },
+    { type: 'gameScore', label: i18n.typeGameScore },
     { type: 'feed',      label: i18n.typeFeed },
     { type: 'post',      label: i18n.typePost },
-    { type: 'spread',    label: i18n.typeSpread },
     { type: 'chat',      label: i18n.typeChat },
     { type: 'pad',       label: i18n.typePad },
     { type: 'forum',     label: i18n.typeForum },
@@ -1640,20 +1715,20 @@ exports.activityView = (actions, filter, userId, q = '') => {
     { type: 'market',    label: i18n.typeMarket },
     { type: 'shop',      label: i18n.typeShop },
     { type: 'project',   label: i18n.typeProject },
+    { type: 'transfer',  label: i18n.typeTransfer },
     { type: 'job',       label: i18n.typeJob },
     { type: 'curriculum',label: i18n.typeCurriculum },
-    { type: 'transfer',  label: i18n.typeTransfer },
-    { type: 'aiExchange',label: i18n.typeAiExchange },
-    { type: 'gameScore', label: i18n.typeGameScore },
-    { type: 'pixelia',   label: i18n.typePixelia },
     { type: 'audio',     label: i18n.typeAudio },
     { type: 'bookmark',  label: i18n.typeBookmark },
-    { type: 'image',     label: i18n.typeImage },
     { type: 'document',  label: i18n.typeDocument },
-    { type: 'video',     label: i18n.typeVideo },
-    { type: 'torrent',   label: i18n.typeTorrent }
+    { type: 'image',     label: i18n.typeImage },
+    { type: 'torrent',   label: i18n.typeTorrent },
+    { type: 'video',     label: i18n.typeVideo }
   ];
 
+  const EXCLUDED_TYPES = new Set(['spread', 'pixelia']);
+  actions = actions.filter(action => !EXCLUDED_TYPES.has(action.type));
+
   let filteredActions;
   if (filter === 'mine') {
     filteredActions = actions.filter(action => action.author === userId && action.type !== 'tombstone');
@@ -1661,7 +1736,7 @@ exports.activityView = (actions, filter, userId, q = '') => {
     const now = Date.now();
     filteredActions = actions.filter(action => action.type !== 'tombstone' && action.ts && now - action.ts < 24 * 60 * 60 * 1000);
   } else if (filter === 'banking') {
-    filteredActions = actions.filter(action => action.type !== 'tombstone' && (action.type === 'bankWallet' || action.type === 'bankClaim' || action.type === 'ubiClaim' || action.type === 'ubiclaimresult'));
+    filteredActions = actions.filter(action => action.type !== 'tombstone' && (action.type === 'bankWallet' || action.type === 'bankClaim' || action.type === 'ubiClaim'));
   } else if (filter === 'tribe') {
     filteredActions = actions.filter(action => action.type === 'tribe');
   } else if (filter === 'parliament') {
@@ -1673,8 +1748,6 @@ exports.activityView = (actions, filter, userId, q = '') => {
     });
   } else if (filter === 'task') {
     filteredActions = actions.filter(action => action.type !== 'tombstone' && (action.type === 'task' || action.type === 'taskAssignment'));
-  } else if (filter === 'spread') {
-    filteredActions = actions.filter(action => action.type === 'spread');
   } else if (filter === 'gameScore') {
     filteredActions = actions.filter(action => action.type === 'gameScore');
   } else if (filter === 'torrent') {
@@ -1738,12 +1811,12 @@ exports.activityView = (actions, filter, userId, q = '') => {
       ),
       div({ class: 'activity-filter-grid' },
         ...[
-          activityTypes.slice(0, 4),
-          activityTypes.slice(4, 12),
-          activityTypes.slice(12, 19),
-          activityTypes.slice(19, 26),
-          activityTypes.slice(26, 29),
-          activityTypes.slice(29)
+          activityTypes.slice(0, 5),
+          activityTypes.slice(5, 9),
+          activityTypes.slice(9, 13),
+          activityTypes.slice(13, 20),
+          activityTypes.slice(20, 27),
+          activityTypes.slice(27)
         ].map(col =>
           div({ class: 'activity-filter-col' },
             col.map(({ type, label }) =>
@@ -1760,7 +1833,7 @@ exports.activityView = (actions, filter, userId, q = '') => {
             sub.filters.map(f => a({ href: `${sub.url}?filter=${encodeURIComponent(f)}`, class: 'filter-btn' }, String(f).toUpperCase()))
           )
         : null,
-    section({ class: 'feed-container' }, renderActionCards(filteredActions, userId, actions))
+    section({ class: 'feed-container' }, renderActionCards(filteredActions, userId, actions, spreadMap))
     )
   );
 

+ 14 - 9
src/views/agenda_view.js

@@ -75,7 +75,6 @@ const renderAgendaItem = (item, userId, filter) => {
     details = [
       renderCardField(i18n.agendaAnonymousLabel + ":", item.isAnonymous ? i18n.agendaYes : i18n.agendaNo),
       renderCardField(i18n.agendaInviteModeLabel + ":", (item.inviteMode ? String(item.inviteMode).toUpperCase() : i18n.noInviteMode)),
-      renderCardField(i18n.agendaLARPLabel + ":", item.isLARP ? i18n.agendaYes : i18n.agendaNo),
       renderCardField(i18n.agendaLocationLabel + ":", item.location || i18n.noLocation),
       renderCardField(i18n.agendaMembersCount + ":", Array.isArray(item.members) ? item.members.length : 0),
       br()
@@ -186,15 +185,21 @@ const renderAgendaItem = (item, userId, filter) => {
     }
   }
 
-  return div({ class: 'agenda-item card' },
-    h2(`[${String(item.type || '').toUpperCase()}] ${item.title || item.name || item.concept || ''}`),
-    form({ method: "GET", action: getViewDetailsAction(item) },
-      button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+  return div({ class: 'agenda-item trending-card' },
+    div({ class: 'card-chips-row' },
+      span({ class: 'pm-exposition-chip pm-exposition-whole' },
+        span({ class: 'pm-exposition-text' }, String(item.type || '').toUpperCase())
+      )
+    ),
+    div({ class: 'card-section' },
+      form({ method: "GET", action: getViewDetailsAction(item) },
+        button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+      ),
+      actionButton,
+      br(),
+      h2(item.title || item.name || item.concept || ''),
+      ...details
     ),
-    actionButton,
-    br(),
-    ...details,
-    br(),
     ...commonFields
   );
 };

+ 171 - 69
src/views/audio_view.js

@@ -16,7 +16,7 @@ const {
   option
 } = require("../server/node_modules/hyperaxe");
 
-const { template, i18n, userLink, renderSpreadButton } = require("./main_views");
+const { template, i18n, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip } = require("./main_views");
 const moment = require("../server/node_modules/moment");
 const { config } = require("../server/SSB_server.js");
 const { renderUrl } = require("../backend/renderUrl")
@@ -63,11 +63,20 @@ const renderAudioFavoriteToggle = (audioObj, returnTo = "") =>
     )
   );
 
-const renderAudioPlayer = (audioObj) =>
+const renderTranscodeButton = (audioObj) =>
+  audioObj.isBcs
+    ? form(
+        { method: "GET", action: `/melody/transcode/${encodeURIComponent(audioObj.key)}`, class: "audio-transcode-form" },
+        button({ type: "submit", class: "filter-btn" }, i18n.audioTranscodeButton || "TRANSCODE")
+      )
+    : null;
+
+const renderAudioPlayer = (audioObj, opts = {}) =>
   audioObj?.url
     ? div(
-        { class: "audio-container", style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
-        audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(audioObj.url)}`, preload: "metadata" })
+        { class: "audio-container" },
+        audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(audioObj.url)}`, preload: "metadata" }),
+        opts.skipTranscode ? null : renderTranscodeButton(audioObj)
       )
     : p(i18n.audioNoFile);
 
@@ -198,6 +207,7 @@ const renderAudioList = exports.renderAudioList = (audios, filter, params = {})
             ownerActions.length ? div({ class: "bookmark-actions" }, ...ownerActions) : null
           ),
           title ? h2(title) : null,
+          audioObj.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(audioObj.lifetime, i18n)) : null,
           renderAudioPlayer(audioObj),
           div(
             { class: "card-comments-summary" },
@@ -214,6 +224,7 @@ const renderAudioList = exports.renderAudioList = (audios, filter, params = {})
               button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
             )
           ),
+          div({ class: "card-spread-left" }, renderSpreadButton(audioObj.key, (params.spreadMap && params.spreadMap.get(audioObj.key)) || params.spreads)),
           renderMapLocationVisitLabel(audioObj.mapUrl),
           br(),
           (() => {
@@ -308,13 +319,14 @@ exports.audioView = async (audios, filter = "all", audioId = null, params = {})
     section(
       div({ class: "tags-header" },
         h2(title),
-        p(i18n.audioDescription),
-        (() => {
-          const { renderReachChip } = require('./clearnet_view');
-          const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetAudios);
-          return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n));
-        })()
+        p(i18n.audioDescription)
       ),
+      (() => {
+        const { renderReachChip } = require('./clearnet_view');
+        const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetAudios);
+        return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n));
+      })(),
+      br(),
       div(
         { class: "filters" },
         form(
@@ -325,7 +337,7 @@ exports.audioView = async (audios, filter = "all", audioId = null, params = {})
           button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterMine),
           button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterRecent),
           button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterTop),
-          button({ type: "submit", name: "filter", value: "blockchain", class: filter === "blockchain" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterBlockchain || "BLOCKCHAIN"),
+          button({ type: "submit", name: "filter", value: "bcs", class: filter === "bcs" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterBcs || "BCS"),
           button(
             { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
             i18n.audioFilterFavorites
@@ -374,25 +386,93 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params
   const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort });
 
   const title = safeText(audioObj.title);
+  const isAuthor = String(audioObj.author) === String(userId);
+  const { renderReachChip } = require('./clearnet_view');
+  const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetAudios);
+
+  const chips = [
+    renderLifespanChip(audioObj.lifetime, i18n),
+    audioObj.sizeBytes ? renderEcoTax(audioObj.sizeBytes, audioObj.key) : null
+  ].filter(Boolean);
+
   const ownerActions = renderAudioOwnerActions(filter, audioObj, { q, sort });
+  const sideActions = [];
+  sideActions.push(renderAudioFavoriteToggle(audioObj, returnTo));
+  if (audioObj.author && String(audioObj.author) !== String(userId)) {
+    sideActions.push(form(
+      { method: "GET", action: "/pm" },
+      input({ type: "hidden", name: "recipients", value: audioObj.author }),
+      button({ type: "submit", class: "filter-btn" }, i18n.audioMessageAuthorButton)
+    ));
+  }
+  if (audioObj.isBcs) {
+    sideActions.push(form(
+      { method: "GET", action: `/melody/transcode/${encodeURIComponent(audioObj.key)}` },
+      button({ type: "submit", class: "filter-btn" }, i18n.audioTranscodeButton || "TRANSCODE")
+    ));
+  }
+  for (const a of ownerActions) sideActions.push(a);
 
-  const topbarLeft =
-    audioObj.author && String(audioObj.author) !== String(userId)
-      ? form(
-          { method: "GET", action: "/pm" },
-          input({ type: "hidden", name: "recipients", value: audioObj.author }),
-          button({ type: "submit", class: "filter-btn" }, i18n.audioMessageAuthorButton)
+  const tagsNode = renderTags(audioObj.tags);
+
+  const audioSide = div({ class: "tribe-side" },
+    div({ class: "shop-title-row" },
+      title ? h2({ class: "tribe-card-title" }, title) : null,
+      renderReachChip(isClearnet, i18n)
+    ),
+    chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
+    safeText(audioObj.description)
+      ? p({ class: "tribe-side-description" }, ...renderUrl(audioObj.description))
+      : null,
+    tagsNode,
+    div({ class: "card-spread-centered" }, renderSpreadButton(audioObj.key, params.spreads)),
+    renderMapLocationVisitLabel(audioObj.mapUrl)
+  );
+
+  const audioMain = div({ class: "tribe-main" },
+    sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null,
+    renderAudioPlayer(audioObj),
+    div({ class: "voting-buttons" },
+      opinionCategories.map((category) =>
+        form(
+          { method: "POST", action: `/audios/opinions/${encodeURIComponent(audioObj.key)}/${category}` },
+          input({ type: "hidden", name: "returnTo", value: returnTo }),
+          button(
+            { class: "vote-btn" },
+            `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${
+              audioObj.opinions?.[category] || 0
+            }]`
+          )
         )
-      : null;
+      )
+    ),
+    (() => {
+      const createdTs = audioObj.createdAt ? new Date(audioObj.createdAt).getTime() : NaN;
+      const updatedTs = audioObj.updatedAt ? new Date(audioObj.updatedAt).getTime() : NaN;
+      const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
 
-  const topbar = div(
-    { class: "bookmark-topbar" },
-    div({ class: "bookmark-actions" }, renderAudioFavoriteToggle(audioObj, returnTo), ...ownerActions)
+      return p(
+        { class: "card-footer" },
+        span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
+        userLink(audioObj.author),
+        showUpdated
+          ? span(
+              { class: "votations-comment-date" },
+              ` | ${i18n.audioUpdatedAt}: ${moment(audioObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
+            )
+          : null
+      );
+    })(),
+    renderAudioCommentsSection(audioObj.key, comments, returnTo)
   );
 
   return template(
     i18n.audioTitle,
     section(
+      div({ class: "tags-header" },
+        h2(i18n.audioAllSectionTitle || i18n.audioTitle),
+        p(i18n.audioDescription)
+      ),
       div(
         { class: "filters" },
         form(
@@ -403,7 +483,7 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params
           button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterMine),
           button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterRecent),
           button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterTop),
-          button({ type: "submit", name: "filter", value: "blockchain", class: filter === "blockchain" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterBlockchain || "BLOCKCHAIN"),
+          button({ type: "submit", name: "filter", value: "bcs", class: filter === "bcs" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterBcs || "BCS"),
           button(
             { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
             i18n.audioFilterFavorites
@@ -411,57 +491,79 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params
           button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.audioCreateButton)
         )
       ),
-      div(
-        { class: "bookmark-item card" },
-        topbar,
-        title ? h2(title) : null,
-        renderAudioPlayer(audioObj),
-        safeText(audioObj.description) ? p(...renderUrl(audioObj.description)) : null,
-        (() => {
-          const { renderReachChip } = require('./clearnet_view');
-          const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetAudios);
-          return div({ class: 'shop-title-row' }, renderReachChip(isClearnet, i18n));
-        })(),
-        renderTags(audioObj.tags),
+      div({ class: "tribe-details" }, audioSide, audioMain)
+    )
+  );
+};
+
+const { renderCompositionSequence } = require("./melody_view");
+
+exports.audioTranscodeDetailView = async ({ audio, decoded = false, stegoPayload = null, availableIds = null, itemSize = null }) => {
+  const title = i18n.audioTranscodeDetailTitle || "Transcode";
+  const composition = Array.isArray(audio.bcsComposition) ? audio.bcsComposition : [];
+  const hasStego = decoded && stegoPayload && (stegoPayload.id || stegoPayload.ts || stegoPayload.msg);
+  const stegoDate = hasStego && Number.isFinite(stegoPayload.ts) ? moment(stegoPayload.ts).format("YYYY/MM/DD HH:mm:ss") : null;
+
+  return template(
+    title,
+    section(
+      div({ class: "tags-header" },
+        h2(title),
+        p(i18n.audioTranscodeDetailDescription || "Decode the embedded payload and the original blockchain composition map.")
+      ),
+      div({ class: "filters" },
+        form({ method: "GET", action: "/melody", class: "ui-toolbar ui-toolbar--filters" },
+          input({ type: "hidden", name: "filter", value: "all" }),
+          button({ type: "submit", class: "filter-btn" }, i18n.audioBackToBcs || "Back to BCS")
+        )
+      ),
+      div({ class: "bookmark-item card" },
+        audio.title ? h2(audio.title) : null,
+        renderAudioPlayer(audio, { skipTranscode: true }),
+        p({ class: "transcode-meta card-footer" },
+          userLink(audio.author),
+          span({ class: "melody-meta-sep" }, " · "),
+          span({ class: "card-value" }, moment(audio.createdAt).format("YYYY/MM/DD HH:mm:ss")),
+          itemSize ? span({ class: "melody-meta-sep" }, " · ") : null,
+          itemSize ? renderEcoTax(itemSize, audio.key) : null
+        ),
+        safeText(audio.description) ? p({ class: "melody-bcs-desc" }, audio.description) : null,
+        renderTags(audio.tags),
         br(),
-        renderMapLocationVisitLabel(audioObj.mapUrl),
+        form({ method: "POST", action: `/melody/transcode/${encodeURIComponent(audio.key)}`, class: "audio-transcode-run-form" },
+          button({ type: "submit", class: "filter-btn" }, i18n.audioTranscodeButton || "TRANSCODE")
+        ),
         br(),
-        (() => {
-          const createdTs = audioObj.createdAt ? new Date(audioObj.createdAt).getTime() : NaN;
-          const updatedTs = audioObj.updatedAt ? new Date(audioObj.updatedAt).getTime() : NaN;
-          const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
-
-          return p(
-            { class: "card-footer" },
-            span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
-            userLink(audioObj.author),
-            showUpdated
-              ? span(
-                  { class: "votations-comment-date" },
-                  ` | ${i18n.audioUpdatedAt}: ${moment(audioObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
-                )
-              : null
-          );
-        })(),
-        div({ class: "spread-row" }, renderSpreadButton(audioObj.key, params.spreads)),
-        div(
-          { class: "voting-buttons" },
-          opinionCategories.map((category) =>
-            form(
-              { method: "POST", action: `/audios/opinions/${encodeURIComponent(audioObj.key)}/${category}` },
-              input({ type: "hidden", name: "returnTo", value: returnTo }),
-              button(
-                { class: "vote-btn" },
-                `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${
-                  audioObj.opinions?.[category] || 0
-                }]`
-              )
+        decoded
+          ? div({ class: "transcode-result" },
+              hasStego
+                ? [
+                    div({ class: "transcode-stego-field" },
+                      span({ class: "card-label" }, (i18n.audioTranscodeStegoTimestamp || "Generated at") + ": "),
+                      span({ class: "card-value" }, stegoDate || (i18n.audioTranscodeStegoUnknown || "—"))
+                    ),
+                    div({ class: "transcode-stego-field" },
+                      span({ class: "card-label" }, (i18n.audioTranscodeStegoOasisId || "By") + ": "),
+                      stegoPayload.id ? userLink(stegoPayload.id) : span({ class: "card-value" }, i18n.audioTranscodeStegoUnknown || "—")
+                    ),
+                    div({ class: "transcode-stego-field transcode-stego-msg" },
+                      span({ class: "card-label" }, (i18n.audioTranscodeStegoMessage || "TEXT") + ":"),
+                      br(),
+                      stegoPayload.msg
+                        ? p({ class: "transcode-stego-text" }, stegoPayload.msg)
+                        : span({ class: "card-value" }, i18n.audioTranscodeStegoEmpty || "(none)")
+                    )
+                  ]
+                : p({ class: "empty" }, i18n.audioTranscodeStegoNotFound || "No steganographic payload could be decoded from this audio."),
+              composition.length
+                ? renderCompositionSequence(composition, availableIds)
+                : p({ class: "empty" }, i18n.audioTranscodeCompositionEmpty || "This audio does not include a stored blockchain composition.")
             )
-          )
-        )
-      ),
-      div({ id: "comments" }, renderAudioCommentsSection(audioObj.key, comments, returnTo))
+          : null
+      )
     )
   );
 };
 
+exports.audiosTranscodeView = exports.audioTranscodeDetailView;
+

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 221 - 12
src/views/banking_views.js


+ 66 - 17
src/views/blockchain_view.js

@@ -1,5 +1,5 @@
 const { div, h2, h3, p, section, button, form, a, input, span, pre, table, tr, td, strong } = require("../server/node_modules/hyperaxe");
-const { template, i18n } = require("../views/main_views");
+const { template, i18n, userLink } = require("../views/main_views");
 const moment = require("../server/node_modules/moment");
 
 const FILTER_LABELS = {
@@ -299,7 +299,7 @@ const renderBlockDiagram = (blocks, qs) => {
 };
 
 const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, viewMode = 'block', restricted = false) => {
-  if (!block) {
+  if (!block || block.notAvailable) {
     return template(
       i18n.blockchain,
       section(
@@ -307,7 +307,16 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, vi
           h2(i18n.blockchain),
           p(i18n.blockchainDescription)
         ),
-        p(i18n.blockchainNoBlocks || 'No blocks')
+        div({ class: 'block-single' },
+          block && block.id ? div({ class: 'block-row block-row--meta' },
+            span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockID || 'Block ID'}:`),
+            span({ class: 'blockchain-card-value' }, block.id)
+          ) : null,
+          p({ class: 'empty' }, i18n.blockchainBlockNotAvailable || 'This block is not available in your local feed. It may belong to a peer you are not yet replicating from.'),
+          div({ class: 'larp-actions' },
+            a({ href: '/blockexplorer', class: 'filter-btn' }, i18n.blockchainBackToBlockexplorer || 'Back to blockexplorer')
+          )
+        )
       )
     );
   }
@@ -323,10 +332,12 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, vi
             span({ class: 'blockchain-card-value' }, block.id)
           ),
           div({ class: 'block-row block-row--meta' },
-            span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockTimestamp}:`),
-            span({ class: 'blockchain-card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ')),
             span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockType}:`),
             span({ class: 'blockchain-card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())
+          ),
+          div({ class: 'block-row block-row--meta' },
+            span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockTimestamp}:`),
+            span({ class: 'blockchain-card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ'))
           )
         ),
         div({ class: 'block-row block-row--content' },
@@ -342,29 +353,36 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, vi
               span({ class: 'blockchain-card-value' }, block.id)
             ),
             div({ class: 'block-row block-row--meta' },
-              span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockTimestamp}:`),
-              span({ class: 'blockchain-card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ')),
               span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockType}:`),
               span({ class: 'blockchain-card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())
             ),
             div({ class: 'block-row block-row--meta' },
-              span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockCarbon || 'Carbon footprint'}:`),
-              span({ class: 'blockchain-card-value' }, formatCarbon(block.size))
-            ),
-            div({ class: 'block-row block-row--meta block-row--meta-spaced' },
+              span({ class: 'blockchain-card-label' }, `${i18n.oasisIdLabel || 'OasisID'}:`),
               a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author)
             )
           ),
           div({ class:'block-row block-row--content' },
             div({ class:'block-content-preview' },
               block.content && typeof block.content.encryptedPayload === 'string'
-                ? div({ class: 'encrypted-payload-box' },
-                    p({ class: 'encrypted-label' }, `[${i18n.bxEncrypted || 'ENCRYPTED'}]`),
-                    p({ class: 'encrypted-hex-label' }, i18n.bxEncryptedHexLabel || 'Ciphertext (preview)'),
-                    pre({ class: 'json-content' }, String(block.content.encryptedPayload).slice(0, 128) + (String(block.content.encryptedPayload).length > 128 ? '…' : ''))
-                  )
+                ? (() => {
+                    const { renderEncryptedChip } = require('./clearnet_view');
+                    return div({ class: 'encrypted-payload-box' },
+                      div({ class: 'title-with-chip' }, renderEncryptedChip(i18n)),
+                      pre({ class: 'json-content' }, String(block.content.encryptedPayload).slice(0, 128) + (String(block.content.encryptedPayload).length > 128 ? '…' : ''))
+                    );
+                  })()
                 : pre({ class:'json-content' }, JSON.stringify(block.content,null,2))
             )
+          ),
+          div({ class: 'block-single' },
+            div({ class: 'block-row block-row--meta' },
+              span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockTimestamp}:`),
+              span({ class: 'blockchain-card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ'))
+            ),
+            div({ class: 'block-row block-row--meta' },
+              span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockCarbon || 'Carbon footprint'}:`),
+              span({ class: 'blockchain-card-value' }, formatCarbon(block.size))
+            )
           )
         );
 
@@ -409,12 +427,14 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, vi
   );
 };
 
-const renderBlockchainView = (blocks, filter, userId, search = {}) => {
+const renderBlockchainView = (blocks, filter, userId, search = {}, extras = {}) => {
   const s = search || {};
   const authorVal = String(s.author || '');
   const idVal = String(s.id || '');
   const fromVal = toDatetimeLocal(s.from);
   const toVal = toDatetimeLocal(s.to);
+  const inspect = extras && extras.inspect ? extras.inspect : null;
+  const inspectVal = inspect ? String(inspect.block || '') : '';
 
   const shown = filterBlocks(blocks, filter, userId);
   const qs = toQueryString(filter, s);
@@ -457,6 +477,35 @@ const renderBlockchainView = (blocks, filter, userId, search = {}) => {
 	    )
 	  )
 	),
+      div({ id: 'inspect', class: 'blockexplorer-inspect' },
+        h3(i18n.bankTaxesLookupTitle || 'Inspect a block'),
+        p(i18n.bankTaxesLookupNote || 'Enter a block ID to look it up in your local feed. The inspector shows the block ID (linked to its detail page), its author, its content type, its size in bytes, its carbon footprint and its ECO tax in ECO.'),
+        form({ method: 'GET', action: '/blockexplorer', class: 'blockexplorer-search-form' },
+          input({ type: 'hidden', name: 'filter', value: filter }),
+          div({ class: 'blockexplorer-search-row' },
+            div({ class: 'blockexplorer-search-pair' },
+              input({ type: 'text', name: 'inspect', value: inspectVal, placeholder: i18n.bankTaxesLookupPlaceholder || 'Block ID (e.g. %abc...=.sha256)', class: 'blockexplorer-search-input' })
+            ),
+            div({ class: 'blockexplorer-search-actions' },
+              button({ type: 'submit', class: 'filter-box__button' }, i18n.bankTaxesLookupButton || 'Inspect')
+            )
+          )
+        ),
+        inspect
+          ? (inspect.found
+              ? div({ class: 'bank-taxes-lookup-result' },
+                  table({ class: 'bank-info-table' },
+                    tr(td({ class: 'card-label' }, i18n.blockchainBlockID || 'Block ID'), td({ class: 'card-value' }, a({ href: `/blockexplorer/block/${encodeURIComponent(inspect.block)}` }, inspect.block))),
+                    tr(td({ class: 'card-label' }, i18n.bankTaxesLookupAuthor || 'Author'), td({ class: 'card-value' }, inspect.author ? userLink(inspect.author) : '—')),
+                    tr(td({ class: 'card-label' }, i18n.bankTaxesLookupType || 'Type'), td({ class: 'card-value' }, String(inspect.blockType || '—'))),
+                    tr(td({ class: 'card-label' }, i18n.bankTaxesLookupSize || 'Size'), td({ class: 'card-value' }, `${Number(inspect.size || 0).toLocaleString()} B`)),
+                    tr(td({ class: 'card-label' }, i18n.bankTaxesLookupCarbon || 'Carbon footprint'), td({ class: 'card-value' }, formatCarbon(inspect.size || 0))),
+                    tr(td({ class: 'card-label' }, i18n.bankTaxesLookupEcoin || 'ECO Tax'), td({ class: 'card-value' }, `${Number(inspect.ecoinTax || 0).toFixed(6)} ECO`))
+                  )
+                )
+              : p({ class: 'empty' }, i18n.bankTaxesLookupNotFound || 'No block found in your local feed with that ID.'))
+          : null
+      ),
       renderBlockDiagram(shown, qs),
       h2({ class: 'block-diagram-title' }, 'Blockchain Blocks'),
       shown.length === 0

+ 90 - 66
src/views/bookmark_view.js

@@ -1,7 +1,7 @@
 const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option } =
   require("../server/node_modules/hyperaxe");
 
-const { template, i18n, userLink, renderSpreadButton} = require("./main_views");
+const { template, i18n, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip } = require("./main_views");
 const moment = require("../server/node_modules/moment");
 const { config } = require("../server/SSB_server.js");
 const { renderUrl } = require("../backend/renderUrl");
@@ -196,9 +196,9 @@ const renderBookmarkList = (filteredBookmarks, filter, params = {}) => {
             renderBookmarkActions(filter, bookmark, params)
           ),
           h2({ class: "bookmark-title" }, bookmark.category || bookmark.url || ""),
+          bookmark.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(bookmark.lifetime, i18n)) : null,
           renderCardField(i18n.bookmarkUrlLabel + ":", urlLink),
           renderCardField(i18n.bookmarkLastVisitLabel + ":", lastVisitTxt),
-          renderCardField(i18n.bookmarkCategoryLabel + ":", safeText(bookmark.category) || i18n.noCategory),
           br,
           div(
             { class: "card-comments-summary" },
@@ -215,6 +215,7 @@ const renderBookmarkList = (filteredBookmarks, filter, params = {}) => {
               button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
             )
           ),
+          div({ class: "card-spread-left" }, renderSpreadButton(bookmark.id, (params.spreadMap && params.spreadMap.get(bookmark.id)) || params.spreads)),
           (() => {
             const createdTs = bookmark.createdAt ? new Date(bookmark.createdAt).getTime() : NaN;
             const updatedTs = bookmark.updatedAt ? new Date(bookmark.updatedAt).getTime() : NaN;
@@ -335,6 +336,12 @@ exports.bookmarkView = async (bookmarks, filter = "all", bookmarkId = null, para
     title,
     section(
       div({ class: "tags-header" }, h2(title), p(i18n.bookmarkDescription)),
+      (() => {
+        const { renderReachChip } = require('./clearnet_view');
+        const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetBookmarks);
+        return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n));
+      })(),
+      br(),
       div(
         { class: "filters" },
         form(
@@ -385,6 +392,8 @@ exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], par
 
   const isAuthor = String(bookmark.author) === String(userId);
   const hasOpinions = Object.keys(bookmark.opinions || {}).length > 0;
+  const { renderReachChip } = require('./clearnet_view');
+  const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetBookmarks);
 
   const lastVisit = bookmark.lastVisit ? moment(bookmark.lastVisit) : null;
   const lastVisitTxt =
@@ -396,35 +405,91 @@ exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], par
     ? a({ href: bookmark.url, target: "_blank", rel: "noreferrer noopener", class: "bookmark-url" }, bookmark.url)
     : i18n.noUrl;
 
-  const pmBtn = renderPMButton(bookmark.author);
+  const chips = [
+    renderLifespanChip(bookmark.lifetime, i18n),
+    bookmark.sizeBytes ? renderEcoTax(bookmark.sizeBytes, bookmark.id) : null
+  ].filter(Boolean);
+
+  const sideActions = [];
+  sideActions.push(renderFavoriteToggle(bookmark, returnTo));
+  if (bookmark.author && String(bookmark.author) !== String(userId)) {
+    sideActions.push(renderPMButton(bookmark.author));
+  }
+  if (isAuthor && !hasOpinions) {
+    sideActions.push(form(
+      { method: "GET", action: `/bookmarks/edit/${encodeURIComponent(bookmark.id)}` },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      button({ class: "update-btn", type: "submit" }, i18n.bookmarkUpdateButton)
+    ));
+  }
+  if (isAuthor) {
+    sideActions.push(form(
+      { method: "POST", action: `/bookmarks/delete/${encodeURIComponent(bookmark.id)}` },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      button({ class: "delete-btn", type: "submit" }, i18n.bookmarkDeleteButton)
+    ));
+  }
 
-  const actions =
-    isAuthor
-      ? div(
-          { class: "bookmark-actions" },
-          renderFavoriteToggle(bookmark, returnTo),
-          !hasOpinions
-            ? form(
-                { method: "GET", action: `/bookmarks/edit/${encodeURIComponent(bookmark.id)}` },
-                input({ type: "hidden", name: "returnTo", value: returnTo }),
-                button({ class: "update-btn", type: "submit" }, i18n.bookmarkUpdateButton)
-              )
-            : null,
-          form(
-            { method: "POST", action: `/bookmarks/delete/${encodeURIComponent(bookmark.id)}` },
-            input({ type: "hidden", name: "returnTo", value: returnTo }),
-            button({ class: "delete-btn", type: "submit" }, i18n.bookmarkDeleteButton)
+  const tagsNode = renderTags(bookmark.tags);
+
+  const bookmarkSide = div({ class: "tribe-side" },
+    div({ class: "shop-title-row" },
+      h2({ class: "tribe-card-title" }, bookmark.category || bookmark.url || ""),
+      renderReachChip(isClearnet, i18n)
+    ),
+    chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
+    safeText(bookmark.description)
+      ? p({ class: "tribe-side-description" }, ...renderUrl(bookmark.description))
+      : null,
+    safeText(bookmark.category)
+      ? renderCardField(i18n.bookmarkCategoryLabel + ":", safeText(bookmark.category))
+      : null,
+    tagsNode,
+    div({ class: "card-spread-centered" }, renderSpreadButton(bookmark.id, params.spreads))
+  );
+
+  const bookmarkMain = div({ class: "tribe-main" },
+    sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null,
+    renderCardField(i18n.bookmarkUrlLabel + ":", urlLink),
+    renderCardField(i18n.bookmarkLastVisitLabel + ":", lastVisitTxt),
+    div({ class: "voting-buttons" },
+      opinionCategories.map((category) =>
+        form(
+          { method: "POST", action: `/bookmarks/opinions/${encodeURIComponent(bookmark.id)}/${category}` },
+          input({ type: "hidden", name: "returnTo", value: returnTo }),
+          button(
+            { class: "vote-btn" },
+            `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${bookmark.opinions?.[category] || 0}]`
           )
         )
-      : div(
-          { class: "bookmark-actions" },
-          pmBtn,
-          renderFavoriteToggle(bookmark, returnTo)
-        );
+      )
+    ),
+    (() => {
+      const createdTs = bookmark.createdAt ? new Date(bookmark.createdAt).getTime() : NaN;
+      const updatedTs = bookmark.updatedAt ? new Date(bookmark.updatedAt).getTime() : NaN;
+      const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
+      return p(
+        { class: "card-footer" },
+        span({ class: "date-link" }, `${moment(bookmark.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
+        userLink(bookmark.author),
+        showUpdated
+          ? span(
+              { class: "votations-comment-date" },
+              ` | ${i18n.bookmarkUpdatedAt}: ${moment(bookmark.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
+            )
+          : null
+      );
+    })(),
+    renderBookmarkCommentsSection(bookmark.id, bookmark.rootId, comments, returnTo)
+  );
 
   return template(
     i18n.bookmarkTitle,
     section(
+      div({ class: "tags-header" },
+        h2(i18n.bookmarkAllSectionTitle || i18n.bookmarkTitle),
+        p(i18n.bookmarkDescription)
+      ),
       div(
         { class: "filters" },
         form(
@@ -439,48 +504,7 @@ exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], par
           button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.bookmarkCreateButton)
         )
       ),
-      div(
-        { class: "bookmark-item card" },
-        actions,
-        h2({ class: "bookmark-title" }, bookmark.category || bookmark.url || ""),
-        renderCardField(i18n.bookmarkUrlLabel + ":", urlLink),
-        renderCardField(i18n.bookmarkLastVisitLabel + ":", lastVisitTxt),
-        renderCardField(i18n.bookmarkCategoryLabel + ":", safeText(bookmark.category) || i18n.noCategory),
-        safeText(bookmark.description) ? p(...renderUrl(bookmark.description)) : null,
-        renderTags(bookmark.tags),
-        br(),
-        (() => {
-          const createdTs = bookmark.createdAt ? new Date(bookmark.createdAt).getTime() : NaN;
-          const updatedTs = bookmark.updatedAt ? new Date(bookmark.updatedAt).getTime() : NaN;
-          const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
-          return p(
-            { class: "card-footer" },
-            span({ class: "date-link" }, `${moment(bookmark.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
-            userLink(bookmark.author),
-            showUpdated
-              ? span(
-                  { class: "votations-comment-date" },
-                  ` | ${i18n.bookmarkUpdatedAt}: ${moment(bookmark.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
-                )
-              : null
-          );
-        })(),
-        div({ class: "spread-row" }, renderSpreadButton(bookmark.id, params.spreads)),
-        div(
-          { class: "voting-buttons" },
-          opinionCategories.map((category) =>
-            form(
-              { method: "POST", action: `/bookmarks/opinions/${encodeURIComponent(bookmark.id)}/${category}` },
-              input({ type: "hidden", name: "returnTo", value: returnTo }),
-              button(
-                { class: "vote-btn" },
-                `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${bookmark.opinions?.[category] || 0}]`
-              )
-            )
-          )
-        )
-      ),
-      renderBookmarkCommentsSection(bookmark.id, bookmark.rootId, comments, returnTo)
+      div({ class: "tribe-details" }, bookmarkSide, bookmarkMain)
     )
   );
 };

+ 40 - 16
src/views/calendars_view.js

@@ -1,5 +1,6 @@
 const { div, h2, h3, h4, p, section, button, form, a, span, br, textarea, input, label, select, option, table, tr, td, ul, li } = require("../server/node_modules/hyperaxe")
-const { template, i18n, userLink} = require("./main_views")
+const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton } = require("./main_views")
+const { renderEncryptedChip } = require("./clearnet_view")
 const moment = require("../server/node_modules/moment")
 const { config } = require("../server/SSB_server.js")
 
@@ -46,21 +47,35 @@ const renderStatus = (cal) => {
   return span({ class: "pad-status-open" }, i18n.calendarStatusOpen || "OPEN")
 }
 
-const renderCalendarCard = (cal) => {
+const renderCalendarStatusChip = (cal) => {
+  const isClosed = !!cal.isClosed
+  const variant = isClosed ? "closed" : "mutuals"
+  const icon = isClosed ? "\u2717" : "\u2713"
+  const label = isClosed ? (i18n.calendarStatusClosed || "CLOSED") : (i18n.calendarStatusOpen || "OPEN")
+  return renderStateChip(variant, icon, label)
+}
+
+const renderCalendarCard = (cal, spreadInfo) => {
   const href = `/calendars/${encodeURIComponent(cal.rootId)}`
+  const chips = [
+    renderCalendarStatusChip(cal),
+    renderEncryptedChip(i18n),
+    renderLifespanChip(cal.lifetime, i18n)
+  ].filter(Boolean)
   return div({ class: "tribe-card" },
     div({ class: "tribe-card-body" },
-      div({ class: "tribe-card-title" },
-        a({ href }, cal.title || "\u2014")
-      ),
-      table({ class: "tribe-info-table" },
-        tr(td({ class: "tribe-info-label" }, i18n.calendarStatusLabel || "Status"), td({ class: "tribe-info-value" }, renderStatus(cal))),
-        cal.deadline ? tr(td({ class: "tribe-info-label" }, i18n.calendarDeadlineLabel || "Deadline"), td({ class: "tribe-info-value" }, moment(cal.deadline).format("YYYY-MM-DD HH:mm"))) : null,
+      div({ class: "shop-title-row" },
+        h2({ class: "tribe-card-title" }, a({ href }, cal.title || "\u2014"))
       ),
+      chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
+      cal.deadline
+        ? p({ class: "job-meta-line" }, `${i18n.calendarDeadlineLabel || "Deadline"}: ${moment(cal.deadline).format("YYYY-MM-DD HH:mm")}`)
+        : null,
       div({ class: "tribe-card-members" },
         span({ class: "tribe-members-count calendar-participants-count" }, `${i18n.calendarParticipantsLabel || "Participants"}: ${cal.participants.length}`)
       ),
-      div({ class: "visit-btn-centered" },
+      div({ class: "card-spread-centered" }, renderSpreadButton(cal.rootId, spreadInfo)),
+      div({ class: "card-visit-btn-centered" },
         a({ href, class: "filter-btn" }, i18n.calendarVisitCalendar || "Visit Calendar")
       )
     )
@@ -210,7 +225,7 @@ exports.calendarsView = async (calendars, filter, calendarToEdit, params) => {
       showForm
         ? renderCreateForm(calendarToEdit, params)
         : (calendars.length > 0
-            ? div({ class: "tribe-grid" }, ...calendars.map(c => renderCalendarCard(c)))
+            ? div({ class: "tribe-grid" }, ...calendars.map(c => renderCalendarCard(c, params && params.spreadMap && params.spreadMap.get(c.rootId))))
             : p({ class: "no-content" }, i18n.calendarsNoItems || "No calendars found."))
     )
   )
@@ -241,19 +256,25 @@ exports.singleCalendarView = async (calendar, dates, notesByDate, params) => {
     ? div({ class: "tribe-side-tags" }, ...calendar.tags.map(t => a({ href: `/search?query=%23${encodeURIComponent(t)}` }, `#${t} `)))
     : null
 
+  const detailChips = [
+    renderCalendarStatusChip(calendar),
+    renderEncryptedChip(i18n),
+    renderLifespanChip(calendar.lifetime, i18n)
+  ].filter(Boolean)
   const calSide = div({ class: "tribe-side" },
-    h2(null, calendar.title || "\u2014"),
+    div({ class: "shop-title-row" },
+      h2({ class: "tribe-card-title" }, calendar.title || "\u2014")
+    ),
+    detailChips.length ? div({ class: "card-chips-row" }, ...detailChips) : null,
+    div({ class: "card-spread-centered" }, renderSpreadButton(calendar.rootId, params && params.spreads)),
     div({ class: "shop-share" },
       span({ class: "tribe-info-label" }, i18n.calendarsShareUrl || "Share URL"),
       input({ type: "text", readonly: true, value: shareUrl, class: "shop-share-input" })
     ),
-    div({ class: "tribe-card-members" },
-      span({ class: "tribe-members-count calendar-participants-count" }, `${i18n.calendarParticipantsLabel || "Participants"}: ${calendar.participants.length}`)
-    ),
     table({ class: "tribe-info-table" },
       tr(td({ class: "tribe-info-label" }, i18n.calendarCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(calendar.createdAt).format("YYYY-MM-DD"))),
-      tr(td({ class: "tribe-info-value", colspan: "4" }, userLink(calendar.author))),
       tr(td({ class: "tribe-info-label" }, i18n.calendarStatusLabel || "Status"), td({ class: "tribe-info-value", colspan: "3" }, renderStatus(calendar))),
+      tr(td({ class: "tribe-info-value", colspan: "4" }, userLink(calendar.author))),
       calendar.deadline ? tr(td({ class: "tribe-info-label" }, i18n.calendarDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, moment(calendar.deadline).format("YYYY-MM-DD HH:mm"))) : null
     ),
     div({ class: "tribe-side-actions" },
@@ -295,7 +316,10 @@ exports.singleCalendarView = async (calendar, dates, notesByDate, params) => {
           )
         : null
     ),
-    tags
+    tags,
+    div({ class: "tribe-card-members" },
+      span({ class: "tribe-members-count calendar-participants-count" }, `${i18n.calendarParticipantsLabel || "Participants"}: ${calendar.participants.length}`)
+    )
   )
 
   const minDate = now.add(1, "minute").format("YYYY-MM-DDTHH:mm")

+ 39 - 17
src/views/chats_view.js

@@ -1,5 +1,6 @@
 const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, td, ul, li } = require("../server/node_modules/hyperaxe")
-const { template, i18n, userLink} = require("./main_views")
+const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton } = require("./main_views")
+const { renderEncryptedChip } = require("./clearnet_view")
 const moment = require("../server/node_modules/moment")
 const { config } = require("../server/SSB_server.js")
 const { renderUrl } = require("../backend/renderUrl")
@@ -48,9 +49,22 @@ const renderModeButtons = (currentFilter) =>
     )
   )
 
+const renderChatStatusChip = (status) => {
+  const s = String(status || "OPEN").toUpperCase()
+  const variant = s === "CLOSED" ? "closed" : s === "INVITE-ONLY" ? "whole" : "mutuals"
+  const icon = s === "CLOSED" ? "\u2717" : s === "INVITE-ONLY" ? "\uD83D\uDD11" : "\u2713"
+  const label = s === "CLOSED" ? i18n.chatStatusClosed
+    : s === "INVITE-ONLY" ? i18n.chatStatusInviteOnly
+    : i18n.chatStatusOpen
+  return renderStateChip(variant, icon, label)
+}
+
 const renderChatCard = (chat, filter, params = {}) => {
-  const statusLabel = chat.status === "CLOSED" ? i18n.chatStatusClosed :
-    chat.status === "INVITE-ONLY" ? i18n.chatStatusInviteOnly : i18n.chatStatusOpen
+  const chips = [
+    renderChatStatusChip(chat.status),
+    renderEncryptedChip(i18n),
+    renderLifespanChip(chat.lifetime, i18n)
+  ].filter(Boolean)
 
   return div({ class: "tribe-card" },
     div({ class: "tribe-card-image-wrapper" },
@@ -59,20 +73,20 @@ const renderChatCard = (chat, filter, params = {}) => {
       )
     ),
     div({ class: "tribe-card-body" },
-      h2({ class: "tribe-card-title" },
-        a({ href: `/chats/${encodeURIComponent(chat.key)}` }, "\uD83D\uDD12 " + (chat.title || i18n.chatUntitled))
-      ),
-      chat.description ? p({ class: "tribe-card-description" }, chat.description) : null,
-      br(),
-      table({ class: "tribe-info-table" },
-        tr(
-          td({ class: "tribe-info-label" }, i18n.chatStatus),
-          td({ class: "tribe-info-value", colspan: "3" }, statusLabel)
+      div({ class: "shop-title-row" },
+        h2({ class: "tribe-card-title" },
+          a({ href: `/chats/${encodeURIComponent(chat.key)}` }, chat.title || i18n.chatUntitled)
         )
       ),
+      chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
+      chat.description ? p({ class: "tribe-card-description" }, chat.description) : null,
       div({ class: "tribe-card-members" },
         span({ class: "tribe-members-count" }, `${i18n.chatParticipants}: ${safeArr(chat.members).length}`)
       ),
+      (() => {
+        const btn = renderSpreadButton(chat.key, (params && params.spreadMap && params.spreadMap.get(chat.key)) || (params && params.spreads));
+        return btn ? div({ class: "card-spread-left" }, btn) : null;
+      })(),
       div({ class: "visit-btn-centered" },
         a({ href: `/chats/${encodeURIComponent(chat.key)}`, class: "filter-btn" }, i18n.chatVisitChat)
       )
@@ -218,13 +232,25 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
   const statusLabel = chat.status === "CLOSED" ? i18n.chatStatusClosed :
     chat.status === "INVITE-ONLY" ? i18n.chatStatusInviteOnly : i18n.chatStatusOpen
 
+  const detailChips = [
+    renderChatStatusChip(chat.status),
+    renderEncryptedChip(i18n),
+    renderLifespanChip(chat.lifetime, i18n)
+  ].filter(Boolean)
   const chatSide = div({ class: "tribe-side" },
-    h2("\uD83D\uDD12 " + (chat.title || i18n.chatUntitled)),
+    div({ class: "shop-title-row" },
+      h2({ class: "tribe-card-title" }, chat.title || i18n.chatUntitled)
+    ),
+    detailChips.length ? div({ class: "card-chips-row" }, ...detailChips) : null,
     renderMediaBlob(chat.image, "/assets/images/default-avatar.png", { class: "tribe-detail-image" }),
     div({ class: "shop-share" },
       span({ class: "tribe-info-label" }, `${i18n.chatShareUrl}: `),
       input({ type: "text", value: fullShareUrl, readonly: true, class: "shop-share-input" })
     ),
+    (() => {
+      const btn = renderSpreadButton(chat.key, params.spreads);
+      return btn ? div({ class: "card-spread-centered" }, btn) : null;
+    })(),
     div({ class: "tribe-card-members" },
       span({ class: "tribe-members-count" }, `${i18n.chatParticipants}: ${safeArr(chat.members).length}`)
     ),
@@ -238,10 +264,6 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
           userLink(chat.author)
         )
       ),
-      tr(
-        td({ class: "tribe-info-label" }, i18n.chatStatus),
-        td({ class: "tribe-info-value", colspan: "3" }, statusLabel)
-      ),
       !isRestrictedInviteOnly && chat.category ? tr(
         td({ class: "tribe-info-label" }, i18n.chatCategoryLabel),
         td({ class: "tribe-info-value", colspan: "3" }, catLabel(chat.category))

+ 0 - 0
src/views/clearnet_view.js


Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů