Browse Source

Oasis release 0.7.8

psy 1 day ago
parent
commit
0b9fb75750
100 changed files with 8615 additions and 1860 deletions
  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
      src/client/assets/larp/images/academia.jpg
  17. BIN
      src/client/assets/larp/images/arrakis.jpg
  18. BIN
      src/client/assets/larp/images/dogma.jpg
  19. BIN
      src/client/assets/larp/images/helix.jpg
  20. BIN
      src/client/assets/larp/images/hermandad.jpg
  21. BIN
      src/client/assets/larp/images/quark.jpg
  22. BIN
      src/client/assets/larp/images/solaris.jpg
  23. BIN
      src/client/assets/larp/images/terraverde.jpg
  24. BIN
      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
      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/
 src/AI/.cache/
 .update_required
 .update_required
 cache/
 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.	
  + Jobs: Module to discover and manage jobs.	
  + Legacy: Module to manage your secret (private key) quickly and securely.	
  + Legacy: Module to manage your secret (private key) quickly and securely.	
  + Latest: Module to receive the most recent posts and discussions.
  + 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.
  + Logs: Module to record (via AI assistant) your experiences.
  + Maps: Module to manage and share offline maps.
  + Maps: Module to manage and share offline maps.
  + Market: Module to exchange goods or services.
  + 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

File diff suppressed because it is too large
+ 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
 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.
 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
 cd src/server
 
 
 printf "==========================\n"
 printf "==========================\n"
-printf "|| OASIS Installer v0.4 ||\n"
+printf "|| OASIS Installer v0.5 ||\n"
 printf "==========================\n"
 printf "==========================\n"
 
 
 sudo apt-get install -y git curl tar
 sudo apt-get install -y git curl tar
 
 
 curl -sL http://deb.nodesource.com/setup_22.x | sudo bash -
 curl -sL http://deb.nodesource.com/setup_22.x | sudo bash -
 sudo apt-get install -y nodejs
 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"
 MODEL_DIR="../AI"
 LLM_FILE="oasis-42-1-chat.Q4_K_M.gguf"
 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.
   server          Launch only the Oasis backend in headless / pub mode.
   help, -h        Show this help message.
   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):
 GUI options (forwarded to the backend):
   --host=<ip>           Hostname / IP the web UI listens on (default: localhost).
   --host=<ip>           Hostname / IP the web UI listens on (default: localhost).
                         Use 0.0.0.0 to expose on a VPS.
                         Use 0.0.0.0 to expose on a VPS.
@@ -28,9 +37,10 @@ GUI options (forwarded to the backend):
 Examples:
 Examples:
   sh oasis.sh
   sh oasis.sh
   sh oasis.sh server
   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 --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
 EOF
 }
 }
 
 
@@ -49,9 +59,17 @@ case "$MODE" in
     exit 0
     exit 0
     ;;
     ;;
   server|pub)
   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
     cd "$CURRENT_DIR/src/server" || exit 1
     exec node SSB_server.js start
     exec node SSB_server.js start
     ;;
     ;;
+  whoami|invite|name|announce|follow|status|gossip)
+    exec node "$CURRENT_DIR/scripts/oasis-pub.js" "$@"
+    ;;
   gui)
   gui)
     shift
     shift
     cd "$CURRENT_DIR/src/backend" || exit 1
     cd "$CURRENT_DIR/src/backend" || exit 1

+ 15 - 4
scripts/build-deb.sh

@@ -2,7 +2,7 @@
 
 
 set -e
 set -e
 
 
-VERSION="0.7.4"
+VERSION="0.7.8"
 PKG_NAME="oasis"
 PKG_NAME="oasis"
 ARCH=$(dpkg --print-architecture)
 ARCH=$(dpkg --print-architecture)
 SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)"
 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/models"
 mkdir -p "${DEB_ROOT}${INSTALL_DIR}/src/client"
 mkdir -p "${DEB_ROOT}${INSTALL_DIR}/src/client"
 mkdir -p "${DEB_ROOT}${INSTALL_DIR}/src/configs"
 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}/scripts"
+mkdir -p "${DEB_ROOT}${INSTALL_DIR}/docs"
 mkdir -p "${DEB_ROOT}/usr/bin"
 mkdir -p "${DEB_ROOT}/usr/bin"
 mkdir -p "${DEB_ROOT}/usr/share/applications"
 mkdir -p "${DEB_ROOT}/usr/share/applications"
 mkdir -p "${DEB_ROOT}/usr/share/doc/${PKG_NAME}"
 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.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 -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
 if [ -d "${SRC_DIR}/src/server/packages" ]; then
     cp -r "${SRC_DIR}/src/server/packages" "${DEB_ROOT}${INSTALL_DIR}/src/server/"
     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
 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/"
     cp "${SRC_DIR}/src/configs/${f}" "${DEB_ROOT}${INSTALL_DIR}/src/configs/"
 done
 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}/"
 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}/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}/LICENSE" "${DEB_ROOT}${INSTALL_DIR}/"
+cp "${SRC_DIR}/README.md" "${DEB_ROOT}${INSTALL_DIR}/" 2>/dev/null || true
 
 
 cat > "${DEB_ROOT}/DEBIAN/control" << EOF
 cat > "${DEB_ROOT}/DEBIAN/control" << EOF
 Package: ${PKG_NAME}
 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 []
   return []
 }
 }
 
 
-async function publishExchange({ q, a, ctx = [], tokens = {} }) {
+async function publishExchange({ q, a, ctx = [], tokens = {}, lang = '', tags = [], rating = 0 }) {
   const s = await openSsb()
   const s = await openSsb()
   if (!s) return null
   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 = {
   const content = {
     type: 'aiExchange',
     type: 'aiExchange',
     question: clip(String(q || ''), 2000),
     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)),
     ctx: ctx.slice(0, 12).map(x => clip(String(x || ''), 800)),
     timestamp: Date.now()
     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) => {
   return new Promise((resolve, reject) => {
     s.publish(content, (err, res) => err ? reject(err) : resolve(res))
     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=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=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: '/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: '/chats',         mod: 'chatMod',    description: 'chats, messaging, encrypted rooms, group conversations' },
   { path: '/pads',          mod: 'padMod',     description: 'pads, collaborative editor, shared notes, encrypted documents' },
   { 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: '/calendars',     mod: 'calendarMod', description: 'calendar, events by date, schedule, reminders, recurring dates' },
   { path: '/maps',          mod: 'mapMod',     description: 'maps, locations, markers, geography, places' },
   { path: '/maps',          mod: 'mapMod',     description: 'maps, locations, markers, geography, places' },
   { path: '/events',        mod: 'eventMod',   description: 'events, agenda, meetups, gatherings, RSVP' },
   { 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: '/tasks',         mod: 'taskMod',    description: 'tasks, todo, assignments, work items, priorities' },
   { path: '/projects',      mod: 'projectMod', description: 'projects, milestones, backers, crowdfunding, bounties' },
   { path: '/projects',      mod: 'projectMod', description: 'projects, milestones, backers, crowdfunding, bounties' },
   { path: '/jobs',          mod: 'jobMod',     description: 'jobs, work, hiring, salaries, vacancies, applications' },
   { 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: '/courts',        mod: 'courtsMod',  description: 'courts, judges, accusations, mediators, justice, disputes' },
   { path: '/votations',     mod: 'votationsMod', description: 'votations, polls, surveys, multi-option votes' },
   { path: '/votations',     mod: 'votationsMod', description: 'votations, polls, surveys, multi-option votes' },
   { path: '/votes',         mod: 'votesMod',   description: 'votes, ballots, decisions, polling, voting' },
   { 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: '/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',        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: '/videos',        mod: 'videoMod',   description: 'videos, films, clips, recordings, watch' },
   { path: '/images',        mod: 'imageMod',   description: 'images, photos, pictures, gallery, memes' },
   { path: '/images',        mod: 'imageMod',   description: 'images, photos, pictures, gallery, memes' },
   { path: '/documents',     mod: 'documentMod', description: 'documents, PDFs, files, papers, references' },
   { 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: '/torrents',      mod: 'torrentMod', description: 'torrents, magnet links, file sharing, downloads' },
   { path: '/tags',          mod: 'tagsMod',    description: 'tags, hashtags, topics, categories, labels' },
   { path: '/tags',          mod: 'tagsMod',    description: 'tags, hashtags, topics, categories, labels' },
   { path: '/search',        mod: null,         description: 'search, find, query, lookup' },
   { 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: '/publish',       mod: null,         description: 'publish, write, create post, new entry, compose' },
   { path: '/games',         mod: 'gameMod',    description: 'games, play, mini-games, scoring, fun' },
   { path: '/games',         mod: 'gameMod',    description: 'games, play, mini-games, scoring, fun' },
   { path: '/pixelia',       mod: 'pixeliaMod', description: 'pixelia, pixel canvas, draw, collaborative pixel art' },
   { 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: '/stats',         mod: 'statsMod',   description: 'stats, statistics, KPIs, metrics, dashboard, carbon footprint' },
   { path: '/blockchain',    mod: 'blockchainMod', description: 'blockchain, blocks, explorer, ledger, chain' },
   { 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: '/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: '/graphos',       mod: 'graphosMod', description: 'graphos, network map, visualization, relationship graph' },
   { path: '/modules',       mod: null,         description: 'modules, features, enable disable plugins, settings' },
   { path: '/modules',       mod: null,         description: 'modules, features, enable disable plugins, settings' },
   { path: '/settings',      mod: null,         description: 'settings, preferences, language, theme, configuration' },
   { 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: '/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: '/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=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' },
   { path: '/stats?filter=MINE', mod: 'statsMod', description: 'my stats, my carbon footprint, my activity numbers, personal kpis' },

File diff suppressed because it is too large
+ 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
src/client/assets/larp/images/academia.jpg


BIN
src/client/assets/larp/images/arrakis.jpg


BIN
src/client/assets/larp/images/dogma.jpg


BIN
src/client/assets/larp/images/helix.jpg


BIN
src/client/assets/larp/images/hermandad.jpg


BIN
src/client/assets/larp/images/quark.jpg


BIN
src/client/assets/larp/images/solaris.jpg


BIN
src/client/assets/larp/images/terraverde.jpg


BIN
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;
   max-height: 0;
   overflow: hidden;
   overflow: hidden;
   transition: max-height 0.25s ease-out;
   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 {
 .oasis-nav-toggle:checked + .oasis-nav-header + .oasis-nav-list {
@@ -311,11 +312,12 @@ nav ul li a:hover {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   gap: 0.5rem;
   gap: 0.5rem;
-  padding: 0.35rem 1.25rem 0.35rem 1.5rem;
+  padding: 0.35rem 0.75rem;
   font-size: 0.85rem;
   font-size: 0.85rem;
   text-decoration: none;
   text-decoration: none;
   opacity: 0.85;
   opacity: 0.85;
   transition: opacity 0.15s ease;
   transition: opacity 0.15s ease;
+  box-sizing: border-box;
 }
 }
 
 
 .oasis-nav-list li a:hover {
 .oasis-nav-list li a:hover {
@@ -507,6 +509,36 @@ nav ul li a:hover {
   justify-content: center;
   justify-content: center;
   min-width: 0;
   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 {
 .top-bar-right {
   justify-content: flex-end;
   justify-content: flex-end;
@@ -1186,10 +1218,14 @@ button.create-button:hover {
   flex: 0 0 300px;
   flex: 0 0 300px;
 }
 }
 
 
-.inhabitant-left a {
+.inhabitant-left > a:first-of-type {
   display: block;
   display: block;
   width: 100%;
   width: 100%;
 }
 }
+.inhabitant-left .profile-sensors-box a {
+  display: inline;
+  width: auto;
+}
 
 
 .inhabitant-left h2 {
 .inhabitant-left h2 {
   margin: 4px 0 0;
   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);
 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;
 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 {
 .activity-image-preview {
   width: 400px;
   width: 400px;
@@ -2680,6 +2725,15 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
   font-weight: bold;
   font-weight: bold;
   text-decoration: underline dotted;
   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 {
 .mode-buttons-row {
   display: flex;
   display: flex;
   flex-direction: row;
   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-help{color:#888;font-size:13px;margin:0 0 12px 0}
 .melody-notes-grid{display:flex;flex-wrap:wrap;gap:6px}
 .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{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-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-note-type{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.5px}
 .melody-composition{margin:16px 0}
 .melody-composition{margin:16px 0}
 .melody-stats{margin:16px 0}
 .melody-stats{margin:16px 0}
 .melody-regen-form{margin-top:10px}
 .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}
 .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}
 .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;
   display: flex;
   gap: 8px;
   gap: 8px;
   flex-wrap: wrap;
   flex-wrap: wrap;
+  align-items: center;
   margin: 8px 0;
   margin: 8px 0;
 }
 }
 
 
 .tribe-side-actions form {
 .tribe-side-actions form {
   margin: 0;
   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 {
 .tribe-side-subtribes {
   margin: 8px 0;
   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 {
 .tribe-card-image-wrapper {
   position: relative;
   position: relative;
   overflow: hidden;
   overflow: hidden;
+  padding: 0 !important;
+  margin: 0 !important;
 }
 }
 
 
 .tribe-card-hero-image {
 .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-technical-block{margin-top:16px}
 .peers-conn-actions{margin-bottom:12px}
 .peers-conn-actions{margin-bottom:12px}
 .invites-pubs-actions{margin:12px 0}
 .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}
 .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{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}
 .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-side,
 .profile-layout .tribe-main{background:transparent}
 .profile-layout .tribe-main{background:transparent}
 .profile-layout-single{display:flex;justify-content:center;padding:24px 0;gap:24px}
 .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{align-items:center;text-align:center}
 .profile-side-header{display:flex;flex-direction:column;align-items:center;gap:6px;width:100%}
 .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 .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-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 a{color:#FFD700;text-decoration:none}
 .profile-side-mention strong{color:#FFD700;font-weight:700}
 .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 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-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{margin:8px 0 0 0}
 .profile-side-relationship .status{display:inline-block;margin:0 2px}
 .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 .inhabitant-last-activity,
 .profile-sensors-box .karma-line,
 .profile-sensors-box .karma-line,
 .profile-sensors-box .ubi-line,
 .profile-sensors-box .ubi-line,
+.profile-sensors-box .gpg-line,
 .inhabitant-left .inhabitant-last-activity,
 .inhabitant-left .inhabitant-last-activity,
 .inhabitant-left .karma-line,
 .inhabitant-left .karma-line,
-.inhabitant-left .ubi-line{
+.inhabitant-left .ubi-line,
+.inhabitant-left .gpg-line{
   display:flex;
   display:flex;
   gap:6px;
   gap:6px;
   align-items:center;
   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 .inhabitant-last-activity strong,
 .profile-sensors-box .karma-line strong,
 .profile-sensors-box .karma-line strong,
 .profile-sensors-box .ubi-line strong,
 .profile-sensors-box .ubi-line strong,
+.profile-sensors-box .gpg-line strong,
 .inhabitant-left .inhabitant-last-activity strong,
 .inhabitant-left .inhabitant-last-activity strong,
 .inhabitant-left .karma-line 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 .inhabitant-last-activity a,
 .profile-sensors-box .karma-line a,
 .profile-sensors-box .karma-line a,
 .profile-sensors-box .ubi-line a,
 .profile-sensors-box .ubi-line a,
+.profile-sensors-box .gpg-line a,
 .inhabitant-left .inhabitant-last-activity a,
 .inhabitant-left .inhabitant-last-activity a,
 .inhabitant-left .karma-line 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 .inhabitant-last-activity a:hover,
 .profile-sensors-box .karma-line a:hover,
 .profile-sensors-box .karma-line a:hover,
 .profile-sensors-box .ubi-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 .inhabitant-last-activity a:hover,
 .inhabitant-left .karma-line 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-main{gap:12px}
 .profile-module-section{display:flex;flex-direction:column;gap:12px;margin:0 0 12px 0}
 .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}
 .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}
 .search-submit-row button{padding:8px 18px}
 .pm-exposition{display:flex;border:0;}
 .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-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-icon{font-size:14px;line-height:1}
 .pm-exposition-text{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}
 .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{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 .shop-clearnet-url{margin:0;text-align:center}
 .profile-reach-toggle{margin:0}
 .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;
   grid-template-columns: 1fr;
   gap: 4px;
   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-subtribe-link:hover { background: #00FF00 !important; color: #000 !important; }
 .tribe-parent-image { border-color: #00FF00 !important; }
 .tribe-parent-image { border-color: #00FF00 !important; }
 .tribe-parent-box { background: #1A1A1A !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; }

File diff suppressed because it is too large
+ 335 - 24
src/client/assets/translations/oasis_ar.js


File diff suppressed because it is too large
+ 339 - 26
src/client/assets/translations/oasis_de.js


File diff suppressed because it is too large
+ 250 - 47
src/client/assets/translations/oasis_en.js


File diff suppressed because it is too large
+ 338 - 27
src/client/assets/translations/oasis_es.js


File diff suppressed because it is too large
+ 343 - 28
src/client/assets/translations/oasis_eu.js


File diff suppressed because it is too large
+ 347 - 26
src/client/assets/translations/oasis_fr.js


File diff suppressed because it is too large
+ 332 - 21
src/client/assets/translations/oasis_hi.js


File diff suppressed because it is too large
+ 340 - 27
src/client/assets/translations/oasis_it.js


File diff suppressed because it is too large
+ 341 - 28
src/client/assets/translations/oasis_pt.js


File diff suppressed because it is too large
+ 335 - 24
src/client/assets/translations/oasis_ru.js


File diff suppressed because it is too large
+ 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'));
 const moduleAlias = require(path.join(__dirname, '../server/node_modules/module-alias'));
 moduleAlias.addAlias('punycode', 'punycode/');
 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")
     .scriptName("oasis")
     .env("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 };
 module.exports = { cli };
 
 

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

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

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

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

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

@@ -2,6 +2,9 @@
   "themes": {
   "themes": {
     "current": "Dark-SNH"
     "current": "Dark-SNH"
   },
   },
+  "ux": {
+    "current": "blocks"
+  },
   "modules": {
   "modules": {
     "popularMod": "on",
     "popularMod": "on",
     "topicsMod": "on",
     "topicsMod": "on",
@@ -49,7 +52,8 @@
     "mapsMod": "on",
     "mapsMod": "on",
     "chatsMod": "on",
     "chatsMod": "on",
     "torrentsMod": "on",
     "torrentsMod": "on",
-    "graphosMod": "on"
+    "graphosMod": "on",
+    "larpMod": "on"
   },
   },
   "wallet": {
   "wallet": {
     "url": "http://localhost:7474",
     "url": "http://localhost:7474",
@@ -68,6 +72,7 @@
   },
   },
   "homePage": "activity",
   "homePage": "activity",
   "language": "en",
   "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"
     "level": "notice"
   },
   },
   "caps": {
   "caps": {
-    "shs": "zTmidAb7t+tKi7W93FIHbOvlbd936x6G/vm8e8Td//A="
+    "shs": "H5EC+V5BU9s0lWxCkt4z8a095Sj8a6TgiLKPYi1JD7s="
   },
   },
   "pub": false,
   "pub": false,
   "local": true,
   "local": true,
@@ -27,9 +27,7 @@
     "feeds": []
     "feeds": []
   },
   },
   "connections": {
   "connections": {
-    "seeds": [
-      "net:solarnethub.com:8008~shs:mGrevRCSX4E5dLgmflWBc50Qkn/1RXUAtDaGHOJ8xB4=.ed25519"
-    ],
+    "seeds": [],
     "incoming": {
     "incoming": {
       "net": [
       "net": [
         {
         {

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

@@ -7,6 +7,8 @@ let _inboxUnread = null;
 let _lastSyncTs = null;
 let _lastSyncTs = null;
 let _ecoValue = null;
 let _ecoValue = null;
 let _lastActivity = null;
 let _lastActivity = null;
+let _maxBlockBytes = 0;
+let _inhabitantCount = 0;
 module.exports = {
 module.exports = {
   getInboxCount: () => _inboxCount,
   getInboxCount: () => _inboxCount,
   setInboxCount: (n) => { _inboxCount = n; },
   setInboxCount: (n) => { _inboxCount = n; },
@@ -25,5 +27,9 @@ module.exports = {
   getEcoValue: () => _ecoValue,
   getEcoValue: () => _ecoValue,
   setEcoValue: (v) => { _ecoValue = v; },
   setEcoValue: (v) => { _ecoValue = v; },
   getLastActivity: () => _lastActivity,
   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\"",
   "name": "SNH \"La Plaza\"",
   "description": "A shared place to begin a utopia ...",
   "description": "A shared place to begin a utopia ...",
   "url": "https://pub.solarnethub.com",
   "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
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 agendaConfigPath = path.join(__dirname, '../configs/agenda-config.json');
 const { getConfig } = require('../configs/config-manager.js');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 function readAgendaConfig() {
 function readAgendaConfig() {
@@ -18,7 +19,7 @@ function writeAgendaConfig(cfg) {
   fs.writeFileSync(agendaConfigPath, JSON.stringify(cfg, null, 2));
   fs.writeFileSync(agendaConfigPath, JSON.stringify(cfg, null, 2));
 }
 }
 
 
-module.exports = ({ cooler, calendarsModel }) => {
+module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel }) => {
   let ssb;
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
 
 
@@ -33,7 +34,7 @@ module.exports = ({ cooler, calendarsModel }) => {
           pull.collect((err, msgs) => {
           pull.collect((err, msgs) => {
             if (err) return reject(err);
             if (err) return reject(err);
 
 
-            const tomb = new Set();
+            const tomb = buildValidatedTombstoneSet(msgs);
             const nodes = new Map();
             const nodes = new Map();
             const parent = new Map();
             const parent = new Map();
             const child = new Map();
             const child = new Map();
@@ -43,8 +44,9 @@ module.exports = ({ cooler, calendarsModel }) => {
               const v = m.value;
               const v = m.value;
               const c = v?.content;
               const c = v?.content;
               if (!c) continue;
               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.type !== targetType) continue;
+              if (c.encryptedPayload) continue;
               nodes.set(k, { key: k, ts: v.timestamp || 0, content: c });
               nodes.set(k, { key: k, ts: v.timestamp || 0, content: c });
               if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k); }
               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 ssbClient = await openSsb();
       const userId = ssbClient.id;
       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([
       const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll] = await Promise.all([
-        fetchItems('task'),
-        fetchItems('event'),
+        tasksViaModel,
+        eventsViaModel,
         fetchItems('transfer'),
         fetchItems('transfer'),
         fetchItems('tribe'),
         fetchItems('tribe'),
-        fetchItems('market'),
+        marketViaModel,
         fetchItems('report'),
         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' }));
       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 pull = require("../server/node_modules/pull-stream");
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const { getConfig } = require("../configs/config-manager.js");
 const { getConfig } = require("../configs/config-manager.js");
 const categories = require("../backend/opinion_categories");
 const categories = require("../backend/opinion_categories");
 
 
@@ -40,7 +41,7 @@ module.exports = ({ cooler }) => {
     });
     });
 
 
   const buildIndex = (messages) => {
   const buildIndex = (messages) => {
-    const tomb = new Set();
+    const tomb = buildValidatedTombstoneSet(messages);
     const nodes = new Map();
     const nodes = new Map();
     const parent = new Map();
     const parent = new Map();
     const child = new Map();
     const child = new Map();
@@ -51,15 +52,14 @@ module.exports = ({ cooler }) => {
       const c = v.content;
       const c = v.content;
       if (!c) continue;
       if (!c) continue;
 
 
-      if (c.type === "tombstone" && c.target) {
-        tomb.add(c.target);
-        continue;
-      }
+      if (c.type === "tombstone") continue;
 
 
       if (c.type !== "audio") continue;
       if (c.type !== "audio") continue;
 
 
       const ts = v.timestamp || m.timestamp || 0;
       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) {
       if (c.replaces) {
         parent.set(k, c.replaces);
         parent.set(k, c.replaces);
@@ -94,20 +94,26 @@ module.exports = ({ cooler }) => {
   const buildAudio = (node, rootId, viewerId) => {
   const buildAudio = (node, rootId, viewerId) => {
     const c = node.c || {};
     const c = node.c || {};
     const voters = safeArr(c.opinions_inhabitants);
     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 {
     return {
       key: node.key,
       key: node.key,
       rootId,
       rootId,
       url: c.url,
       url: c.url,
       createdAt: c.createdAt || new Date(node.ts).toISOString(),
       createdAt: c.createdAt || new Date(node.ts).toISOString(),
       updatedAt: c.updatedAt || null,
       updatedAt: c.updatedAt || null,
-      tags: safeArr(c.tags),
+      tags: tagsArr,
       author: c.author,
       author: c.author,
       title: c.title || "",
       title: c.title || "",
       description: c.description || "",
       description: c.description || "",
       mapUrl: c.mapUrl || "",
       mapUrl: c.mapUrl || "",
       opinions: c.opinions || {},
       opinions: c.opinions || {},
       opinions_inhabitants: voters,
       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) {
     async updateAudioById(id, blobMarkdown, tagsRaw, title, description, mapUrl) {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
       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 === "recent") list = list.filter((a) => new Date(a.createdAt).getTime() >= now - 86400000);
       else if (filter === "top") {
       else if (filter === "top") {
         list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
         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) {
       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 pull = require("../server/node_modules/pull-stream");
 const { getConfig } = require("../configs/config-manager.js");
 const { getConfig } = require("../configs/config-manager.js");
 const { config } = require("../server/SSB_server.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));
 const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
 
 
@@ -14,8 +15,7 @@ const DEFAULT_RULES = {
   alpha: 0.2,
   alpha: 0.2,
   reserveMin: 500,
   reserveMin: 500,
   capPerEpoch: 2000,
   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
   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_MAX = 500;
 const ECO_HISTORY_MIN_GAP_MS = 5 * 60 * 1000;
 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() {
 function ensureStoreFiles() {
   if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true });
   if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true });
   if (!fs.existsSync(EPOCHS_PATH)) fs.writeFileSync(EPOCHS_PATH, "[]");
   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) {
 async function getUserEngagementScore(userId) {
   const ssb = await openSsb();
   const ssb = await openSsb();
   const uid = resolveUserId(userId);
   const uid = resolveUserId(userId);
@@ -663,9 +818,9 @@ async function getLastPublishedTimestamp(userId) {
 }
 }
 
 
   function computePoolVars(pubBal, rules) {
   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);
     const pool = clamp(rawMin, 0, Number.MAX_SAFE_INTEGER);
     return { pubBal, alphaCap, available, rawMin, pool };
     return { pubBal, alphaCap, available, rawMin, pool };
   }
   }
@@ -675,9 +830,10 @@ async function getLastPublishedTimestamp(userId) {
     const pv = computePoolVars(pubBal, rules);
     const pv = computePoolVars(pubBal, rules);
     const addresses = await listAddressesMerged();
     const addresses = await listAddressesMerged();
     const eligible = addresses.filter(a => a.address && isValidEcoinAddress(a.address));
     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 = [];
     const weights = [];
     for (const entry of eligible) {
     for (const entry of eligible) {
       const score = await getUserEngagementScore(entry.id);
       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) });
       weights.push({ user: userId, w: clamp(1 + score / 100, wMin, wMax) });
     }
     }
     const W = weights.reduce((acc, x) => acc + x.w, 0) || 1;
     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}`,
         id: `alloc:${epochId}:${user}`,
         epoch: epochId,
         epoch: epochId,
         user,
         user,
         weight: Number(w.toFixed(6)),
         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))
         amount: Number(amount.toFixed(6))
-      };
-    });
+      });
+    }
     const snapshot = JSON.stringify({ epochId, pool: pv.pool, weights, allocations, rules }, null, 2);
     const snapshot = JSON.stringify({ epochId, pool: pv.pool, weights, allocations, rules }, null, 2);
     const hash = crypto.createHash("sha256").update(snapshot).digest("hex");
     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 };
     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}`,
         concept: `UBI ${eid}`,
         status: "UNCLAIMED",
         status: "UNCLAIMED",
         createdAt: new Date().toISOString(),
         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}`],
         tags: ["UBI", `epoch:${eid}`],
         opinions: {}
         opinions: {}
       };
       };
@@ -836,7 +1002,7 @@ async function getLastPublishedTimestamp(userId) {
   async function publishPubAvailability() {
   async function publishPubAvailability() {
     if (!isPubNode()) return;
     if (!isPubNode()) return;
     const balance = await safeGetBalance("pub");
     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 available = Number(balance) >= floor;
     const ssb = await openSsb();
     const ssb = await openSsb();
     if (!ssb || !ssb.publish) return;
     if (!ssb || !ssb.publish) return;
@@ -868,7 +1034,7 @@ async function getLastPublishedTimestamp(userId) {
     let allocations;
     let allocations;
     if (isPubNode()) {
     if (isPubNode()) {
       pubBalance = await safeGetBalance("pub");
       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;
       ubiAvailable = Number(pubBalance) >= floor;
       try { await publishPubAvailability(); } catch (_) {}
       try { await publishPubAvailability(); } catch (_) {}
       const all = await transfersRepo.listByTag("UBI");
       const all = await transfersRepo.listByTag("UBI");
@@ -911,7 +1077,24 @@ async function getLastPublishedTimestamp(userId) {
     };
     };
     const exchange = await calculateEcoinValue();
     const exchange = await calculateEcoinValue();
     const exchangeHistory = readEcoHistory();
     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) {
   async function getAllocationById(id) {
@@ -1012,16 +1195,34 @@ async function getLastPublishedTimestamp(userId) {
       const pool = pv.pool || 0;
       const pool = pv.pool || 0;
       const addresses = await listAddressesMerged();
       const addresses = await listAddressesMerged();
       const eligible = addresses.filter(a => a.address && isValidEcoinAddress(a.address));
       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;
       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 (_) {}
     } 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 }));
     const claimHistory = await getUbiClaimHistory(userId).catch(() => ({ lastClaimedDate: null, totalClaimed: 0 }));
     return {
     return {
       ecoValue,
       ecoValue,
       karmaScore,
       karmaScore,
       estimatedUBI,
       estimatedUBI,
+      estimatedUBIBeforeTax: Number(estimatedUBIBeforeTax.toFixed(6)),
+      userEcoinTax: Number(userEcoinTax.toFixed(6)),
+      userArchTax: Number(userArchTax.toFixed(6)),
+      userTotalTax: Number(userTotalTax.toFixed(6)),
       lastClaimedDate: claimHistory.lastClaimedDate,
       lastClaimedDate: claimHistory.lastClaimedDate,
       totalClaimed: claimHistory.totalClaimed
       totalClaimed: claimHistory.totalClaimed
     };
     };
@@ -1167,6 +1368,12 @@ async function getLastPublishedTimestamp(userId) {
     setUserAddress,
     setUserAddress,
     listAddressesMerged,
     listAddressesMerged,
     calculateEcoinValue,
     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 pull = require('../server/node_modules/pull-stream');
 const config = require('../server/ssb_config');
 const config = require('../server/ssb_config');
 const { getConfig } = require('../configs/config-manager.js');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
 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 idToBlock = new Map();
       const referencedAsReplaces = new Set();
       const referencedAsReplaces = new Set();
 
 
@@ -177,7 +178,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
         }
         }
 
 
         if (c.type === 'tombstone' && c.target) {
         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') });
           idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c, size: Buffer.byteLength(JSON.stringify(msg.value), 'utf8') });
           continue;
           continue;
         }
         }
@@ -301,7 +301,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       );
       );
 
 
       const me = userId || config.keys.id;
       const me = userId || config.keys.id;
-      const tombstoned = new Set();
+      const tombstoned = buildValidatedTombstoneSet(results);
       const idToBlock = new Map();
       const idToBlock = new Map();
       const referencedAsReplaces = new Set();
       const referencedAsReplaces = new Set();
       const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
       const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
@@ -330,7 +330,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
           continue;
           continue;
         }
         }
         if (c.type === 'tombstone' && c.target) {
         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') });
           idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c, size: Buffer.byteLength(JSON.stringify(msg.value), 'utf8') });
           continue;
           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 moment = require("../server/node_modules/moment");
 const { getConfig } = require("../configs/config-manager.js");
 const { getConfig } = require("../configs/config-manager.js");
 const categories = require("../backend/opinion_categories");
 const categories = require("../backend/opinion_categories");
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 
 
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
@@ -48,7 +49,7 @@ module.exports = ({ cooler }) => {
     });
     });
 
 
   const buildIndex = (messages) => {
   const buildIndex = (messages) => {
-    const tomb = new Set();
+    const tomb = buildValidatedTombstoneSet(messages);
     const nodes = new Map();
     const nodes = new Map();
     const parent = new Map();
     const parent = new Map();
     const child = new Map();
     const child = new Map();
@@ -59,15 +60,14 @@ module.exports = ({ cooler }) => {
       const c = v.content;
       const c = v.content;
       if (!c) continue;
       if (!c) continue;
 
 
-      if (c.type === "tombstone" && c.target) {
-        tomb.add(c.target);
-        continue;
-      }
+      if (c.type === 'tombstone') continue;
 
 
       if (c.type !== "bookmark") continue;
       if (c.type !== "bookmark") continue;
 
 
       const ts = v.timestamp || m.timestamp || 0;
       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) {
       if (c.replaces) {
         parent.set(k, c.replaces);
         parent.set(k, c.replaces);
@@ -115,7 +115,8 @@ module.exports = ({ cooler }) => {
       opinions: c.opinions || {},
       opinions: c.opinions || {},
       opinions_inhabitants: voters,
       opinions_inhabitants: voters,
       author: c.author,
       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 pull = require("../server/node_modules/pull-stream")
 const crypto = require("crypto")
 const crypto = require("crypto")
 const { getConfig } = require("../configs/config-manager.js")
 const { getConfig } = require("../configs/config-manager.js")
+const { buildValidatedTombstoneSet } = require('./tombstone_validator')
 const logLimit = getConfig().ssbLogStream?.limit || 1000
 const logLimit = getConfig().ssbLogStream?.limit || 1000
 const INVITE_CODE_BYTES = 16
 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 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) =>
   const readAll = async (ssbClient) =>
     new Promise((resolve, reject) =>
     new Promise((resolve, reject) =>
       pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
       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 {
   return {
     type: "calendar",
     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) {
     async resolveRootId(id) {
       const ssbClient = await openSsb()
       const ssbClient = await openSsb()
       const messages = await readAll(ssbClient)
       const messages = await readAll(ssbClient)
@@ -235,7 +304,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
         if (validStatus === "OPEN") {
         if (validStatus === "OPEN") {
           try {
           try {
             const pubCode = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
             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 tipId = await this.resolveCurrentId(calendarId)
             const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
             const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
             const dec = decryptCalendarRoot(item.content, calendarId)
             const dec = decryptCalendarRoot(item.content, calendarId)
@@ -247,7 +317,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
               tags: Array.isArray(dec.tags) ? dec.tags : [],
               tags: Array.isArray(dec.tags) ? dec.tags : [],
               author: userId,
               author: userId,
               participants: [userId],
               participants: [userId],
-              invites: [{ code: pubCode, ek, gen: 1, public: true }],
+              invites: [{ code: pubCode, ek, salt: inviteSalt, gen: 1, public: true }],
               createdAt: dec.createdAt,
               createdAt: dec.createdAt,
               updatedAt: new Date().toISOString(),
               updatedAt: new Date().toISOString(),
               replaces: tipId
               replaces: tipId
@@ -418,11 +488,28 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
       if (item.content.tribeId) updated = await encryptIfTribe(updated)
       if (item.content.tribeId) updated = await encryptIfTribe(updated)
       else updated = encryptStandalone(updated, rootId)
       else updated = encryptStandalone(updated, rootId)
       const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
       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 }
       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()))
       await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
       return result
       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) {
     async getCalendarById(id) {
       const ssbClient = await openSsb()
       const ssbClient = await openSsb()
       const messages = await readAll(ssbClient)
       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")
       const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
       let invite = code
       let invite = code
       if (tribeCrypto && !cal.tribeId) {
       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 tipId = await this.resolveCurrentId(calendarId)
       const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
       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
       let calKey = null
       if (tribeCrypto && typeof matchedInvite === "object") {
       if (tribeCrypto && typeof matchedInvite === "object") {
         if (matchedInvite.ekChain) {
         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) {
           if (Array.isArray(chain) && chain.length) {
             for (const entry of chain) {
             for (const entry of chain) {
               if (Array.isArray(entry.keys) && entry.keys.length) {
               if (Array.isArray(entry.keys) && entry.keys.length) {
@@ -860,7 +948,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
             calKey = chain[0].key
             calKey = chain[0].key
           }
           }
         } else if (matchedInvite.ek) {
         } 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)
           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 pull = require("../server/node_modules/pull-stream")
 const crypto = require("crypto")
 const crypto = require("crypto")
 const { getConfig } = require("../configs/config-manager.js")
 const { getConfig } = require("../configs/config-manager.js")
+const { buildValidatedTombstoneSet } = require('./tombstone_validator')
 const logLimit = getConfig().ssbLogStream?.limit || 1000
 const logLimit = getConfig().ssbLogStream?.limit || 1000
 
 
 const safeArr = (v) => (Array.isArray(v) ? v : [])
 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 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) => {
   const getTribeKeysFor = async (tribeId) => {
     if (!tribeCrypto || !tribesModel || !tribeId) return []
     if (!tribeCrypto || !tribesModel || !tribeId) return []
     try {
     try {
@@ -97,11 +141,17 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
     if (rawC.type !== "chat") return null
     if (rawC.type !== "chat") return null
 
 
     let c = rawC
     let c = rawC
+    let undecryptable = false
     if (tribeCrypto && c.encryptedPayload) {
     if (tribeCrypto && c.encryptedPayload) {
       const keyChainSets = resolveKeyChainSets(rootId)
       const keyChainSets = resolveKeyChainSets(rootId)
       c = tribeCrypto.decryptContent(c, keyChainSets)
       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 {
     return {
       key: node.key,
       key: node.key,
       rootId,
       rootId,
@@ -109,15 +159,16 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
       description: c.description || "",
       description: c.description || "",
       image: c.image || null,
       image: c.image || null,
       category: c.category || "",
       category: c.category || "",
-      status: c.status || "OPEN",
+      status: inferredStatus,
       tags: safeArr(c.tags),
       tags: safeArr(c.tags),
       members: safeArr(c.members),
       members: safeArr(c.members),
-      invites: safeArr(c.invites),
+      invites,
       author: c.author || node.author,
       author: c.author || node.author,
       createdAt: c.createdAt || new Date(node.ts).toISOString(),
       createdAt: c.createdAt || new Date(node.ts).toISOString(),
       updatedAt: c.updatedAt || null,
       updatedAt: c.updatedAt || null,
       encrypted: !!c.encrypted,
       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
         if (!node || node.c.type !== "chat") continue
         const chat = buildChat(node, rootId)
         const chat = buildChat(node, rootId)
         if (!chat) continue
         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)
         items.push(chat)
       }
       }
 
 
@@ -430,14 +483,15 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
       let invite = code
       let invite = code
 
 
       if (tribeCrypto) {
       if (tribeCrypto) {
-        const ekChain = tribeCrypto.encryptChainForInvite([chat.rootId], code)
+        const inviteSalt = tribeCrypto.generateInviteSalt()
+        const ekChain = tribeCrypto.encryptChainForInvite([chat.rootId], code, inviteSalt)
         if (ekChain) {
         if (ekChain) {
-          invite = { code, ekChain, gen: lookupGen(chat.rootId) }
+          invite = { code, ekChain, salt: inviteSalt, gen: lookupGen(chat.rootId) }
         } else {
         } else {
           const chatKey = lookupKey(chat.rootId)
           const chatKey = lookupKey(chat.rootId)
           if (chatKey) {
           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
       let chatKey = null
       if (tribeCrypto && typeof matchedInvite === "object") {
       if (tribeCrypto && typeof matchedInvite === "object") {
         if (matchedInvite.ekChain) {
         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) {
           if (Array.isArray(chain) && chain.length) {
             for (const entry of chain) {
             for (const entry of chain) {
               if (Array.isArray(entry.keys) && entry.keys.length) {
               if (Array.isArray(entry.keys) && entry.keys.length) {
@@ -492,7 +546,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
             chatKey = chain[0].key
             chatKey = chain[0].key
           }
           }
         } else if (matchedInvite.ek) {
         } 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)
           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")
       if (chat.author === userId) throw new Error("Author cannot leave their own chat")
       const members = chat.members.filter(m => m !== userId)
       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 })
       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) {
     async sendMessage(chatId, text, image = null) {
@@ -585,14 +665,11 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
         let encKey = null
         let encKey = null
         if (chat.tribeId) encKey = await getTribeFirstKeyFor(chat.tribeId)
         if (chat.tribeId) encKey = await getTribeFirstKeyFor(chat.tribeId)
         if (!encKey) encKey = lookupKey(chat.rootId)
         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 {
       } else {
-        content.text = safeText(text)
+        throw new Error('Chat crypto unavailable — cannot send message')
       }
       }
 
 
       return new Promise((resolve, reject) => {
       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 pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const moment = require('../server/node_modules/moment');
 const { getConfig } = require('../configs/config-manager.js');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 
 
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const CASE_ANSWER_DAYS = 7;
 const CASE_ANSWER_DAYS = 7;
@@ -81,15 +82,14 @@ module.exports = ({ cooler, services = {}, tribeCrypto }) => {
 
 
   async function listByType(type) {
   async function listByType(type) {
     const msgs = await readLog();
     const msgs = await readLog();
-    const tomb = new Set();
+    const tomb = buildValidatedTombstoneSet(msgs);
     const rep = new Map();
     const rep = new Map();
     const map = new Map();
     const map = new Map();
     for (const m of msgs) {
     for (const m of msgs) {
       const k = m.key || m.id;
       const k = m.key || m.id;
       const c = m.value?.content || m.content;
       const c = m.value?.content || m.content;
       if (!c) continue;
       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);
         if (c.replaces) rep.set(c.replaces, k);
         map.set(k, { id: k, ...c });
         map.set(k, { id: k, ...c });
       }
       }

+ 12 - 20
src/models/crypto.js

@@ -435,15 +435,11 @@ module.exports = (configPath, namespace = 'tribes') => {
   const createHelpers = (tribesModel) => ({
   const createHelpers = (tribesModel) => ({
     async encryptIfTribe(content) {
     async encryptIfTribe(content) {
       if (!content || !content.tribeId || !tribesModel) return 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) {
     async decryptIfTribe(content) {
       if (!content || !tribesModel) return content;
       if (!content || !tribesModel) return content;
@@ -489,7 +485,7 @@ module.exports = (configPath, namespace = 'tribes') => {
         if (!r || !r.body) continue;
         if (!r || !r.body) continue;
         const inner = r.body;
         const inner = r.body;
         if (inner.k === 'tombstone' && inner.target) {
         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 } });
           out.push({ ...m, value: { ...m.value, content: flat } });
         } else if (kSet.has(inner.k)) {
         } else if (kSet.has(inner.k)) {
           const flat = { ...inner, type: inner.k, _decrypted: true, _rootId: r.rootId };
           const flat = { ...inner, type: inner.k, _decrypted: true, _rootId: r.rootId };
@@ -500,16 +496,12 @@ module.exports = (configPath, namespace = 'tribes') => {
       return out;
       return out;
     },
     },
     async encryptTombstone(target, tribeId, author) {
     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 pull = require('../server/node_modules/pull-stream');
 const { getConfig } = require('../configs/config-manager.js');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 const extractBlobId = str => {
 const extractBlobId = str => {
@@ -148,11 +149,7 @@ module.exports = ({ cooler }) => {
           pull.collect((err, msgs) => {
           pull.collect((err, msgs) => {
             if (err) return reject(err);
             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
             const cvMsgs = msgs
               .filter(m =>
               .filter(m =>

+ 8 - 7
src/models/documents_model.js

@@ -1,6 +1,7 @@
 const pull = require("../server/node_modules/pull-stream");
 const pull = require("../server/node_modules/pull-stream");
 const { getConfig } = require("../configs/config-manager.js");
 const { getConfig } = require("../configs/config-manager.js");
 const categories = require("../backend/opinion_categories");
 const categories = require("../backend/opinion_categories");
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const mediaFavorites = require("../backend/media-favorites");
 const mediaFavorites = require("../backend/media-favorites");
 
 
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
@@ -41,7 +42,7 @@ module.exports = ({ cooler }) => {
     });
     });
 
 
   const buildIndex = (messages) => {
   const buildIndex = (messages) => {
-    const tomb = new Set();
+    const tomb = buildValidatedTombstoneSet(messages);
     const nodes = new Map();
     const nodes = new Map();
     const parent = new Map();
     const parent = new Map();
     const child = new Map();
     const child = new Map();
@@ -52,15 +53,14 @@ module.exports = ({ cooler }) => {
       const c = v.content;
       const c = v.content;
       if (!c) continue;
       if (!c) continue;
 
 
-      if (c.type === "tombstone" && c.target) {
-        tomb.add(c.target);
-        continue;
-      }
+      if (c.type === "tombstone") continue;
 
 
       if (c.type !== "document") continue;
       if (c.type !== "document") continue;
 
 
       const ts = v.timestamp || m.timestamp || 0;
       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) {
       if (c.replaces) {
         parent.set(k, c.replaces);
         parent.set(k, c.replaces);
@@ -105,7 +105,8 @@ module.exports = ({ cooler }) => {
       title: c.title || "",
       title: c.title || "",
       description: c.description || "",
       description: c.description || "",
       opinions: c.opinions || {},
       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 pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const moment = require('../server/node_modules/moment');
+const crypto = require('crypto');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const { getConfig } = require('../configs/config-manager.js');
 const { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
-module.exports = ({ cooler }) => {
+module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
   let ssb;
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
   const me = async () => (await openSsb()).id;
   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 uniq = (arr) => Array.from(new Set((Array.isArray(arr) ? arr : []).filter(x => typeof x === 'string' && x.trim().length)));
 
 
   const normalizePrivacy = (v) => {
   const normalizePrivacy = (v) => {
@@ -38,6 +82,27 @@ module.exports = ({ cooler }) => {
   return {
   return {
     type: 'event',
     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) {
     async createEvent(title, description, date, location, price = 0, url = "", attendees = [], tagsRaw = [], isPublic, mapUrl = "", clearnetPublic = false) {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const userId = await me();
       const userId = await me();
@@ -53,7 +118,8 @@ module.exports = ({ cooler }) => {
         ? tagsRaw.filter(Boolean)
         ? tagsRaw.filter(Boolean)
         : String(tagsRaw || '').split(',').map(s => s.trim()).filter(Boolean);
         : String(tagsRaw || '').split(',').map(s => s.trim()).filter(Boolean);
 
 
-      const content = {
+      const visibility = normalizePrivacy(isPublic);
+      const plainContent = {
         type: 'event',
         type: 'event',
         title,
         title,
         description,
         description,
@@ -66,47 +132,166 @@ module.exports = ({ cooler }) => {
         createdAt: new Date().toISOString(),
         createdAt: new Date().toISOString(),
         organizer: userId,
         organizer: userId,
         status: 'OPEN',
         status: 'OPEN',
-        isPublic: normalizePrivacy(isPublic),
+        isPublic: visibility,
         mapUrl: String(mapUrl || "").trim(),
         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));
         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) {
     async toggleAttendee(eventId) {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const userId = await me();
       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 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);
       const status = deriveStatus(c);
       if (status === 'CLOSED') throw new Error("Cannot attend a closed event");
       if (status === 'CLOSED') throw new Error("Cannot attend a closed event");
 
 
       let attendees = uniq(c.attendees || []);
       let attendees = uniq(c.attendees || []);
       const idx = attendees.indexOf(userId);
       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);
       attendees = uniq(attendees);
 
 
-      const updated = {
+      const isPrivate = normalizePrivacy(c.isPublic) === 'private';
+      const isOrganizer = c.organizer === userId;
+      let updated = {
         ...c,
         ...c,
         attendees,
         attendees,
         updatedAt: new Date().toISOString(),
         updatedAt: new Date().toISOString(),
         replaces: eventId
         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));
         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) {
     async deleteEventById(eventId) {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const userId = await me();
       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 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 };
       const tombstone = { type: 'tombstone', target: eventId, deletedAt: new Date().toISOString(), author: userId };
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
         ssbClient.publish(tombstone, (err, res) => err ? reject(err) : resolve(res));
         ssbClient.publish(tombstone, (err, res) => err ? reject(err) : resolve(res));
@@ -116,7 +301,9 @@ module.exports = ({ cooler }) => {
     async getEventById(eventId) {
     async getEventById(eventId) {
       const ssbClient = await openSsb();
       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 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);
       const status = deriveStatus(c);
 
 
@@ -136,7 +323,10 @@ module.exports = ({ cooler }) => {
         status,
         status,
         isPublic: normalizePrivacy(c.isPublic),
         isPublic: normalizePrivacy(c.isPublic),
         mapUrl: c.mapUrl || "",
         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 ssbClient = await openSsb();
       const userId = await me();
       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 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);
       const status = deriveStatus(c);
       if (status === 'CLOSED') throw new Error("Cannot edit a closed event");
       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");
       if (moment(date).isBefore(moment().startOf('minute'))) throw new Error("Cannot set an event in the past");
 
 
-      const updated = {
+      let updated = {
         ...c,
         ...c,
         title: updatedData.title ?? c.title,
         title: updatedData.title ?? c.title,
         description: updatedData.description ?? c.description,
         description: updatedData.description ?? c.description,
@@ -178,9 +369,63 @@ module.exports = ({ cooler }) => {
         replaces: eventId
         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));
         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') {
     async listAll(author = null, filter = 'all') {
@@ -191,21 +436,42 @@ module.exports = ({ cooler }) => {
           ssbClient.createLogStream({ limit: logLimit }),
           ssbClient.createLogStream({ limit: logLimit }),
           pull.collect((err, results) => {
           pull.collect((err, results) => {
             if (err) return reject(new Error("Error listing events: " + err.message));
             if (err) return reject(new Error("Error listing events: " + err.message));
-            const tombstoned = new Set();
+            const tombstoned = buildValidatedTombstoneSet(results);
             const replaces = new Map();
             const replaces = new Map();
             const byId = 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) {
             for (const r of results) {
               const k = r.key;
               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 (c.replaces) replaces.set(c.replaces, k);
                 if (author && c.organizer !== author) continue;
                 if (author && c.organizer !== author) continue;
 
 
@@ -225,7 +491,10 @@ module.exports = ({ cooler }) => {
                   organizer: c.organizer || '',
                   organizer: c.organizer || '',
                   status,
                   status,
                   isPublic: normalizePrivacy(c.isPublic),
                   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 pull = require("../server/node_modules/pull-stream");
 const { getConfig } = require("../configs/config-manager.js");
 const { getConfig } = require("../configs/config-manager.js");
 const categories = require("../backend/opinion_categories");
 const categories = require("../backend/opinion_categories");
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 const FEED_TEXT_MIN = Number(getConfig().feed?.minLength ?? 1);
 const FEED_TEXT_MIN = Number(getConfig().feed?.minLength ?? 1);
@@ -40,7 +41,7 @@ module.exports = ({ cooler }) => {
 
 
     const forward = new Map();
     const forward = new Map();
     const replacedIds = new Set();
     const replacedIds = new Set();
-    const tombstoned = new Set();
+    const tombstoned = buildValidatedTombstoneSet(messages);
     const feedsById = new Map();
     const feedsById = new Map();
     const actions = [];
     const actions = [];
 
 
@@ -48,10 +49,7 @@ module.exports = ({ cooler }) => {
       const c = msg?.value?.content;
       const c = msg?.value?.content;
       const k = msg?.key;
       const k = msg?.key;
       if (!c || !k) continue;
       if (!c || !k) continue;
-      if (c.type === "tombstone" && c.target) {
-        tombstoned.add(c.target);
-        continue;
-      }
+      if (c.type === 'tombstone') continue;
       if (c.type === "feed") {
       if (c.type === "feed") {
         feedsById.set(k, msg);
         feedsById.set(k, msg);
         if (c.replaces) {
         if (c.replaces) {
@@ -179,23 +177,29 @@ module.exports = ({ cooler }) => {
     if (!c || c.type !== "feed") throw new Error("Invalid feed");
     if (!c || c.type !== "feed") throw new Error("Invalid feed");
     if (!isValidFeedText(c.text)) 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) || [];
     const existing = idx.actionsByRoot.get(tipId) || [];
     for (const a of existing) {
     for (const a of existing) {
       const ac = a?.value?.content || {};
       const ac = a?.value?.content || {};
       if (ac.type === "feed-action" && ac.action === "vote" && a.value?.author === userId) throw new Error("Already voted");
       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) => {
     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 pull = require('../server/node_modules/pull-stream');
+const crypto = require('crypto');
 const { getConfig } = require('../configs/config-manager.js');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
-module.exports = ({ cooler }) => {
+module.exports = ({ cooler, tribeCrypto, forumCrypto }) => {
   let ssb, userId;
   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 () => {
   const openSsb = async () => {
     if (!ssb) {
     if (!ssb) {
@@ -15,11 +62,9 @@ module.exports = ({ cooler }) => {
 
 
   async function collectTombstones(ssbClient) {
   async function collectTombstones(ssbClient) {
     return new Promise((resolve, reject) => {
     return new Promise((resolve, reject) => {
-      const tomb = new Set();
       pull(
       pull(
         ssbClient.createLogStream({ limit: logLimit }),
         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 {
   return {
-    createForum: async (category, title, text) => {
+    ingestKeys: async () => { await ingestOwnTribeKeys(); },
+
+    createForum: async (category, title, text, isPrivate = false) => {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
-      const content = {
+      const isPrivateFlag = isPrivate === true || isPrivate === 'true' || isPrivate === 'on';
+      const plainContent = {
         type: 'forum',
         type: 'forum',
         category,
         category,
         title,
         title,
@@ -89,16 +137,40 @@ module.exports = ({ cooler }) => {
         createdAt: new Date().toISOString(),
         createdAt: new Date().toISOString(),
         author: userId,
         author: userId,
         votes: { positives: 0, negatives: 0 },
         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) => {
     addMessageToForum: async (forumId, message, parentId = null) => {
       const ssbClient = await openSsb();
       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,
         ...message,
         root: forumId,
         root: forumId,
         type: 'forum',
         type: 'forum',
@@ -108,11 +180,59 @@ module.exports = ({ cooler }) => {
         votes_inhabitants: []
         votes_inhabitants: []
       };
       };
       if (parentId) content.branch = parentId;
       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) =>
       return new Promise((resolve, reject) =>
         ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res))
         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) => {
     voteContent: async (targetId, value) => {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const whoami = await new Promise((res, rej) =>
       const whoami = await new Promise((res, rej) =>
@@ -160,12 +280,20 @@ module.exports = ({ cooler }) => {
         pull(ssbClient.createLogStream({ limit: logLimit }), 
         pull(ssbClient.createLogStream({ limit: logLimit }), 
         pull.collect((err, data) => err ? rej(err) : res(data)))
         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
       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(
       const forumsWithVotes = await Promise.all(
         forums.map(async f => {
         forums.map(async f => {
           const { positives, negatives } = await aggregateVotes(ssbClient, f.key);
           const { positives, negatives } = await aggregateVotes(ssbClient, f.key);
@@ -174,10 +302,21 @@ module.exports = ({ cooler }) => {
       );
       );
       const repliesByRoot = {};
       const repliesByRoot = {};
       msgs.forEach(m => {
       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(
       const final = await Promise.all(
@@ -227,12 +366,12 @@ module.exports = ({ cooler }) => {
         pull(ssbClient.createLogStream({ limit: logLimit }), 
         pull(ssbClient.createLogStream({ limit: logLimit }), 
         pull.collect((err, data) => err ? rej(err) : res(data)))
         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));
       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');
       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);
       const { positives, negatives } = await aggregateVotes(ssbClient, id);
       return {
       return {
         ...base,
         ...base,
@@ -249,17 +388,27 @@ module.exports = ({ cooler }) => {
         pull(ssbClient.createLogStream({ limit: logLimit }), 
         pull(ssbClient.createLogStream({ limit: logLimit }), 
         pull.collect((err, data) => err ? rej(err) : res(data)))
         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
       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,
           key: m.key,
-          text: m.value.content.text,
-          author: m.value.content.author,
+          text: c.text,
+          author: c.author,
           timestamp: m.value.timestamp,
           timestamp: m.value.timestamp,
-          parent: m.value.content.branch || null
+          parent: c.branch || null
         }));
         }));
       for (let r of replies) {
       for (let r of replies) {
         const { positives: rp, negatives: rn } = await aggregateVotes(ssbClient, r.key);
         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 pull = require("../server/node_modules/pull-stream");
 const { getConfig } = require("../configs/config-manager.js");
 const { getConfig } = require("../configs/config-manager.js");
 const categories = require("../backend/opinion_categories");
 const categories = require("../backend/opinion_categories");
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 
 
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
@@ -43,7 +44,7 @@ module.exports = ({ cooler }) => {
     });
     });
 
 
   const buildIndex = (messages) => {
   const buildIndex = (messages) => {
-    const tomb = new Set();
+    const tomb = buildValidatedTombstoneSet(messages);
     const nodes = new Map();
     const nodes = new Map();
     const parent = new Map();
     const parent = new Map();
     const child = new Map();
     const child = new Map();
@@ -54,15 +55,14 @@ module.exports = ({ cooler }) => {
       const c = v.content;
       const c = v.content;
       if (!c) continue;
       if (!c) continue;
 
 
-      if (c.type === "tombstone" && c.target) {
-        tomb.add(c.target);
-        continue;
-      }
+      if (c.type === "tombstone") continue;
 
 
       if (c.type !== "image") continue;
       if (c.type !== "image") continue;
 
 
       const ts = v.timestamp || m.timestamp || 0;
       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) {
       if (c.replaces) {
         parent.set(k, c.replaces);
         parent.set(k, c.replaces);
@@ -111,7 +111,8 @@ module.exports = ({ cooler }) => {
       meme: !!c.meme,
       meme: !!c.meme,
       opinions: c.opinions || {},
       opinions: c.opinions || {},
       opinions_inhabitants: voters,
       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 pull = require("../server/node_modules/pull-stream")
 const moment = require("../server/node_modules/moment")
 const moment = require("../server/node_modules/moment")
 const { getConfig } = require("../configs/config-manager.js")
 const { getConfig } = require("../configs/config-manager.js")
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000
 const logLimit = getConfig().ssbLogStream?.limit || 1000
 
 
 const norm = (s) => String(s || "").trim().toLowerCase()
 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 util = require('../server/node_modules/util');
 const axios = require('../server/node_modules/axios');
 const axios = require('../server/node_modules/axios');
 const { getConfig } = require('../configs/config-manager.js');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 
 
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
@@ -275,7 +276,7 @@ module.exports = ({ cooler }) => {
       )
       )
     );
     );
     const items = [];
     const items = [];
-    const tombstoned = new Set();
+    const tombstoned = buildValidatedTombstoneSet(raw);
     const replaced = new Map();
     const replaced = new Map();
     for (const m of raw) {
     for (const m of raw) {
       if (!m || !m.value) continue;
       if (!m || !m.value) continue;
@@ -290,7 +291,7 @@ module.exports = ({ cooler }) => {
       const c = v?.content;
       const c = v?.content;
       if (!c) continue;
       if (!c) continue;
       if (v.author !== userId) 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.type !== 'log') continue;
       if (c.replaces) replaced.set(c.replaces, dec.key || keyIn);
       if (c.replaces) replaced.set(c.replaces, dec.key || keyIn);
       items.push({
       items.push({

+ 98 - 28
src/models/main_models.js

@@ -1,5 +1,6 @@
 "use strict";
 "use strict";
 
 
+const { buildValidatedTombstoneSet } = require("./tombstone_validator");
 const debug = require("../server/node_modules/debug")("oasis");
 const debug = require("../server/node_modules/debug")("oasis");
 const { isRoot, isReply: isComment } = require("../server/node_modules/ssb-thread-schema");
 const { isRoot, isReply: isComment } = require("../server/node_modules/ssb-thread-schema");
 const lodash = require("../server/node_modules/lodash");
 const lodash = require("../server/node_modules/lodash");
@@ -307,6 +308,8 @@ models.about = {
       ubi:      result.ubi      === true,
       ubi:      result.ubi      === true,
       wallet:   result.wallet   === true,
       wallet:   result.wallet   === true,
       ecoTax:   result.ecoTax   !== false,
       ecoTax:   result.ecoTax   !== false,
+      larpSign: result.larpSign === true,
+      gpg:      result.gpg      !== false,
       clearnet: result.clearnet === true,
       clearnet: result.clearnet === true,
       clearnetShops:     result.clearnetShops     === true,
       clearnetShops:     result.clearnetShops     === true,
       clearnetJobs:      result.clearnetJobs      === true,
       clearnetJobs:      result.clearnetJobs      === true,
@@ -318,6 +321,7 @@ models.about = {
       clearnetImages:    result.clearnetImages    === true,
       clearnetImages:    result.clearnetImages    === true,
       clearnetDocuments: result.clearnetDocuments === true,
       clearnetDocuments: result.clearnetDocuments === true,
       clearnetTorrents:  result.clearnetTorrents  === true,
       clearnetTorrents:  result.clearnetTorrents  === true,
+      clearnetBookmarks: result.clearnetBookmarks === true,
       profileShops:      result.profileShops      === true,
       profileShops:      result.profileShops      === true,
       profileJobs:       result.profileJobs       === true,
       profileJobs:       result.profileJobs       === true,
       profileEvents:     result.profileEvents     === true,
       profileEvents:     result.profileEvents     === true,
@@ -327,7 +331,8 @@ models.about = {
       profileVideos:     result.profileVideos     === true,
       profileVideos:     result.profileVideos     === true,
       profileImages:     result.profileImages     === true,
       profileImages:     result.profileImages     === true,
       profileDocuments:  result.profileDocuments  === true,
       profileDocuments:  result.profileDocuments  === true,
-      profileTorrents:   result.profileTorrents   === true
+      profileTorrents:   result.profileTorrents   === true,
+      profileBookmarks:  result.profileBookmarks  === true
     };
     };
   },
   },
   name: async (feedId) => {
   name: async (feedId) => {
@@ -391,6 +396,20 @@ models.about = {
       })) || "";
       })) || "";
     return raw;
     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() {
   _startNameWarmup() {
     const abortable = pullAbortable();
     const abortable = pullAbortable();
     let intervals = [];
     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 ssb = await cooler.open();
       const normalizePrefs = (raw) => {
       const normalizePrefs = (raw) => {
         const r = raw || {};
         const r = raw || {};
@@ -1878,6 +1897,8 @@ const post = {
           ubi:      r.ubi      === true,
           ubi:      r.ubi      === true,
           wallet:   r.wallet   === true,
           wallet:   r.wallet   === true,
           ecoTax:   r.ecoTax   !== false,
           ecoTax:   r.ecoTax   !== false,
+          larpSign: r.larpSign === true,
+          gpg:      r.gpg      !== false,
           clearnet: r.clearnet === true,
           clearnet: r.clearnet === true,
           clearnetShops:     r.clearnetShops     === true,
           clearnetShops:     r.clearnetShops     === true,
           clearnetJobs:      r.clearnetJobs      === true,
           clearnetJobs:      r.clearnetJobs      === true,
@@ -1889,6 +1910,7 @@ const post = {
           clearnetImages:    r.clearnetImages    === true,
           clearnetImages:    r.clearnetImages    === true,
           clearnetDocuments: r.clearnetDocuments === true,
           clearnetDocuments: r.clearnetDocuments === true,
           clearnetTorrents:  r.clearnetTorrents  === true,
           clearnetTorrents:  r.clearnetTorrents  === true,
+          clearnetBookmarks: r.clearnetBookmarks === true,
           profileShops:      r.profileShops      === true,
           profileShops:      r.profileShops      === true,
           profileJobs:       r.profileJobs       === true,
           profileJobs:       r.profileJobs       === true,
           profileEvents:     r.profileEvents     === true,
           profileEvents:     r.profileEvents     === true,
@@ -1898,12 +1920,24 @@ const post = {
           profileVideos:     r.profileVideos     === true,
           profileVideos:     r.profileVideos     === true,
           profileImages:     r.profileImages     === true,
           profileImages:     r.profileImages     === true,
           profileDocuments:  r.profileDocuments  === true,
           profileDocuments:  r.profileDocuments  === true,
-          profileTorrents:   r.profileTorrents   === true
+          profileTorrents:   r.profileTorrents   === true,
+          profileBookmarks:  r.profileBookmarks  === true
         };
         };
       };
       };
       const prefs = visibilityPrefs ? normalizePrefs(visibilityPrefs) : undefined;
       const prefs = visibilityPrefs ? normalizePrefs(visibilityPrefs) : undefined;
       const baseFields = { type: "about", about: ssb.id, name, description };
       const baseFields = { type: "about", about: ssb.id, name, description };
       if (prefs) baseFields.visibilityPrefs = prefs;
       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) {
       if (image && image.length > 0) {
         const megabyte = Math.pow(2, 20);
         const megabyte = Math.pow(2, 20);
         const maxSize = 50 * megabyte;
         const maxSize = 50 * megabyte;
@@ -2074,11 +2108,7 @@ const post = {
           return null;
           return null;
         }
         }
       }).filter(Boolean);
       }).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 => {
       return decryptedMessages.filter(msg => {
         if (tombstoneTargets.has(msg.key)) return false;
         if (tombstoneTargets.has(msg.key)) return false;
           const content = msg.value?.content;
           const content = msg.value?.content;
@@ -2214,42 +2244,82 @@ models.lifetime = (() => {
   };
   };
 })();
 })();
 
 
+const ownSpreadsByTarget = new Map();
+const ownTombstoned = new Set();
 models.spreads = {
 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) => {
   forMessage: async (msgKey) => {
     if (!msgKey || typeof msgKey !== 'string') return { count: 0, voters: [], alreadySpread: false };
     if (!msgKey || typeof msgKey !== 'string') return { count: 0, voters: [], alreadySpread: false };
     const ssb = await cooler.open();
     const ssb = await cooler.open();
     const myId = ssb.id;
     const myId = ssb.id;
-    return new Promise((resolve) => {
+    const refs = await new Promise((resolve) => {
       pull(
       pull(
         ssb.backlinks.read({
         ssb.backlinks.read({
-          query: [{ $filter: { dest: msgKey, value: { content: { type: 'vote' } } } }],
+          query: [{ $filter: { dest: msgKey } }],
           index: 'DTA',
           index: 'DTA',
           meta: true
           meta: true
         }),
         }),
         pull.filter(ref => {
         pull.filter(ref => {
           if (!ref || !ref.value || !ref.value.content) return false;
           if (!ref || !ref.value || !ref.value.content) return false;
           const c = ref.value.content;
           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 pull = require("../server/node_modules/pull-stream");
 const crypto = require("crypto");
 const crypto = require("crypto");
 const { getConfig } = require("../configs/config-manager.js");
 const { getConfig } = require("../configs/config-manager.js");
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 
 
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const INVITE_CODE_BYTES = 16;
 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 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 tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null;
   const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c;
   const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c;
   const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c;
   const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c;
@@ -223,6 +267,72 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
   return {
   return {
     type: "map",
     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) {
     async resolveCurrentId(id) {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const messages = await getAllMessages(ssbClient);
       const messages = await getAllMessages(ssbClient);
@@ -273,7 +383,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
         updatedAt: now
         updatedAt: now
       };
       };
 
 
-      const shouldEncryptStandalone = !tribeId && tribeCrypto && (mType === "OPEN" || mType === "CLOSED");
+      const shouldEncryptStandalone = !tribeId && tribeCrypto;
       let mapKey = null;
       let mapKey = null;
       let content = plainContent;
       let content = plainContent;
       if (tribeId) {
       if (tribeId) {
@@ -299,7 +409,8 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
         if (mType === "OPEN") {
         if (mType === "OPEN") {
           try {
           try {
             const pubCode = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex");
             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 = {
             let updated = {
               type: "map",
               type: "map",
               replaces: result.key,
               replaces: result.key,
@@ -311,7 +422,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
               mapType: mType,
               mapType: mType,
               author: userId,
               author: userId,
               members: [userId],
               members: [userId],
-              invites: [{ code: pubCode, ek, gen: 1, public: true }],
+              invites: [{ code: pubCode, ek, salt: inviteSalt, gen: 1, public: true }],
               tags,
               tags,
               ...(image ? { image } : {}),
               ...(image ? { image } : {}),
               createdAt: now,
               createdAt: now,
@@ -440,7 +551,8 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
         content = await encryptIfTribe(content);
         content = await encryptIfTribe(content);
       } else if (tribeCrypto) {
       } else if (tribeCrypto) {
         const mapKey = lookupKey(rootId);
         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) => {
       return new Promise((resolve, reject) => {
@@ -532,8 +644,9 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
       const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex");
       const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex");
       let invite = code;
       let invite = code;
       if (tribeCrypto && !map.tribeId) {
       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 tipId = await this.resolveCurrentId(mapId);
       const rootId = await this.resolveRootId(mapId);
       const rootId = await this.resolveRootId(mapId);
@@ -563,7 +676,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
         updatedAt: new Date().toISOString()
         updatedAt: new Date().toISOString()
       };
       };
       if (effectiveTribeId) updated = await encryptIfTribe(updated);
       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)));
       await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r)));
       const tomb1 = await tombFor(tipId, effectiveTribeId, userId);
       const tomb1 = await tombFor(tipId, effectiveTribeId, userId);
       await new Promise((resolve, reject) => ssbClient.publish(tomb1, e => e ? reject(e) : resolve()));
       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;
       let mapKey = null;
       if (tribeCrypto && typeof matchedInvite === "object") {
       if (tribeCrypto && typeof matchedInvite === "object") {
         if (matchedInvite.ekChain) {
         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) {
           if (Array.isArray(chain) && chain.length) {
             for (const entry of chain) {
             for (const entry of chain) {
               if (Array.isArray(entry.keys) && entry.keys.length) {
               if (Array.isArray(entry.keys) && entry.keys.length) {
@@ -601,7 +714,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
             mapKey = chain[0].key;
             mapKey = chain[0].key;
           }
           }
         } else if (matchedInvite.ek) {
         } 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);
           ownCrypto.setKey(matched.rootId || matched.key, mapKey, matchedInvite.gen || 1);
         }
         }
       }
       }
@@ -639,7 +752,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
         updatedAt: new Date().toISOString()
         updatedAt: new Date().toISOString()
       };
       };
       if (effectiveTribeId2) updated = await encryptIfTribe(updated);
       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)));
       await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r)));
       const tomb2 = await tombFor(tipId, effectiveTribeId2, userId);
       const tomb2 = await tombFor(tipId, effectiveTribeId2, userId);
       await new Promise((resolve, reject) => ssbClient.publish(tomb2, e => e ? reject(e) : resolve()));
       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 pull = require("../server/node_modules/pull-stream")
 const moment = require("../server/node_modules/moment")
 const moment = require("../server/node_modules/moment")
 const { getConfig } = require("../configs/config-manager.js")
 const { getConfig } = require("../configs/config-manager.js")
+const { buildValidatedTombstoneSet } = require('./tombstone_validator')
 const logLimit = getConfig().ssbLogStream?.limit || 1000
 const logLimit = getConfig().ssbLogStream?.limit || 1000
 
 
 const N = (s) => String(s || "").toUpperCase().replace(/\s+/g, "_")
 const N = (s) => String(s || "").toUpperCase().replace(/\s+/g, "_")
@@ -70,17 +71,14 @@ module.exports = ({ cooler, tribeCrypto }) => {
     const ssbClient = await openSsb()
     const ssbClient = await openSsb()
     const messages = await readAll(ssbClient)
     const messages = await readAll(ssbClient)
 
 
-    const tomb = new Set()
+    const tomb = buildValidatedTombstoneSet(messages)
     const fwd = new Map()
     const fwd = new Map()
     const parent = new Map()
     const parent = new Map()
 
 
     for (const m of messages) {
     for (const m of messages) {
       const c = m.value && m.value.content
       const c = m.value && m.value.content
       if (!c) continue
       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.type !== "market") continue
       if (c.replaces) {
       if (c.replaces) {
         fwd.set(c.replaces, m.key)
         fwd.set(c.replaces, m.key)
@@ -247,7 +245,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
       const userId = ssbClient.id
       const userId = ssbClient.id
       const messages = await readAll(ssbClient)
       const messages = await readAll(ssbClient)
 
 
-      const tomb = new Set()
+      const tomb = buildValidatedTombstoneSet(messages)
       const nodes = new Map()
       const nodes = new Map()
       const parent = new Map()
       const parent = new Map()
       const child = new Map()
       const child = new Map()
@@ -256,10 +254,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
         const k = m.key
         const k = m.key
         const c = m.value && m.value.content
         const c = m.value && m.value.content
         if (!c) continue
         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.type !== "market") continue
         nodes.set(k, { key: k, ts: (m.value && m.value.timestamp) || m.timestamp || 0, c })
         nodes.set(k, { key: k, ts: (m.value && m.value.timestamp) || m.timestamp || 0, c })
         if (c.replaces) {
         if (c.replaces) {
@@ -397,7 +392,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
       const userId = ssbClient.id
       const userId = ssbClient.id
       const messages = await readAll(ssbClient)
       const messages = await readAll(ssbClient)
 
 
-      const tomb = new Set()
+      const tomb = buildValidatedTombstoneSet(messages)
       const nodes = new Map()
       const nodes = new Map()
       const parent = new Map()
       const parent = new Map()
       const child = new Map()
       const child = new Map()
@@ -406,10 +401,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
         const k = m.key
         const k = m.key
         const c = m.value && m.value.content
         const c = m.value && m.value.content
         if (!c) continue
         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.type !== "market") continue
         nodes.set(k, { key: k, ts: (m.value && m.value.timestamp) || m.timestamp || 0, c })
         nodes.set(k, { key: k, ts: (m.value && m.value.timestamp) || m.timestamp || 0, c })
         if (c.replaces) {
         if (c.replaces) {

+ 65 - 1
src/models/melody_model.js

@@ -112,6 +112,70 @@ module.exports = ({ cooler }) => {
     NOTE_NAMES,
     NOTE_NAMES,
     OCTAVES,
     OCTAVES,
     TYPE_TO_DEGREE,
     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 pull = require('../server/node_modules/pull-stream');
 const { getConfig } = require('../configs/config-manager.js');
 const { getConfig } = require('../configs/config-manager.js');
 const categories = require('../backend/opinion_categories');
 const categories = require('../backend/opinion_categories');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
@@ -73,7 +74,7 @@ module.exports = ({ cooler }) => {
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
       );
       );
     });
     });
-    const tombstoned = new Set();
+    const tombstoned = buildValidatedTombstoneSet(messages);
     const replaces = new Map();
     const replaces = new Map();
     const byId = new Map();
     const byId = new Map();
 
 
@@ -81,9 +82,8 @@ module.exports = ({ cooler }) => {
       const key = msg.key;
       const key = msg.key;
       const c = msg.value?.content;
       const c = msg.value?.content;
       if (!c) continue;
       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;
         continue;
       }
       }
       if (c.opinions && !tombstoned.has(key) && !['task', 'event', 'report'].includes(c.type)) {
       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 pull = require("../server/node_modules/pull-stream")
 const crypto = require("crypto")
 const crypto = require("crypto")
 const fs = require("fs")
 const fs = require("fs")
+const { buildValidatedTombstoneSet } = require('./tombstone_validator')
 const path = require("path")
 const path = require("path")
 const { getConfig } = require("../configs/config-manager.js")
 const { getConfig } = require("../configs/config-manager.js")
 const logLimit = getConfig().ssbLogStream?.limit || 1000
 const logLimit = getConfig().ssbLogStream?.limit || 1000
@@ -120,16 +121,63 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
     return null
     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"))
     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"))
     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) =>
   const readAll = async (ssbClient) =>
     new Promise((resolve, reject) =>
     new Promise((resolve, reject) =>
       pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
       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 = []
       const initialInvites = []
       if (validStatus === "OPEN" && !usesTribeKey) {
       if (validStatus === "OPEN" && !usesTribeKey) {
         const pubCode = crypto.randomBytes(INVITE_BYTES).toString("hex")
         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 = {
       const content = {
@@ -319,7 +368,8 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
             if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
             if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
           }
           }
           if (!keyHex) keyHex = getPadKey(rootId)
           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 = {
           const updated = {
             ...c,
             ...c,
             title: data.title !== undefined ? enc(safeText(data.title)) : c.title,
             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) {
     async addMemberToPad(padId, feedId) {
       const tipId = await this.resolveCurrentId(padId)
       const tipId = await this.resolveCurrentId(padId)
       const ssbClient = await openSsb()
       const ssbClient = await openSsb()
@@ -481,8 +580,9 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
       const code = crypto.randomBytes(INVITE_BYTES).toString("hex")
       const code = crypto.randomBytes(INVITE_BYTES).toString("hex")
       let invite = code
       let invite = code
       if (keyHex) {
       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]
       const invites = [...pad.invites, invite]
       await this.updatePadById(padId, { invites })
       await this.updatePadById(padId, { invites })
@@ -507,7 +607,7 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
       let padKey = null
       let padKey = null
       let resolvedRootId = null
       let resolvedRootId = null
       if (typeof matchedInvite === "object" && matchedInvite.ek) {
       if (typeof matchedInvite === "object" && matchedInvite.ek) {
-        padKey = decryptFromInvite(matchedInvite.ek, code)
+        padKey = decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt)
         resolvedRootId = await this.resolveRootId(matchedPad.rootId)
         resolvedRootId = await this.resolveRootId(matchedPad.rootId)
         setPadKey(resolvedRootId, padKey)
         setPadKey(resolvedRootId, padKey)
       }
       }
@@ -557,15 +657,16 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
         if (tKeys.length) keyHex = tKeys[0]
         if (tKeys.length) keyHex = tKeys[0]
       }
       }
       if (!keyHex) keyHex = getPadKey(rootId)
       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 now = new Date().toISOString()
-      const encText = keyHex ? encryptField(safeText(text), keyHex) : safeText(text)
+      const encText = encryptField(safeText(text), keyHex)
       const content = {
       const content = {
         type: "padEntry",
         type: "padEntry",
         padId: rootId,
         padId: rootId,
         text: encText,
         text: encText,
         author: ssbClient.id,
         author: ssbClient.id,
         createdAt: now,
         createdAt: now,
-        encrypted: !!keyHex,
+        encrypted: true,
         ...(pad && pad.tribeId ? { tribeId: pad.tribeId } : {})
         ...(pad && pad.tribeId ? { tribeId: pad.tribeId } : {})
       }
       }
       return new Promise((resolve, reject) => {
       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 pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const moment = require('../server/node_modules/moment');
 const { getConfig } = require('../configs/config-manager.js');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 
 
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const TERM_DAYS = 60;
 const TERM_DAYS = 60;
@@ -91,7 +92,7 @@ module.exports = ({ cooler, services = {} }) => {
   }
   }
 
 
   function listByTypeFromMsgs(msgs, type) {
   function listByTypeFromMsgs(msgs, type) {
-    const tomb = new Set();
+    const tomb = buildValidatedTombstoneSet(msgs);
     const rep = new Map();
     const rep = new Map();
     const children = new Map();
     const children = new Map();
     const map = new Map();
     const map = new Map();
@@ -101,10 +102,7 @@ module.exports = ({ cooler, services = {} }) => {
       const v = m.value || {};
       const v = m.value || {};
       const c = v.content;
       const c = v.content;
       if (!c) continue;
       if (!c) continue;
-
-      if (c.type === 'tombstone' && c.target) tomb.add(c.target);
-
-      if (c.type === type) {
+if (c.type === type) {
         if (c.replaces) {
         if (c.replaces) {
           const oldId = c.replaces;
           const oldId = c.replaces;
           const ts = normMs(v.timestamp || m.timestamp || Date.now());
           const ts = normMs(v.timestamp || m.timestamp || Date.now());
@@ -1215,12 +1213,12 @@ module.exports = ({ cooler, services = {} }) => {
     } catch (_) { chainIds = [tribeId]; }
     } catch (_) { chainIds = [tribeId]; }
     const tribeIdSet = new Set(Array.isArray(chainIds) && chainIds.length ? chainIds : [tribeId]);
     const tribeIdSet = new Set(Array.isArray(chainIds) && chainIds.length ? chainIds : [tribeId]);
     const msgs = await tribeReadLog();
     const msgs = await tribeReadLog();
-    const tomb = new Set();
+    const tomb = buildValidatedTombstoneSet(msgs);
     const replaced = new Set();
     const replaced = new Set();
     const items = new Map();
     const items = new Map();
     for (const m of msgs) {
     for (const m of msgs) {
       const c = m.value?.content; if (!c) continue;
       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 (c.type !== type) continue;
       if (!tribeIdSet.has(c.tribeId)) continue;
       if (!tribeIdSet.has(c.tribeId)) continue;
       if (c.replaces) replaced.add(c.replaces);
       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 pull = require('../server/node_modules/pull-stream');
 const { getConfig } = require('../configs/config-manager.js');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 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 replaces = new Map();
     const byId = 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 replaces = new Map();
     const byKey = new Map();
     const byKey = new Map();
 
 
@@ -104,10 +101,7 @@ module.exports = ({ cooler }) => {
       const c = m.value?.content;
       const c = m.value?.content;
       const k = m.key;
       const k = m.key;
       if (!c) continue;
       if (!c) continue;
-      if (c.type === 'tombstone' && c.target) {
-        tombstoned.add(c.target);
-        continue;
-      }
+      if (c.type === 'tombstone') continue;
       if (c.type === 'pixelia') {
       if (c.type === 'pixelia') {
         if (tombstoned.has(k)) continue;
         if (tombstoned.has(k)) continue;
         if (c.replaces) replaces.set(c.replaces, k);
         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 posts = [];
       const tombed = new Set();
       const tombed = new Set();
+      const tombClaims = new Map();
+      const authorByKey = new Map();
+      const recpsByKey = new Map();
       for (const m of raw) {
       for (const m of raw) {
         if (!m || !m.value) continue;
         if (!m || !m.value) continue;
         const keyIn = m.key || m.value?.key || m.value?.hash || '';
         const keyIn = m.key || m.value?.key || m.value?.hash || '';
@@ -108,11 +111,15 @@ module.exports = ({ cooler }) => {
         const k = dec?.key || keyIn;
         const k = dec?.key || keyIn;
         if (!c || c.private !== true || !k) continue;
         if (!c || c.private !== true || !k) continue;
         if (c.type === 'tombstone' && c.target) {
         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;
           continue;
         }
         }
+        authorByKey.set(k, v.author);
         if (c.type === 'post') {
         if (c.type === 'post') {
           const to = Array.isArray(c.to) ? c.to : [];
           const to = Array.isArray(c.to) ? c.to : [];
+          recpsByKey.set(k, to);
           const author = v.author;
           const author = v.author;
           if (author === userId || to.includes(userId)) {
           if (author === userId || to.includes(userId)) {
             posts.push({
             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));
       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 pull = require("../server/node_modules/pull-stream")
 const moment = require("../server/node_modules/moment")
 const moment = require("../server/node_modules/moment")
 const { getConfig } = require("../configs/config-manager.js")
 const { getConfig } = require("../configs/config-manager.js")
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = (getConfig().ssbLogStream && getConfig().ssbLogStream.limit) || 1000
 const logLimit = (getConfig().ssbLogStream && getConfig().ssbLogStream.limit) || 1000
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
@@ -104,8 +105,7 @@ module.exports = ({ cooler }) => {
     for (const m of all) {
     for (const m of all) {
       const c = m && m.value && m.value.content
       const c = m && m.value && m.value.content
       if (!c) continue
       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
     let cur = id
@@ -182,7 +182,9 @@ module.exports = ({ cooler }) => {
         createdAt: new Date().toISOString(),
         createdAt: new Date().toISOString(),
         updatedAt: null,
         updatedAt: null,
         mapUrl: String(data.mapUrl || "").trim(),
         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))))
       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)
       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) {
     async deleteProject(id) {
       const ssbClient = await openSsb()
       const ssbClient = await openSsb()
       const tip = await resolveTipId(id)
       const tip = await resolveTipId(id)
@@ -433,7 +451,7 @@ module.exports = ({ cooler }) => {
       const currentUserId = ssbClient.id
       const currentUserId = ssbClient.id
       const msgs = await getAllMsgs(ssbClient)
       const msgs = await getAllMsgs(ssbClient)
 
 
-      const tomb = new Set()
+      const tomb = buildValidatedTombstoneSet(msgs)
       const nodes = new Map()
       const nodes = new Map()
       const parent = new Map()
       const parent = new Map()
       const child = new Map()
       const child = new Map()
@@ -442,10 +460,7 @@ module.exports = ({ cooler }) => {
         const k = m && m.key
         const k = m && m.key
         const c = m && m.value && m.value.content
         const c = m && m.value && m.value.content
         if (!c) continue
         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 (c.type !== TYPE) continue
         nodes.set(k, { key: k, ts: (m.value && m.value.timestamp) || 0, c })
         nodes.set(k, { key: k, ts: (m.value && m.value.timestamp) || 0, c })
         if (c.replaces) {
         if (c.replaces) {

+ 35 - 3
src/models/reports_model.js

@@ -1,5 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
 const { getConfig } = require('../configs/config-manager.js');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 const normU = (v) => String(v || '').trim().toUpperCase();
 const normU = (v) => String(v || '').trim().toUpperCase();
@@ -74,7 +75,9 @@ module.exports = ({ cooler }) => {
         confirmations: [],
         confirmations: [],
         severity: normalizeSeverity(severity) || 'low',
         severity: normalizeSeverity(severity) || 'low',
         status: 'OPEN',
         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)));
       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)));
       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() {
     async listAll() {
       const ssb = await openSsb();
       const ssb = await openSsb();
 
 
@@ -209,7 +241,7 @@ module.exports = ({ cooler }) => {
           pull.collect((err, results) => {
           pull.collect((err, results) => {
             if (err) return reject(err);
             if (err) return reject(err);
 
 
-            const tombstoned = new Set();
+            const tombstoned = buildValidatedTombstoneSet(results);
             const replaced = new Map();
             const replaced = new Map();
             const reports = 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;
               const c = r && r.value && r.value.content ? r.value.content : null;
               if (!key || !c) continue;
               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.type === 'report') {
                 if (c.replaces) replaced.set(c.replaces, key);
                 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 pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const moment = require('../server/node_modules/moment');
 const { getConfig } = require('../configs/config-manager.js');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
 module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
@@ -55,7 +56,7 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
       case 'votes':
       case 'votes':
         return [content?.question, content?.deadline, content?.status, ...(Object.values(content?.votes || {})), content?.totalVotes];
         return [content?.question, content?.deadline, content?.status, ...(Object.values(content?.votes || {})), content?.totalVotes];
       case 'tribe':
       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':
       case 'audio':
         return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])];
         return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])];
       case 'image':
       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 replacesMap = new Map();
     const latestByKey = 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 pull = require("../server/node_modules/pull-stream")
 const { getConfig } = require("../configs/config-manager.js")
 const { getConfig } = require("../configs/config-manager.js")
 const categories = require("../backend/opinion_categories")
 const categories = require("../backend/opinion_categories")
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000
 const logLimit = getConfig().ssbLogStream?.limit || 1000
 
 
 const safeArr = (v) => (Array.isArray(v) ? v : [])
 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 fs = require('fs');
 const path = require('path');
 const path = require('path');
 const { getConfig } = require('../configs/config-manager.js');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 const STORAGE_DIR = path.join(__dirname, "..", "configs");
 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;
       if (c.type && HIDDEN_ENVELOPE_TYPES.has(c.type)) return false;
       return true;
       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;
     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 myMsgsAll = allMsgs.filter(m => m.value.author === userId);
     const myShare = allMsgs.length ? (myMsgsAll.length / allMsgs.length) * 100 : 0;
     const myShare = allMsgs.length ? (myMsgsAll.length / allMsgs.length) * 100 : 0;
     const avgMsgsPerInhabitant = inhabitants ? allMsgs.length / inhabitants : 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 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;
     const networkSpanDays = totalsTs.length >= 2 ? (totalsTs[totalsTs.length - 1] - totalsTs[0]) / 86400000 : 0;
@@ -552,8 +550,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       tribePublicNames,
       tribePublicNames,
       tribePublicCount,
       tribePublicCount,
       tribePrivateCount,
       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),
       folderSize: formatSize(folderSize),
       statsBlockchainSize: formatSize(flumeSize),
       statsBlockchainSize: formatSize(flumeSize),
       statsBlobsSize: formatSize(blobsSize),
       statsBlobsSize: formatSize(blobsSize),
@@ -588,7 +586,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
         topAuthors
         topAuthors
       },
       },
       tombstoneKPIs: {
       tombstoneKPIs: {
-        networkTombstoneCount: allMsgs.filter(m => m.value.content.type === 'tombstone').length,
+        networkTombstoneCount: validatedTombstoneCount,
         ratio: tombstoneRatio
         ratio: tombstoneRatio
       },
       },
       networkKPIs: {
       networkKPIs: {

+ 2 - 6
src/models/tags_model.js

@@ -1,5 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
 const { getConfig } = require('../configs/config-manager.js');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler, padsModel, tribesModel }) => {
 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 replacesMap = new Map();
       const latestByKey = 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 pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const moment = require('../server/node_modules/moment');
 const { getConfig } = require('../configs/config-manager.js');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler, pmModel }) => {
 module.exports = ({ cooler, pmModel }) => {
@@ -51,7 +52,9 @@ module.exports = ({ cooler, pmModel }) => {
         assignees: [userId],
         assignees: [userId],
         createdAt: new Date().toISOString(),
         createdAt: new Date().toISOString(),
         status: 'OPEN',
         status: 'OPEN',
-        author: userId
+        author: userId,
+        opinions: {},
+        opinions_inhabitants: []
       };
       };
 
 
       return new Promise((res, rej) => ssb.publish(content, (err, msg) => err ? rej(err) : res(msg)));
       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 };
       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) {
     async toggleAssignee(taskId) {
       const ssb = await openSsb();
       const ssb = await openSsb();
       const userId = ssb.id;
       const userId = ssb.id;
@@ -188,15 +208,14 @@ module.exports = ({ cooler, pmModel }) => {
         pull(ssb.createLogStream({ limit: logLimit }),
         pull(ssb.createLogStream({ limit: logLimit }),
           pull.collect((err, results) => {
           pull.collect((err, results) => {
             if (err) return reject(err);
             if (err) return reject(err);
-            const tombstoned = new Set();
+            const tombstoned = buildValidatedTombstoneSet(results);
             const replaced = new Map();
             const replaced = new Map();
             const tasks = new Map();
             const tasks = new Map();
 
 
             for (const r of results) {
             for (const r of results) {
               const { key, value: { content: c } } = r;
               const { key, value: { content: c } } = r;
               if (!c) continue;
               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);
                 if (c.replaces) replaced.set(c.replaces, key);
                 const status = c.status === 'OPEN' && moment(c.endTime).isBefore(now) ? 'CLOSED' : c.status;
                 const status = c.status === 'OPEN' && moment(c.endTime).isBefore(now) ? 'CLOSED' : c.status;
                 tasks.set(key, { id: key, ...c, status });
                 tasks.set(key, { id: key, ...c, status });
@@ -221,13 +240,13 @@ module.exports = ({ cooler, pmModel }) => {
       );
       );
       const now = Date.now();
       const now = Date.now();
       const sent = new Set();
       const sent = new Set();
-      const tombstoned = new Set();
+      const tombstoned = buildValidatedTombstoneSet(messages);
       const replaced = new Set();
       const replaced = new Set();
       const tasks = new Map();
       const tasks = new Map();
       for (const m of messages) {
       for (const m of messages) {
         const c = m.value && m.value.content;
         const c = m.value && m.value.content;
         if (!c) continue;
         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 === 'taskReminderSent' && c.target) { sent.add(c.target); continue; }
         if (c.type === 'task') {
         if (c.type === 'task') {
           if (c.replaces) replaced.add(c.replaces);
           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;
       if (c.type !== "torrent") continue;
 
 
       const ts = v.timestamp || m.timestamp || 0;
       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);
       authorByKey.set(k, v.author);
 
 
       if (c.replaces) {
       if (c.replaces) {
@@ -128,7 +130,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       opinions_inhabitants: voters,
       opinions_inhabitants: voters,
       hasVoted: viewerId ? voters.includes(viewerId) : false,
       hasVoted: viewerId ? voters.includes(viewerId) : false,
       tribeId: c.tribeId || null,
       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 moment = require("../server/node_modules/moment")
 const { getConfig } = require("../configs/config-manager.js")
 const { getConfig } = require("../configs/config-manager.js")
 const categories = require("../backend/opinion_categories")
 const categories = require("../backend/opinion_categories")
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000
 const logLimit = getConfig().ssbLogStream?.limit || 1000
 
 
 const isValidId = (to) => /^@[A-Za-z0-9+/]+={0,2}\.ed25519$/.test(String(to || ""))
 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 { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const opinionCategories = require('../backend/opinion_categories');
 const opinionCategories = require('../backend/opinion_categories');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
@@ -34,7 +35,7 @@ module.exports = ({ cooler }) => {
       );
       );
     });
     });
 
 
-    const tombstoned = new Set();
+    const tombstoned = buildValidatedTombstoneSet(messages);
     const replaces = new Map();
     const replaces = new Map();
     const itemsById = 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 pull = require('../server/node_modules/pull-stream');
 const crypto = require('crypto');
 const crypto = require('crypto');
 const { getConfig } = require('../configs/config-manager.js');
 const { getConfig } = require('../configs/config-manager.js');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const tribeLogLimit = Math.max(logLimit, 100000);
 const tribeLogLimit = Math.max(logLimit, 100000);
 
 
 const INVITE_CODE_BYTES = 16;
 const INVITE_CODE_BYTES = 16;
 const VALID_INVITE_MODES = ['strict', 'open'];
 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 }) => {
 module.exports = ({ cooler, tribeCrypto }) => {
   let ssb;
   let ssb;
@@ -94,7 +95,6 @@ module.exports = ({ cooler, tribeCrypto }) => {
           image: c.image,
           image: c.image,
           location: c.location,
           location: c.location,
           tags: c.tags,
           tags: c.tags,
-          isLARP: c.isLARP,
           isAnonymous: c.isAnonymous,
           isAnonymous: c.isAnonymous,
           members: c.members,
           members: c.members,
           invites: c.invites,
           invites: c.invites,
@@ -262,7 +262,6 @@ module.exports = ({ cooler, tribeCrypto }) => {
       image: c.image || null,
       image: c.image || null,
       location: c.location || null,
       location: c.location || null,
       tags: Array.isArray(c.tags) ? c.tags : [],
       tags: Array.isArray(c.tags) ? c.tags : [],
-      isLARP: !!c.isLARP,
       isAnonymous: c.isAnonymous !== false,
       isAnonymous: c.isAnonymous !== false,
       members: Array.isArray(c.members) ? c.members : [],
       members: Array.isArray(c.members) ? c.members : [],
       invites: Array.isArray(c.invites) ? c.invites : [],
       invites: Array.isArray(c.invites) ? c.invites : [],
@@ -292,7 +291,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
   return {
   return {
     type: 'tribe',
     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"');
       if (!VALID_INVITE_MODES.includes(inviteMode)) throw new Error('Invalid invite mode. Must be "strict" or "open"');
       const client = await openSsb();
       const client = await openSsb();
       const userId = client.id;
       const userId = client.id;
@@ -310,7 +309,6 @@ module.exports = ({ cooler, tribeCrypto }) => {
         image: blobId,
         image: blobId,
         location,
         location,
         tags,
         tags,
-        isLARP: Boolean(isLARP),
         isAnonymous: isPrivate,
         isAnonymous: isPrivate,
         members: [userId],
         members: [userId],
         invites: [],
         invites: [],
@@ -481,7 +479,6 @@ module.exports = ({ cooler, tribeCrypto }) => {
         image: updatedContent.image !== undefined ? updatedContent.image : tribe.image,
         image: updatedContent.image !== undefined ? updatedContent.image : tribe.image,
         location: updatedContent.location !== undefined ? updatedContent.location : tribe.location,
         location: updatedContent.location !== undefined ? updatedContent.location : tribe.location,
         tags: updatedContent.tags !== undefined ? updatedContent.tags : tribe.tags,
         tags: updatedContent.tags !== undefined ? updatedContent.tags : tribe.tags,
-        isLARP: updatedContent.isLARP !== undefined ? !!updatedContent.isLARP : tribe.isLARP,
         isAnonymous: updatedContent.isAnonymous !== undefined ? updatedContent.isAnonymous : tribe.isAnonymous,
         isAnonymous: updatedContent.isAnonymous !== undefined ? updatedContent.isAnonymous : tribe.isAnonymous,
         members: updatedContent.members !== undefined ? updatedContent.members : tribe.members,
         members: updatedContent.members !== undefined ? updatedContent.members : tribe.members,
         invites: updatedContent.invites !== undefined ? updatedContent.invites : tribe.invites,
         invites: updatedContent.invites !== undefined ? updatedContent.invites : tribe.invites,
@@ -564,7 +561,9 @@ module.exports = ({ cooler, tribeCrypto }) => {
       return code;
       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 client = await openSsb();
       const userId = client.id;
       const userId = client.id;
       const msgs = await streamLog();
       const msgs = await streamLog();
@@ -620,16 +619,21 @@ module.exports = ({ cooler, tribeCrypto }) => {
       );
       );
     },
     },
 
 
-    async leaveTribe(tribeId) {
+    async leaveTribe(tribeId, opts = {}) {
       const client = await openSsb();
       const client = await openSsb();
       const userId = client.id;
       const userId = client.id;
       const tribe = await this.getTribeById(tribeId);
       const tribe = await this.getTribeById(tribeId);
       if (!tribe) throw new Error('Tribe not found');
       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 members = Array.isArray(tribe.members) ? [...tribe.members] : [];
       const idx = members.indexOf(userId);
       const idx = members.indexOf(userId);
       if (idx === -1) throw new Error('User is not a member of this tribe');
       if (idx === -1) throw new Error('User is not a member of this tribe');
       members.splice(idx, 1);
       members.splice(idx, 1);
+      if (isAuthor && members.length === 0) {
+        await this.publishTombstone(tribeId).catch(() => {});
+        return;
+      }
       await this.updateTribeById(tribeId, { members });
       await this.updateTribeById(tribeId, { members });
       if (members.length > 0) {
       if (members.length > 0) {
         await this.rotateTribeKey(tribeId, members).catch(() => {});
         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 pull = require("../server/node_modules/pull-stream");
 const { getConfig } = require("../configs/config-manager.js");
 const { getConfig } = require("../configs/config-manager.js");
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const categories = require("../backend/opinion_categories");
 const categories = require("../backend/opinion_categories");
 
 
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
@@ -43,7 +44,7 @@ module.exports = ({ cooler }) => {
     });
     });
 
 
   const buildIndex = (messages) => {
   const buildIndex = (messages) => {
-    const tomb = new Set();
+    const tomb = buildValidatedTombstoneSet(messages);
     const nodes = new Map();
     const nodes = new Map();
     const parent = new Map();
     const parent = new Map();
     const child = new Map();
     const child = new Map();
@@ -54,15 +55,14 @@ module.exports = ({ cooler }) => {
       const c = v.content;
       const c = v.content;
       if (!c) continue;
       if (!c) continue;
 
 
-      if (c.type === "tombstone" && c.target) {
-        tomb.add(c.target);
-        continue;
-      }
+      if (c.type === "tombstone") continue;
 
 
       if (c.type !== "video") continue;
       if (c.type !== "video") continue;
 
 
       const ts = v.timestamp || m.timestamp || 0;
       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) {
       if (c.replaces) {
         parent.set(k, c.replaces);
         parent.set(k, c.replaces);
@@ -110,7 +110,8 @@ module.exports = ({ cooler }) => {
       mapUrl: c.mapUrl || "",
       mapUrl: c.mapUrl || "",
       opinions: c.opinions || {},
       opinions: c.opinions || {},
       opinions_inhabitants: voters,
       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 pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const moment = require('../server/node_modules/moment');
+const { buildValidatedTombstoneSet } = require('./tombstone_validator');
 const { getConfig } = require('../configs/config-manager.js');
 const { getConfig } = require('../configs/config-manager.js');
 const categories = require('../backend/opinion_categories');
 const categories = require('../backend/opinion_categories');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
@@ -23,7 +24,7 @@ module.exports = ({ cooler }) => {
   }
   }
 
 
   function buildIndex(messages) {
   function buildIndex(messages) {
-    const tombstoned = new Set();
+    const tombstoned = buildValidatedTombstoneSet(messages);
     const replaced = new Map();
     const replaced = new Map();
     const votes = new Map();
     const votes = new Map();
     const parent = new Map();
     const parent = new Map();
@@ -34,10 +35,7 @@ module.exports = ({ cooler }) => {
       const c = v && v.content;
       const c = v && v.content;
       if (!c) continue;
       if (!c) continue;
 
 
-      if (c.type === 'tombstone' && c.target) {
-        tombstoned.add(c.target);
-        continue;
-      }
+      if (c.type === 'tombstone') continue;
 
 
       if (c.type !== TYPE) 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 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) {
   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);
     return realErr.apply(console, args);
   };
   };
 })();
 })();
@@ -36,7 +61,7 @@ const Server = SecretStack({ caps })
   .use(require('ssb-search'))
   .use(require('ssb-search'))
   .use(require('ssb-private'))
   .use(require('ssb-private'))
   .use(require('ssb-friend-pub'))
   .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-logging'))
   .use(require('ssb-replication-scheduler'))
   .use(require('ssb-replication-scheduler'))
   .use(require('ssb-partial-replication'))
   .use(require('ssb-partial-replication'))
@@ -54,7 +79,15 @@ if (!config.pub) {
   Server.use(require('./lanRouter'));
   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'));
   Server.use(require('ssb-autofollow'));
 }
 }
 
 

+ 11 - 0
src/server/lanRouter.js

@@ -1,8 +1,17 @@
 const pull = require('./node_modules/pull-stream');
 const pull = require('./node_modules/pull-stream');
 const Ref = require('./node_modules/ssb-ref');
 const Ref = require('./node_modules/ssb-ref');
+const fs = require('fs');
+const path = require('path');
 
 
 const staged = new Set();
 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) {
 function stagePeer(ssb, address, key, eagerReplicate) {
   if (!address || !key || key === ssb.id) return;
   if (!address || !key || key === ssb.id) return;
   if (staged.has(address)) return;
   if (staged.has(address)) return;
@@ -45,6 +54,8 @@ function handleDiscovery(ssb, d, opts) {
 
 
 function startRouter(ssb, opts) {
 function startRouter(ssb, opts) {
   if (!ssb.lan || typeof ssb.lan.discoveredPeers !== 'function') return;
   if (!ssb.lan || typeof ssb.lan.discoveredPeers !== 'function') return;
+  const oasisCfg = readOasisConfig();
+  if (oasisCfg.lanBroadcasting === false) return;
   try { ssb.lan.start(); } catch (_) {}
   try { ssb.lan.start(); } catch (_) {}
   pull(
   pull(
     ssb.lan.discoveredPeers(),
     ssb.lan.discoveredPeers(),

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

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

+ 2 - 1
src/server/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@krakenslab/oasis",
   "name": "@krakenslab/oasis",
-  "version": "0.7.7",
+  "version": "0.7.8",
   "description": "Oasis - Social Networking Utopia",
   "description": "Oasis - Social Networking Utopia",
   "repository": {
   "repository": {
     "type": "git",
     "type": "git",
@@ -56,6 +56,7 @@
     "node-llama-cpp": "^3.10.0",
     "node-llama-cpp": "^3.10.0",
     "non-private-ip": "^2.2.0",
     "non-private-ip": "^2.2.0",
     "open": "^8.4.2",
     "open": "^8.4.2",
+    "openpgp": "^6.3.0",
     "packet-stream": "^2.0.6",
     "packet-stream": "^2.0.6",
     "packet-stream-codec": "^1.2.0",
     "packet-stream-codec": "^1.2.0",
     "pdfjs-dist": "^5.2.133",
     "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 { template, i18n } = require('./main_views');
 const { renderUrl } = require('../backend/renderUrl');
 const { renderUrl } = require('../backend/renderUrl');
 
 
@@ -112,21 +112,30 @@ exports.aiView = (history = [], userPrompt = '') => {
                   : null,
                   : null,
               entry.trainStatus === 'approved' || entry.trainStatus === 'rejected'
               entry.trainStatus === 'approved' || entry.trainStatus === 'rejected'
                 ? null
                 ? 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) }),
                       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 { 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 moment = require("../server/node_modules/moment");
 const { renderUrl } = require('../backend/renderUrl');
 const { renderUrl } = require('../backend/renderUrl');
 const { getConfig } = require("../configs/config-manager.js");
 const { getConfig } = require("../configs/config-manager.js");
@@ -214,7 +228,7 @@ function buildActivityItemsWithPostThreads(deduped, allActions) {
 }
 }
 
 
 exports.renderActionCards = renderActionCards;
 exports.renderActionCards = renderActionCards;
-function renderActionCards(actions, userId, allActions) {
+function renderActionCards(actions, userId, allActions, spreadMap = new Map()) {
   const all = Array.isArray(allActions) ? allActions : actions;
   const all = Array.isArray(allActions) ? allActions : actions;
   const byIdAll = new Map();
   const byIdAll = new Map();
   for (const a0 of all) {
   for (const a0 of all) {
@@ -505,7 +519,8 @@ function renderActionCards(actions, userId, allActions) {
     }
     }
 
 
     if (type === 'tribe') {
     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 : [];
       const validTags = Array.isArray(tags) ? tags : [];
       cardBody.push(
       cardBody.push(
         div({ class: 'card-section tribe' },
         div({ class: 'card-section tribe' },
@@ -515,11 +530,9 @@ function renderActionCards(actions, userId, allActions) {
            div({ style: 'display:flex; gap:.6em; flex-wrap:wrap;' },
            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))) : "",
             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)) : "",
             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')
             ? renderMediaBlob(image, '/assets/images/default-tribe.png')
             : img({ src: '/assets/images/default-tribe.png', class: 'feed-image tribe-image' }),
             : img({ src: '/assets/images/default-tribe.png', class: 'feed-image tribe-image' }),
           p({ class: 'tribe-description' }, ...renderUrl(description || '')),
           p({ class: 'tribe-description' }, ...renderUrl(description || '')),
@@ -1276,12 +1289,33 @@ function renderActionCards(actions, userId, allActions) {
     }
     }
       
       
     if (type === 'aiExchange') {
     if (type === 'aiExchange') {
-      const { ctx } = content;
+      const { ctx, lang, tags, rating } = content;
+      const helpful = Number(action.helpfulVotes || 0);
       cardBody.push(
       cardBody.push(
         div({ class: 'card-section ai-exchange' },
         div({ class: 'card-section ai-exchange' },
           Array.isArray(ctx) && ctx.length
           Array.isArray(ctx) && ctx.length
             ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.aiSnippetsLearned || 'Snippets learned') + ':'), span({ class: 'card-value' }, String(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 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' },
       p({ class: 'card-footer' },
         span({ class: 'date-link' }, `${date} ${i18n.performed} `),
         span({ class: 'date-link' }, `${date} ${i18n.performed} `),
         userLink(action.author)
         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 title = filter === 'mine' ? i18n.yourActivity : i18n.globalActivity;
   const desc = i18n.activityDesc;
   const desc = i18n.activityDesc;
 
 
@@ -1620,18 +1695,18 @@ exports.activityView = (actions, filter, userId, q = '') => {
     { type: 'all',       label: i18n.allButton },
     { type: 'all',       label: i18n.allButton },
     { type: 'mine',      label: i18n.mineButton },
     { type: 'mine',      label: i18n.mineButton },
     { type: 'report',    label: i18n.typeReport },
     { type: 'report',    label: i18n.typeReport },
-    { type: 'karmaScore',label: i18n.typeKarmaScore },
+    { type: 'aiExchange',label: i18n.typeAiExchange },
     { type: 'about',     label: i18n.typeAbout },
     { type: 'about',     label: i18n.typeAbout },
     { type: 'tribe',     label: i18n.typeTribe },
     { type: 'tribe',     label: i18n.typeTribe },
     { type: 'parliament',label: i18n.typeParliament },
     { type: 'parliament',label: i18n.typeParliament },
     { type: 'courts',    label: i18n.typeCourts },
     { type: 'courts',    label: i18n.typeCourts },
     { type: 'votes',     label: i18n.typeVotes },
     { type: 'votes',     label: i18n.typeVotes },
-    { type: 'calendar',  label: i18n.typeCalendar || 'Calendar' },
     { type: 'event',     label: i18n.typeEvent },
     { type: 'event',     label: i18n.typeEvent },
+    { type: 'calendar',  label: i18n.typeCalendar },
     { type: 'task',      label: i18n.typeTask },
     { type: 'task',      label: i18n.typeTask },
+    { type: 'gameScore', label: i18n.typeGameScore },
     { type: 'feed',      label: i18n.typeFeed },
     { type: 'feed',      label: i18n.typeFeed },
     { type: 'post',      label: i18n.typePost },
     { type: 'post',      label: i18n.typePost },
-    { type: 'spread',    label: i18n.typeSpread },
     { type: 'chat',      label: i18n.typeChat },
     { type: 'chat',      label: i18n.typeChat },
     { type: 'pad',       label: i18n.typePad },
     { type: 'pad',       label: i18n.typePad },
     { type: 'forum',     label: i18n.typeForum },
     { type: 'forum',     label: i18n.typeForum },
@@ -1640,20 +1715,20 @@ exports.activityView = (actions, filter, userId, q = '') => {
     { type: 'market',    label: i18n.typeMarket },
     { type: 'market',    label: i18n.typeMarket },
     { type: 'shop',      label: i18n.typeShop },
     { type: 'shop',      label: i18n.typeShop },
     { type: 'project',   label: i18n.typeProject },
     { type: 'project',   label: i18n.typeProject },
+    { type: 'transfer',  label: i18n.typeTransfer },
     { type: 'job',       label: i18n.typeJob },
     { type: 'job',       label: i18n.typeJob },
     { type: 'curriculum',label: i18n.typeCurriculum },
     { 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: 'audio',     label: i18n.typeAudio },
     { type: 'bookmark',  label: i18n.typeBookmark },
     { type: 'bookmark',  label: i18n.typeBookmark },
-    { type: 'image',     label: i18n.typeImage },
     { type: 'document',  label: i18n.typeDocument },
     { 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;
   let filteredActions;
   if (filter === 'mine') {
   if (filter === 'mine') {
     filteredActions = actions.filter(action => action.author === userId && action.type !== 'tombstone');
     filteredActions = actions.filter(action => action.author === userId && action.type !== 'tombstone');
@@ -1661,7 +1736,7 @@ exports.activityView = (actions, filter, userId, q = '') => {
     const now = Date.now();
     const now = Date.now();
     filteredActions = actions.filter(action => action.type !== 'tombstone' && action.ts && now - action.ts < 24 * 60 * 60 * 1000);
     filteredActions = actions.filter(action => action.type !== 'tombstone' && action.ts && now - action.ts < 24 * 60 * 60 * 1000);
   } else if (filter === 'banking') {
   } 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') {
   } else if (filter === 'tribe') {
     filteredActions = actions.filter(action => action.type === 'tribe');
     filteredActions = actions.filter(action => action.type === 'tribe');
   } else if (filter === 'parliament') {
   } else if (filter === 'parliament') {
@@ -1673,8 +1748,6 @@ exports.activityView = (actions, filter, userId, q = '') => {
     });
     });
   } else if (filter === 'task') {
   } else if (filter === 'task') {
     filteredActions = actions.filter(action => action.type !== 'tombstone' && (action.type === 'task' || action.type === 'taskAssignment'));
     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') {
   } else if (filter === 'gameScore') {
     filteredActions = actions.filter(action => action.type === 'gameScore');
     filteredActions = actions.filter(action => action.type === 'gameScore');
   } else if (filter === 'torrent') {
   } else if (filter === 'torrent') {
@@ -1738,12 +1811,12 @@ exports.activityView = (actions, filter, userId, q = '') => {
       ),
       ),
       div({ class: 'activity-filter-grid' },
       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 =>
         ].map(col =>
           div({ class: 'activity-filter-col' },
           div({ class: 'activity-filter-col' },
             col.map(({ type, label }) =>
             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()))
             sub.filters.map(f => a({ href: `${sub.url}?filter=${encodeURIComponent(f)}`, class: 'filter-btn' }, String(f).toUpperCase()))
           )
           )
         : null,
         : 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 = [
     details = [
       renderCardField(i18n.agendaAnonymousLabel + ":", item.isAnonymous ? i18n.agendaYes : i18n.agendaNo),
       renderCardField(i18n.agendaAnonymousLabel + ":", item.isAnonymous ? i18n.agendaYes : i18n.agendaNo),
       renderCardField(i18n.agendaInviteModeLabel + ":", (item.inviteMode ? String(item.inviteMode).toUpperCase() : i18n.noInviteMode)),
       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.agendaLocationLabel + ":", item.location || i18n.noLocation),
       renderCardField(i18n.agendaMembersCount + ":", Array.isArray(item.members) ? item.members.length : 0),
       renderCardField(i18n.agendaMembersCount + ":", Array.isArray(item.members) ? item.members.length : 0),
       br()
       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
     ...commonFields
   );
   );
 };
 };

+ 171 - 69
src/views/audio_view.js

@@ -16,7 +16,7 @@ const {
   option
   option
 } = require("../server/node_modules/hyperaxe");
 } = 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 moment = require("../server/node_modules/moment");
 const { config } = require("../server/SSB_server.js");
 const { config } = require("../server/SSB_server.js");
 const { renderUrl } = require("../backend/renderUrl")
 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
   audioObj?.url
     ? div(
     ? 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);
     : p(i18n.audioNoFile);
 
 
@@ -198,6 +207,7 @@ const renderAudioList = exports.renderAudioList = (audios, filter, params = {})
             ownerActions.length ? div({ class: "bookmark-actions" }, ...ownerActions) : null
             ownerActions.length ? div({ class: "bookmark-actions" }, ...ownerActions) : null
           ),
           ),
           title ? h2(title) : null,
           title ? h2(title) : null,
+          audioObj.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(audioObj.lifetime, i18n)) : null,
           renderAudioPlayer(audioObj),
           renderAudioPlayer(audioObj),
           div(
           div(
             { class: "card-comments-summary" },
             { class: "card-comments-summary" },
@@ -214,6 +224,7 @@ const renderAudioList = exports.renderAudioList = (audios, filter, params = {})
               button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
               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),
           renderMapLocationVisitLabel(audioObj.mapUrl),
           br(),
           br(),
           (() => {
           (() => {
@@ -308,13 +319,14 @@ exports.audioView = async (audios, filter = "all", audioId = null, params = {})
     section(
     section(
       div({ class: "tags-header" },
       div({ class: "tags-header" },
         h2(title),
         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(
       div(
         { class: "filters" },
         { class: "filters" },
         form(
         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: "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: "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: "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(
           button(
             { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
             { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
             i18n.audioFilterFavorites
             i18n.audioFilterFavorites
@@ -374,25 +386,93 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params
   const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort });
   const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort });
 
 
   const title = safeText(audioObj.title);
   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 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(
   return template(
     i18n.audioTitle,
     i18n.audioTitle,
     section(
     section(
+      div({ class: "tags-header" },
+        h2(i18n.audioAllSectionTitle || i18n.audioTitle),
+        p(i18n.audioDescription)
+      ),
       div(
       div(
         { class: "filters" },
         { class: "filters" },
         form(
         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: "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: "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: "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(
           button(
             { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
             { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
             i18n.audioFilterFavorites
             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)
           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(),
         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(),
         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;
+

File diff suppressed because it is too large
+ 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 { 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 moment = require("../server/node_modules/moment");
 
 
 const FILTER_LABELS = {
 const FILTER_LABELS = {
@@ -299,7 +299,7 @@ const renderBlockDiagram = (blocks, qs) => {
 };
 };
 
 
 const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, viewMode = 'block', restricted = false) => {
 const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, viewMode = 'block', restricted = false) => {
-  if (!block) {
+  if (!block || block.notAvailable) {
     return template(
     return template(
       i18n.blockchain,
       i18n.blockchain,
       section(
       section(
@@ -307,7 +307,16 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, vi
           h2(i18n.blockchain),
           h2(i18n.blockchain),
           p(i18n.blockchainDescription)
           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)
             span({ class: 'blockchain-card-value' }, block.id)
           ),
           ),
           div({ class: 'block-row block-row--meta' },
           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-label' }, `${i18n.blockchainBlockType}:`),
             span({ class: 'blockchain-card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())
             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' },
         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)
               span({ class: 'blockchain-card-value' }, block.id)
             ),
             ),
             div({ class: 'block-row block-row--meta' },
             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-label' }, `${i18n.blockchainBlockType}:`),
               span({ class: 'blockchain-card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())
               span({ class: 'blockchain-card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())
             ),
             ),
             div({ class: 'block-row block-row--meta' },
             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)
               a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author)
             )
             )
           ),
           ),
           div({ class:'block-row block-row--content' },
           div({ class:'block-row block-row--content' },
             div({ class:'block-content-preview' },
             div({ class:'block-content-preview' },
               block.content && typeof block.content.encryptedPayload === 'string'
               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))
                 : 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 s = search || {};
   const authorVal = String(s.author || '');
   const authorVal = String(s.author || '');
   const idVal = String(s.id || '');
   const idVal = String(s.id || '');
   const fromVal = toDatetimeLocal(s.from);
   const fromVal = toDatetimeLocal(s.from);
   const toVal = toDatetimeLocal(s.to);
   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 shown = filterBlocks(blocks, filter, userId);
   const qs = toQueryString(filter, s);
   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),
       renderBlockDiagram(shown, qs),
       h2({ class: 'block-diagram-title' }, 'Blockchain Blocks'),
       h2({ class: 'block-diagram-title' }, 'Blockchain Blocks'),
       shown.length === 0
       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 } =
 const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option } =
   require("../server/node_modules/hyperaxe");
   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 moment = require("../server/node_modules/moment");
 const { config } = require("../server/SSB_server.js");
 const { config } = require("../server/SSB_server.js");
 const { renderUrl } = require("../backend/renderUrl");
 const { renderUrl } = require("../backend/renderUrl");
@@ -196,9 +196,9 @@ const renderBookmarkList = (filteredBookmarks, filter, params = {}) => {
             renderBookmarkActions(filter, bookmark, params)
             renderBookmarkActions(filter, bookmark, params)
           ),
           ),
           h2({ class: "bookmark-title" }, bookmark.category || bookmark.url || ""),
           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.bookmarkUrlLabel + ":", urlLink),
           renderCardField(i18n.bookmarkLastVisitLabel + ":", lastVisitTxt),
           renderCardField(i18n.bookmarkLastVisitLabel + ":", lastVisitTxt),
-          renderCardField(i18n.bookmarkCategoryLabel + ":", safeText(bookmark.category) || i18n.noCategory),
           br,
           br,
           div(
           div(
             { class: "card-comments-summary" },
             { class: "card-comments-summary" },
@@ -215,6 +215,7 @@ const renderBookmarkList = (filteredBookmarks, filter, params = {}) => {
               button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
               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 createdTs = bookmark.createdAt ? new Date(bookmark.createdAt).getTime() : NaN;
             const updatedTs = bookmark.updatedAt ? new Date(bookmark.updatedAt).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,
     title,
     section(
     section(
       div({ class: "tags-header" }, h2(title), p(i18n.bookmarkDescription)),
       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(
       div(
         { class: "filters" },
         { class: "filters" },
         form(
         form(
@@ -385,6 +392,8 @@ exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], par
 
 
   const isAuthor = String(bookmark.author) === String(userId);
   const isAuthor = String(bookmark.author) === String(userId);
   const hasOpinions = Object.keys(bookmark.opinions || {}).length > 0;
   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 lastVisit = bookmark.lastVisit ? moment(bookmark.lastVisit) : null;
   const lastVisitTxt =
   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)
     ? a({ href: bookmark.url, target: "_blank", rel: "noreferrer noopener", class: "bookmark-url" }, bookmark.url)
     : i18n.noUrl;
     : 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(
   return template(
     i18n.bookmarkTitle,
     i18n.bookmarkTitle,
     section(
     section(
+      div({ class: "tags-header" },
+        h2(i18n.bookmarkAllSectionTitle || i18n.bookmarkTitle),
+        p(i18n.bookmarkDescription)
+      ),
       div(
       div(
         { class: "filters" },
         { class: "filters" },
         form(
         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)
           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 { 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 moment = require("../server/node_modules/moment")
 const { config } = require("../server/SSB_server.js")
 const { config } = require("../server/SSB_server.js")
 
 
@@ -46,21 +47,35 @@ const renderStatus = (cal) => {
   return span({ class: "pad-status-open" }, i18n.calendarStatusOpen || "OPEN")
   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 href = `/calendars/${encodeURIComponent(cal.rootId)}`
+  const chips = [
+    renderCalendarStatusChip(cal),
+    renderEncryptedChip(i18n),
+    renderLifespanChip(cal.lifetime, i18n)
+  ].filter(Boolean)
   return div({ class: "tribe-card" },
   return div({ class: "tribe-card" },
     div({ class: "tribe-card-body" },
     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" },
       div({ class: "tribe-card-members" },
         span({ class: "tribe-members-count calendar-participants-count" }, `${i18n.calendarParticipantsLabel || "Participants"}: ${cal.participants.length}`)
         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")
         a({ href, class: "filter-btn" }, i18n.calendarVisitCalendar || "Visit Calendar")
       )
       )
     )
     )
@@ -210,7 +225,7 @@ exports.calendarsView = async (calendars, filter, calendarToEdit, params) => {
       showForm
       showForm
         ? renderCreateForm(calendarToEdit, params)
         ? renderCreateForm(calendarToEdit, params)
         : (calendars.length > 0
         : (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."))
             : 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} `)))
     ? div({ class: "tribe-side-tags" }, ...calendar.tags.map(t => a({ href: `/search?query=%23${encodeURIComponent(t)}` }, `#${t} `)))
     : null
     : null
 
 
+  const detailChips = [
+    renderCalendarStatusChip(calendar),
+    renderEncryptedChip(i18n),
+    renderLifespanChip(calendar.lifetime, i18n)
+  ].filter(Boolean)
   const calSide = div({ class: "tribe-side" },
   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" },
     div({ class: "shop-share" },
       span({ class: "tribe-info-label" }, i18n.calendarsShareUrl || "Share URL"),
       span({ class: "tribe-info-label" }, i18n.calendarsShareUrl || "Share URL"),
       input({ type: "text", readonly: true, value: shareUrl, class: "shop-share-input" })
       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" },
     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-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-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
       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" },
     div({ class: "tribe-side-actions" },
@@ -295,7 +316,10 @@ exports.singleCalendarView = async (calendar, dates, notesByDate, params) => {
           )
           )
         : null
         : 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")
   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 { 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 moment = require("../server/node_modules/moment")
 const { config } = require("../server/SSB_server.js")
 const { config } = require("../server/SSB_server.js")
 const { renderUrl } = require("../backend/renderUrl")
 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 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" },
   return div({ class: "tribe-card" },
     div({ class: "tribe-card-image-wrapper" },
     div({ class: "tribe-card-image-wrapper" },
@@ -59,20 +73,20 @@ const renderChatCard = (chat, filter, params = {}) => {
       )
       )
     ),
     ),
     div({ class: "tribe-card-body" },
     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" },
       div({ class: "tribe-card-members" },
         span({ class: "tribe-members-count" }, `${i18n.chatParticipants}: ${safeArr(chat.members).length}`)
         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" },
       div({ class: "visit-btn-centered" },
         a({ href: `/chats/${encodeURIComponent(chat.key)}`, class: "filter-btn" }, i18n.chatVisitChat)
         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 :
   const statusLabel = chat.status === "CLOSED" ? i18n.chatStatusClosed :
     chat.status === "INVITE-ONLY" ? i18n.chatStatusInviteOnly : i18n.chatStatusOpen
     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" },
   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" }),
     renderMediaBlob(chat.image, "/assets/images/default-avatar.png", { class: "tribe-detail-image" }),
     div({ class: "shop-share" },
     div({ class: "shop-share" },
       span({ class: "tribe-info-label" }, `${i18n.chatShareUrl}: `),
       span({ class: "tribe-info-label" }, `${i18n.chatShareUrl}: `),
       input({ type: "text", value: fullShareUrl, readonly: true, class: "shop-share-input" })
       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" },
     div({ class: "tribe-card-members" },
       span({ class: "tribe-members-count" }, `${i18n.chatParticipants}: ${safeArr(chat.members).length}`)
       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)
           userLink(chat.author)
         )
         )
       ),
       ),
-      tr(
-        td({ class: "tribe-info-label" }, i18n.chatStatus),
-        td({ class: "tribe-info-value", colspan: "3" }, statusLabel)
-      ),
       !isRestrictedInviteOnly && chat.category ? tr(
       !isRestrictedInviteOnly && chat.category ? tr(
         td({ class: "tribe-info-label" }, i18n.chatCategoryLabel),
         td({ class: "tribe-info-label" }, i18n.chatCategoryLabel),
         td({ class: "tribe-info-value", colspan: "3" }, catLabel(chat.category))
         td({ class: "tribe-info-value", colspan: "3" }, catLabel(chat.category))

+ 0 - 0
src/views/clearnet_view.js


Some files were not shown because too many files changed in this diff