psy пре 17 часа
родитељ
комит
4f3edce089
47 измењених фајлова са 1922 додато и 542 уклоњено
  1. 18 1
      docs/CHANGELOG.md
  2. 1 1
      docs/devs/install.md
  3. 160 27
      src/backend/backend.js
  4. 11 9
      src/client/assets/styles/style.css
  5. 13 2
      src/client/assets/translations/oasis_ar.js
  6. 13 2
      src/client/assets/translations/oasis_de.js
  7. 13 2
      src/client/assets/translations/oasis_en.js
  8. 13 2
      src/client/assets/translations/oasis_es.js
  9. 13 2
      src/client/assets/translations/oasis_eu.js
  10. 13 3
      src/client/assets/translations/oasis_fr.js
  11. 13 2
      src/client/assets/translations/oasis_hi.js
  12. 13 2
      src/client/assets/translations/oasis_it.js
  13. 13 2
      src/client/assets/translations/oasis_pt.js
  14. 13 2
      src/client/assets/translations/oasis_ru.js
  15. 13 2
      src/client/assets/translations/oasis_zh.js
  16. 3 3
      src/client/gui.js
  17. 1 0
      src/configs/media-favorites.json
  18. 351 0
      src/games/neoninfiltrator/index.html
  19. 73 0
      src/games/neoninfiltrator/thumbnail.svg
  20. 182 0
      src/games/rockpaperscissors/index.html
  21. 17 0
      src/games/rockpaperscissors/thumbnail.svg
  22. 144 136
      src/games/tiktaktoe/index.html
  23. 15 24
      src/games/tiktaktoe/thumbnail.svg
  24. 4 1
      src/models/activity_model.js
  25. 4 1
      src/models/chats_model.js
  26. 183 32
      src/models/courts_model.js
  27. 1 2
      src/models/feed_model.js
  28. 1 1
      src/models/games_model.js
  29. 11 3
      src/models/jobs_model.js
  30. 11 4
      src/models/market_model.js
  31. 1 0
      src/models/pm_model.js
  32. 10 3
      src/models/shops_model.js
  33. 19 3
      src/models/tags_model.js
  34. 15 0
      src/server/nodemon.json
  35. 328 147
      src/server/package-lock.json
  36. 6 1
      src/server/package.json
  37. 11 2
      src/server/ssb_metadata.js
  38. 32 14
      src/views/blockchain_view.js
  39. 29 23
      src/views/chats_view.js
  40. 1 4
      src/views/cipher_view.js
  41. 27 14
      src/views/event_view.js
  42. 3 3
      src/views/feed_view.js
  43. 3 1
      src/views/games_view.js
  44. 15 0
      src/views/main_views.js
  45. 42 40
      src/views/pads_view.js
  46. 16 2
      src/views/search_view.js
  47. 30 17
      src/views/task_view.js

+ 18 - 1
docs/CHANGELOG.md

@@ -13,7 +13,24 @@ All notable changes to this project will be documented in this file.
 ### Security
 ### Security
 -->
 -->
 
 
-## v0.7.0 - 2026-04-12
+## 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
 ### Added
 
 

+ 1 - 1
docs/devs/install.md

@@ -12,5 +12,5 @@ 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). 
 
 
-While the server processes are running, they will restart theirselves automatically every time you save changes in any file into `/src`. Page autoreload feature is not available even for the development environment because we avoid using JavaScript in the browser, so your browser will remain untouched. Reload the page manually to display the 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.
 
 

+ 160 - 27
src/backend/backend.js

@@ -139,6 +139,12 @@ const checkMod = (ctx, mod) => {
   return serverValue === 'on' || serverValue === undefined;
   return serverValue === 'on' || serverValue === undefined;
 };
 };
 const getViewerId = () => SSBconfig?.config?.keys?.id || SSBconfig?.keys?.id;
 const getViewerId = () => SSBconfig?.config?.keys?.id || SSBconfig?.keys?.id;
+const getUserTribeIds = async (uid) => {
+  const allTribes = await tribesModel.listAll().catch(() => []);
+  const memberTribes = allTribes.filter(t => t.members.includes(uid));
+  const idSets = await Promise.all(memberTribes.map(t => tribesModel.getChainIds(t.id).catch(() => [t.id])));
+  return new Set(idSets.flat());
+};
 const refreshInboxCount = async (messagesOpt) => {
 const refreshInboxCount = async (messagesOpt) => {
   const messages = messagesOpt || await pmModel.listAllPrivate();
   const messages = messagesOpt || await pmModel.listAllPrivate();
   const userId = getViewerId();
   const userId = getViewerId();
@@ -233,7 +239,7 @@ const extractMentions = async (text) => {
   }));
   }));
   return resolvedMentions;
   return resolvedMentions;
 };
 };
-const cooler = ssb({ offline: config.offline });
+const cooler = ssb({ offline: config.offline, port: config.port, host: config.host, isPublic: config.public });
 const models = require("../models/main_models");
 const models = require("../models/main_models");
 const { about, blob, friend, meta, post, vote } = models({
 const { about, blob, friend, meta, post, vote } = models({
   cooler,
   cooler,
@@ -258,7 +264,6 @@ const reportsModel = require('../models/reports_model')({ cooler, isPublic: conf
 const transfersModel = require('../models/transfers_model')({ cooler, isPublic: config.public });
 const transfersModel = require('../models/transfers_model')({ cooler, isPublic: config.public });
 const padsModel = require('../models/pads_model')({ cooler, cipherModel });
 const padsModel = require('../models/pads_model')({ cooler, cipherModel });
 const calendarsModel = require('../models/calendars_model')({ cooler, pmModel });
 const calendarsModel = require('../models/calendars_model')({ cooler, pmModel });
-const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public, padsModel });
 const cvModel = require('../models/cv_model')({ cooler, isPublic: config.public });
 const cvModel = require('../models/cv_model')({ cooler, isPublic: config.public });
 const inhabitantsModel = require('../models/inhabitants_model')({ cooler, isPublic: config.public });
 const inhabitantsModel = require('../models/inhabitants_model')({ cooler, isPublic: config.public });
 const feedModel = require('../models/feed_model')({ cooler, isPublic: config.public });
 const feedModel = require('../models/feed_model')({ cooler, isPublic: config.public });
@@ -270,15 +275,16 @@ const agendaModel = require("../models/agenda_model")({ cooler, isPublic: config
 const trendingModel = require('../models/trending_model')({ cooler, isPublic: config.public });
 const trendingModel = require('../models/trending_model')({ cooler, isPublic: config.public });
 const statsModel = require('../models/stats_model')({ cooler, isPublic: config.public });
 const statsModel = require('../models/stats_model')({ cooler, isPublic: config.public });
 const tribesModel = require('../models/tribes_model')({ cooler, isPublic: config.public, tribeCrypto });
 const tribesModel = require('../models/tribes_model')({ cooler, isPublic: config.public, tribeCrypto });
+const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public, padsModel, tribesModel });
 const tribesContentModel = require('../models/tribes_content_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel });
 const tribesContentModel = require('../models/tribes_content_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel });
 const searchModel = require('../models/search_model')({ cooler, isPublic: config.public, padsModel });
 const searchModel = require('../models/search_model')({ cooler, isPublic: config.public, padsModel });
 const activityModel = require('../models/activity_model')({ cooler, isPublic: config.public });
 const activityModel = require('../models/activity_model')({ cooler, isPublic: config.public });
 const pixeliaModel = require('../models/pixelia_model')({ cooler, isPublic: config.public });
 const pixeliaModel = require('../models/pixelia_model')({ cooler, isPublic: config.public });
-const marketModel = require('../models/market_model')({ cooler, isPublic: config.public });
+const marketModel = require('../models/market_model')({ cooler, isPublic: config.public, tribeCrypto });
 const forumModel = require('../models/forum_model')({ cooler, isPublic: config.public });
 const forumModel = require('../models/forum_model')({ cooler, isPublic: config.public });
 const blockchainModel = require('../models/blockchain_model')({ cooler, isPublic: config.public });
 const blockchainModel = require('../models/blockchain_model')({ cooler, isPublic: config.public });
-const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.public });
-const shopsModel = require('../models/shops_model')({ cooler, isPublic: config.public });
+const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.public, tribeCrypto });
+const shopsModel = require('../models/shops_model')({ cooler, isPublic: config.public, tribeCrypto });
 const chatsModel = require('../models/chats_model')({ cooler, tribeCrypto });
 const chatsModel = require('../models/chats_model')({ cooler, tribeCrypto });
 const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public });
 const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public });
 const mapsModel = require("../models/maps_model")({ cooler, isPublic: config.public });
 const mapsModel = require("../models/maps_model")({ cooler, isPublic: config.public });
@@ -286,10 +292,13 @@ const gamesModel = require('../models/games_model')({ cooler });
 const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public });
 const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public });
 const favoritesModel = require("../models/favorites_model")({ services: { cooler }, audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel });
 const favoritesModel = require("../models/favorites_model")({ services: { cooler }, audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel });
 const parliamentModel = require('../models/parliament_model')({ cooler, services: { tribes: tribesModel, votes: votesModel, inhabitants: inhabitantsModel, banking: bankingModel } });
 const parliamentModel = require('../models/parliament_model')({ cooler, services: { tribes: tribesModel, votes: votesModel, inhabitants: inhabitantsModel, banking: bankingModel } });
-const courtsModel = require('../models/courts_model')({ cooler, services: { votes: votesModel, inhabitants: inhabitantsModel, tribes: tribesModel, banking: bankingModel } });
+const courtsModel = require('../models/courts_model')({ cooler, services: { votes: votesModel, inhabitants: inhabitantsModel, tribes: tribesModel, banking: bankingModel }, tribeCrypto });
 tribesModel.processIncomingKeys().catch(err => {
 tribesModel.processIncomingKeys().catch(err => {
   if (config.debug) console.error('tribe-keys scan error:', err.message);
   if (config.debug) console.error('tribe-keys scan error:', err.message);
 });
 });
+courtsModel.processIncomingCourtsKeys().catch(err => {
+  if (config.debug) console.error('courts-keys scan error:', err.message);
+});
 const getVoteComments = async (voteId) => {
 const getVoteComments = async (voteId) => {
   const raw = await post.topicComments(voteId);
   const raw = await post.topicComments(voteId);
   return (raw || []).filter(c => c?.value?.content?.type === 'post' && c.value.content.root === voteId)
   return (raw || []).filter(c => c?.value?.content?.type === 'post' && c.value.content.root === voteId)
@@ -645,7 +654,7 @@ const resolveCommentComponents = async function (ctx) {
   }
   }
   return { messages, myFeedId, parentMessage, contentWarning };
   return { messages, myFeedId, parentMessage, contentWarning };
 };
 };
-const { authorView, previewCommentView, commentView, editProfileView, extendedView, latestView, likesView, threadView, hashtagView, mentionsView, popularView, previewView, privateView, publishCustomView, publishView, previewSubtopicView, subtopicView, imageSearchView, setLanguage, topicsView, summaryView, threadsView } = require("../views/main_views");
+const { authorView, previewCommentView, commentView, editProfileView, extendedView, latestView, likesView, threadView, hashtagView, mentionsView, popularView, previewView, privateView, publishCustomView, publishView, previewSubtopicView, subtopicView, imageSearchView, setLanguage, topicsView, summaryView, threadsView, tribeAccessDeniedView } = require("../views/main_views");
 const { activityView } = require("../views/activity_view");
 const { activityView } = require("../views/activity_view");
 const { cvView, createCVView } = require("../views/cv_view");
 const { cvView, createCVView } = require("../views/cv_view");
 const { indexingView } = require("../views/indexing_view");
 const { indexingView } = require("../views/indexing_view");
@@ -685,8 +694,8 @@ const { forumView, singleForumView } = require("../views/forum_view");
 const { renderBlockchainView, renderSingleBlockView } = require("../views/blockchain_view");
 const { renderBlockchainView, renderSingleBlockView } = require("../views/blockchain_view");
 const { jobsView, singleJobsView, renderJobForm } = require("../views/jobs_view");
 const { jobsView, singleJobsView, renderJobForm } = require("../views/jobs_view");
 const { shopsView, singleShopView, singleProductView, editProductView } = require("../views/shops_view");
 const { shopsView, singleShopView, singleProductView, editProductView } = require("../views/shops_view");
-const { chatsView, singleChatView } = require("../views/chats_view");
-const { padsView, singlePadView } = require("../views/pads_view");
+const { chatsView, singleChatView, renderChatInvitePage } = require("../views/chats_view");
+const { padsView, singlePadView, renderPadInvitePage } = require("../views/pads_view");
 const { calendarsView, singleCalendarView } = require("../views/calendars_view");
 const { calendarsView, singleCalendarView } = require("../views/calendars_view");
 const { projectsView, singleProjectView } = require("../views/projects_view")
 const { projectsView, singleProjectView } = require("../views/projects_view")
 const { renderBankingView, renderSingleAllocationView, renderEpochView } = require("../views/banking_views")
 const { renderBankingView, renderSingleAllocationView, renderEpochView } = require("../views/banking_views")
@@ -822,6 +831,16 @@ router
     let filter = query.filter || 'recent';
     let filter = query.filter || 'recent';
     if (searchActive && String(filter).toLowerCase() === 'recent') filter = 'all';
     if (searchActive && String(filter).toLowerCase() === 'recent') filter = 'all';
     const blockchainData = await blockchainModel.listBlockchain(filter, userId, search);
     const blockchainData = await blockchainModel.listBlockchain(filter, userId, search);
+    const allTribesList = await tribesModel.listAll().catch(() => []);
+    const anonTribeSet = new Set(allTribesList.filter(tr => tr.isAnonymous === true).map(tr => tr.id));
+    for (const block of blockchainData) {
+      const c = block.content || {};
+      const t = c.type || block.type || '';
+      const isPrivate = String(c.isPublic || '').toLowerCase() === 'private';
+      block.restricted = t === 'tribe' || t.startsWith('courts') || t === 'job' || t === 'job_sub' ||
+        c.status === 'INVITE-ONLY' || c.status === 'PRIVATE' ||
+        (c.tribeId && anonTribeSet.has(c.tribeId)) || isPrivate;
+    }
     ctx.body = renderBlockchainView(blockchainData, filter, userId, search);
     ctx.body = renderBlockchainView(blockchainData, filter, userId, search);
   })
   })
   .get('/blockexplorer/block/:id', async (ctx) => {
   .get('/blockexplorer/block/:id', async (ctx) => {
@@ -839,7 +858,21 @@ router
     const blockId = ctx.params.id;
     const blockId = ctx.params.id;
     const block = await blockchainModel.getBlockById(blockId);
     const block = await blockchainModel.getBlockById(blockId);
     const viewMode = query.view || 'block';
     const viewMode = query.view || 'block';
-    ctx.body = renderSingleBlockView(block, filter, userId, search, viewMode);
+    let restricted = false;
+    if (block) {
+      const c = block.value?.content || {};
+      const t = c.type || '';
+      const allTribes = await tribesModel.listAll().catch(() => []);
+      const anonTribeIds = new Set(allTribes.filter(tr => tr.isAnonymous === true).map(tr => tr.id));
+      const isPrivate = String(c.isPublic || '').toLowerCase() === 'private';
+      restricted = t === 'tribe' ||
+        t.startsWith('courts') ||
+        t === 'job' || t === 'job_sub' ||
+        c.status === 'INVITE-ONLY' || c.status === 'PRIVATE' ||
+        (c.tribeId && anonTribeIds.has(c.tribeId)) ||
+        isPrivate;
+    }
+    ctx.body = renderSingleBlockView(block, filter, userId, search, viewMode, restricted);
   })
   })
   .get("/public/latest", async (ctx) => {
   .get("/public/latest", async (ctx) => {
     if (!checkMod(ctx, 'latestMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'latestMod')) { ctx.redirect('/modules'); return; }
@@ -888,9 +921,23 @@ router
   .get("/search", async (ctx) => {
   .get("/search", async (ctx) => {
     const query = ctx.query.query || '';
     const query = ctx.query.query || '';
     if (!query) return ctx.body = await searchView({ messages: [], query, types: [] });
     if (!query) return ctx.body = await searchView({ messages: [], query, types: [] });
+    const userId = getViewerId();
+    const allTribes = await tribesModel.listAll();
+    const anonTribeIds = new Set(allTribes.filter(t => t.isAnonymous === true).map(t => t.id));
+    const applySearchPrivacy = (msgs) => msgs.filter(msg => {
+      const c = msg.value?.content;
+      if (!c) return true;
+      if (c.tribeId && anonTribeIds.has(c.tribeId)) return false;
+      if (c.type === 'event' && c.isPublic === 'private' && c.organizer !== userId && !(Array.isArray(c.attendees) && c.attendees.includes(userId))) return false;
+      if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE' && c.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false;
+      if (c.status === 'PRIVATE') return false;
+      if (c.type === 'shop' && c.visibility === 'CLOSED' && c.author !== userId) return false;
+      return true;
+    });
     const results = await searchModel.search({ query, types: [] });
     const results = await searchModel.search({ query, types: [] });
     ctx.body = await searchView({ results: Object.entries(results).reduce((acc, [type, msgs]) => {
     ctx.body = await searchView({ results: Object.entries(results).reduce((acc, [type, msgs]) => {
-      acc[type] = msgs.map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' });
+      const filtered = applySearchPrivacy(msgs).map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' });
+      if (filtered.length > 0) acc[type] = filtered;
       return acc;
       return acc;
     }, {}), query, types: [] });
     }, {}), query, types: [] });
   })
   })
@@ -921,10 +968,13 @@ router
   .get("/maps", async (ctx) => {
   .get("/maps", async (ctx) => {
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
     const { filter = 'all', q = '', lat, lng, zoom, tribeId, title, description, markerLabel, tags, mapType } = ctx.query;
     const { filter = 'all', q = '', lat, lng, zoom, tribeId, title, description, markerLabel, tags, mapType } = ctx.query;
-    const items = await mapsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, viewerId: getViewerId() });
+    const uid = getViewerId();
+    const items = await mapsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, viewerId: uid });
     const fav = await mediaFavorites.getFavoriteSet('maps');
     const fav = await mediaFavorites.getFavoriteSet('maps');
     let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
     let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
     if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
     if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
+    const myTribeIds = await getUserTribeIds(uid);
+    enriched = enriched.filter(x => !x.tribeId || myTribeIds.has(x.tribeId));
     try {
     try {
       ctx.body = await mapsView(enriched, filter, null, { q, lat, lng, zoom, title, description, markerLabel, tags, mapType, ...(tribeId ? { tribeId } : {}) });
       ctx.body = await mapsView(enriched, filter, null, { q, lat, lng, zoom, title, description, markerLabel, tags, mapType, ...(tribeId ? { tribeId } : {}) });
     } catch (e) {
     } catch (e) {
@@ -941,11 +991,16 @@ router
   .get("/maps/:mapId", async (ctx) => {
   .get("/maps/:mapId", async (ctx) => {
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
     const { mapId } = ctx.params; const { filter = 'all', q = '', zoom = '0', mkLat = '', mkLng = '', label: mkMarkerLabel = '' } = ctx.query;
     const { mapId } = ctx.params; const { filter = 'all', q = '', zoom = '0', mkLat = '', mkLng = '', label: mkMarkerLabel = '' } = ctx.query;
-    const mapItem = await mapsModel.getMapById(mapId, getViewerId());
+    const uid = getViewerId();
+    const mapItem = await mapsModel.getMapById(mapId, uid);
     const fav = await mediaFavorites.getFavoriteSet('maps');
     const fav = await mediaFavorites.getFavoriteSet('maps');
     let tribeMembers = [];
     let tribeMembers = [];
     if (mapItem.tribeId) {
     if (mapItem.tribeId) {
-      try { const t = await tribesModel.getTribeById(mapItem.tribeId); tribeMembers = t.members || []; } catch {}
+      try {
+        const t = await tribesModel.getTribeById(mapItem.tribeId);
+        if (!t.members.includes(uid)) { ctx.body = tribeAccessDeniedView(t); return; }
+        tribeMembers = t.members;
+      } catch { ctx.redirect('/tribes'); return; }
     }
     }
     ctx.body = await singleMapView({ ...mapItem, isFavorite: fav.has(String(mapItem.rootId || mapItem.key)) }, filter, { q, zoom, mkLat, mkLng, mkMarkerLabel, tribeMembers, returnTo: safeReturnTo(ctx, `/maps?filter=${encodeURIComponent(filter)}`, ['/maps']) });
     ctx.body = await singleMapView({ ...mapItem, isFavorite: fav.has(String(mapItem.rootId || mapItem.key)) }, filter, { q, zoom, mkLat, mkLng, mkMarkerLabel, tribeMembers, returnTo: safeReturnTo(ctx, `/maps?filter=${encodeURIComponent(filter)}`, ['/maps']) });
   })
   })
@@ -1840,7 +1895,8 @@ router
     const modelFilter = filter === "favorites" ? "all" : filter;
     const modelFilter = filter === "favorites" ? "all" : filter;
     const items = await chatsModel.listAll({ filter: modelFilter, q, viewerId });
     const items = await chatsModel.listAll({ filter: modelFilter, q, viewerId });
     const fav = await mediaFavorites.getFavoriteSet('chats');
     const fav = await mediaFavorites.getFavoriteSet('chats');
-    const enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
+    const myTribeIds = await getUserTribeIds(viewerId);
+    const enriched = items.filter(x => !x.tribeId || myTribeIds.has(x.tribeId)).map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
     const finalList = filter === "favorites" ? enriched.filter(x => x.isFavorite) : enriched;
     const finalList = filter === "favorites" ? enriched.filter(x => x.isFavorite) : enriched;
     ctx.body = await chatsView(finalList, filter, null, { q });
     ctx.body = await chatsView(finalList, filter, null, { q });
   })
   })
@@ -1853,8 +1909,15 @@ router
   .get("/chats/:chatId", async (ctx) => {
   .get("/chats/:chatId", async (ctx) => {
     if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
     const { filter = 'all', q = '' } = ctx.query;
     const { filter = 'all', q = '' } = ctx.query;
+    const uid = getViewerId();
     const chat = await chatsModel.getChatById(ctx.params.chatId);
     const chat = await chatsModel.getChatById(ctx.params.chatId);
     if (!chat) { ctx.redirect('/chats'); return; }
     if (!chat) { ctx.redirect('/chats'); return; }
+    if (chat.tribeId) {
+      try {
+        const t = await tribesModel.getTribeById(chat.tribeId);
+        if (!t.members.includes(uid)) { ctx.body = tribeAccessDeniedView(t); return; }
+      } catch { ctx.redirect('/tribes'); return; }
+    }
     const fav = await mediaFavorites.getFavoriteSet('chats');
     const fav = await mediaFavorites.getFavoriteSet('chats');
     const messages = await chatsModel.listMessages(chat.rootId || chat.key);
     const messages = await chatsModel.listMessages(chat.rootId || chat.key);
     ctx.body = await singleChatView({ ...chat, isFavorite: fav.has(String(chat.rootId || chat.key)) }, filter, messages, { q, returnTo: safeReturnTo(ctx, `/chats?filter=${encodeURIComponent(filter)}`, ['/chats']) });
     ctx.body = await singleChatView({ ...chat, isFavorite: fav.has(String(chat.rootId || chat.key)) }, filter, messages, { q, returnTo: safeReturnTo(ctx, `/chats?filter=${encodeURIComponent(filter)}`, ['/chats']) });
@@ -1875,22 +1938,29 @@ router
     const tribeId = ctx.query.tribeId || "";
     const tribeId = ctx.query.tribeId || "";
     const pads = await padsModel.listAll({ filter, viewerId: uid });
     const pads = await padsModel.listAll({ filter, viewerId: uid });
     const fav = await mediaFavorites.getFavoriteSet('pads');
     const fav = await mediaFavorites.getFavoriteSet('pads');
-    const enriched = pads.map(p => ({ ...p, isFavorite: fav.has(String(p.rootId)) }));
+    const myTribeIds = await getUserTribeIds(uid);
+    const enriched = pads.filter(p => !p.tribeId || myTribeIds.has(p.tribeId)).map(p => ({ ...p, isFavorite: fav.has(String(p.rootId)) }));
     ctx.body = await padsView(enriched, filter, null, { q, ...(tribeId ? { tribeId } : {}) });
     ctx.body = await padsView(enriched, filter, null, { q, ...(tribeId ? { tribeId } : {}) });
   })
   })
   .get("/pads/:padId", async (ctx) => {
   .get("/pads/:padId", async (ctx) => {
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
+    const uid = getViewerId();
     const pad = await padsModel.getPadById(ctx.params.padId);
     const pad = await padsModel.getPadById(ctx.params.padId);
     if (!pad) { ctx.redirect('/pads'); return; }
     if (!pad) { ctx.redirect('/pads'); return; }
+    if (pad.tribeId) {
+      try {
+        const t = await tribesModel.getTribeById(pad.tribeId);
+        if (!t.members.includes(uid)) { ctx.body = tribeAccessDeniedView(t); return; }
+      } catch { ctx.redirect('/tribes'); return; }
+    }
     const fav = await mediaFavorites.getFavoriteSet('pads');
     const fav = await mediaFavorites.getFavoriteSet('pads');
     const entries = await padsModel.getEntries(pad.rootId);
     const entries = await padsModel.getEntries(pad.rootId);
-    const inviteCode = ctx.query.inviteCode || null;
     const versionKey = ctx.query.version || null;
     const versionKey = ctx.query.version || null;
     const selectedVersion = versionKey
     const selectedVersion = versionKey
       ? (entries.find(e => e.key === versionKey) || entries[parseInt(versionKey)] || null)
       ? (entries.find(e => e.key === versionKey) || entries[parseInt(versionKey)] || null)
       : null;
       : null;
     const baseUrl = `${ctx.protocol}://${ctx.host}`;
     const baseUrl = `${ctx.protocol}://${ctx.host}`;
-    ctx.body = await singlePadView({ ...pad, isFavorite: fav.has(String(pad.rootId)) }, entries, { baseUrl, inviteCode, selectedVersion });
+    ctx.body = await singlePadView({ ...pad, isFavorite: fav.has(String(pad.rootId)) }, entries, { baseUrl, selectedVersion });
   })
   })
   .get("/calendars", async (ctx) => {
   .get("/calendars", async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
@@ -1909,14 +1979,22 @@ router
     const modelFilter = filter === "favorites" ? "all" : filter;
     const modelFilter = filter === "favorites" ? "all" : filter;
     const calendars = await calendarsModel.listAll({ filter: modelFilter, viewerId: uid });
     const calendars = await calendarsModel.listAll({ filter: modelFilter, viewerId: uid });
     const fav = await mediaFavorites.getFavoriteSet('calendars');
     const fav = await mediaFavorites.getFavoriteSet('calendars');
-    const enriched = calendars.map(c => ({ ...c, isFavorite: fav.has(String(c.rootId)) }));
+    const myTribeIds = await getUserTribeIds(uid);
+    const enriched = calendars.filter(c => !c.tribeId || myTribeIds.has(c.tribeId)).map(c => ({ ...c, isFavorite: fav.has(String(c.rootId)) }));
     const finalList = filter === "favorites" ? enriched.filter(c => c.isFavorite) : enriched;
     const finalList = filter === "favorites" ? enriched.filter(c => c.isFavorite) : enriched;
     ctx.body = await calendarsView(finalList, filter, null, { q, ...(tribeId ? { tribeId } : {}) });
     ctx.body = await calendarsView(finalList, filter, null, { q, ...(tribeId ? { tribeId } : {}) });
   })
   })
   .get("/calendars/:calId", async (ctx) => {
   .get("/calendars/:calId", async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    const uid = getViewerId();
     const cal = await calendarsModel.getCalendarById(ctx.params.calId);
     const cal = await calendarsModel.getCalendarById(ctx.params.calId);
     if (!cal) { ctx.redirect('/calendars'); return; }
     if (!cal) { ctx.redirect('/calendars'); return; }
+    if (cal.tribeId) {
+      try {
+        const t = await tribesModel.getTribeById(cal.tribeId);
+        if (!t.members.includes(uid)) { ctx.body = tribeAccessDeniedView(t); return; }
+      } catch { ctx.redirect('/tribes'); return; }
+    }
     const dates = await calendarsModel.getDatesForCalendar(cal.rootId);
     const dates = await calendarsModel.getDatesForCalendar(cal.rootId);
     const notesByDate = {};
     const notesByDate = {};
     for (const d of dates) {
     for (const d of dates) {
@@ -2301,9 +2379,23 @@ router
     if (typeof types === "string") types = [types];
     if (typeof types === "string") types = [types];
     if (!Array.isArray(types)) types = [];
     if (!Array.isArray(types)) types = [];
     if (!query) return ctx.body = await searchView({ messages: [], query, types });
     if (!query) return ctx.body = await searchView({ messages: [], query, types });
+    const userId = getViewerId();
+    const allTribes = await tribesModel.listAll();
+    const anonTribeIds = new Set(allTribes.filter(t => t.isAnonymous === true).map(t => t.id));
+    const applySearchPrivacy = (msgs) => msgs.filter(msg => {
+      const c = msg.value?.content;
+      if (!c) return true;
+      if (c.tribeId && anonTribeIds.has(c.tribeId)) return false;
+      if (c.type === 'event' && c.isPublic === 'private' && c.organizer !== userId && !(Array.isArray(c.attendees) && c.attendees.includes(userId))) return false;
+      if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE' && c.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false;
+      if (c.status === 'PRIVATE') return false;
+      if (c.type === 'shop' && c.visibility === 'CLOSED' && c.author !== userId) return false;
+      return true;
+    });
     const results = await searchModel.search({ query, types });
     const results = await searchModel.search({ query, types });
     ctx.body = await searchView({ results: Object.entries(results).reduce((acc, [type, msgs]) => {
     ctx.body = await searchView({ results: Object.entries(results).reduce((acc, [type, msgs]) => {
-      acc[type] = msgs.map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' });
+      const filtered = applySearchPrivacy(msgs).map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' });
+      if (filtered.length > 0) acc[type] = filtered;
       return acc;
       return acc;
     }, {}), query, types });
     }, {}), query, types });
   })
   })
@@ -2438,6 +2530,8 @@ router
     ctx.redirect(ctx.get('referer') || `/forum/${encodeURIComponent(ctx.params.forumId)}`);
     ctx.redirect(ctx.get('referer') || `/forum/${encodeURIComponent(ctx.params.forumId)}`);
   })
   })
   .post('/forum/delete/:id', koaBody(), async ctx => {
   .post('/forum/delete/:id', koaBody(), async ctx => {
+    const forum = await forumModel.getForumById(ctx.params.id).catch(() => null);
+    if (!forum || forum.author !== getViewerId()) { ctx.status = 403; ctx.body = 'Forbidden'; return; }
     await forumModel.deleteForumById(ctx.params.id);
     await forumModel.deleteForumById(ctx.params.id);
     ctx.redirect('/forum');
     ctx.redirect('/forum');
   })
   })
@@ -2499,7 +2593,11 @@ router
     ctx.redirect(ctx.get("Referer") || "/feed");
     ctx.redirect(ctx.get("Referer") || "/feed");
   })
   })
   .post("/feed/refeed/:id", koaBody(), async (ctx) => {
   .post("/feed/refeed/:id", koaBody(), async (ctx) => {
-    await feedModel.createRefeed(ctx.params.id);
+    try {
+      await feedModel.createRefeed(ctx.params.id);
+    } catch (e) {
+      if (e.message !== "Already refeeded") throw e;
+    }
     ctx.redirect(ctx.get("Referer") || "/feed");
     ctx.redirect(ctx.get("Referer") || "/feed");
   })
   })
   .post("/feed/:feedId/comments", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
   .post("/feed/:feedId/comments", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
@@ -2567,11 +2665,12 @@ router
   .post("/maps/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'maps', 'remove'))
   .post("/maps/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'maps', 'remove'))
   .post("/maps/:mapId/marker", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
   .post("/maps/:mapId/marker", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
-    const mapItem = await mapsModel.getMapById(ctx.params.mapId, getViewerId());
-    if (mapItem.tribeId && mapItem.mapType === "OPEN") {
+    const uid = getViewerId();
+    const mapItem = await mapsModel.getMapById(ctx.params.mapId, uid);
+    if (mapItem.tribeId) {
       try {
       try {
         const t = await tribesModel.getTribeById(mapItem.tribeId);
         const t = await tribesModel.getTribeById(mapItem.tribeId);
-        if (!t.members.includes(getViewerId())) { ctx.status = 403; ctx.body = "Forbidden"; return; }
+        if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; }
       } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
       } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
     }
     }
     const b = ctx.request.body;
     const b = ctx.request.body;
@@ -2995,6 +3094,8 @@ router
     ctx.redirect('/parliament?filter=proposals');
     ctx.redirect('/parliament?filter=proposals');
   })
   })
   .post('/parliament/proposals/close/:id', koaBody(), async (ctx) => {
   .post('/parliament/proposals/close/:id', koaBody(), async (ctx) => {
+    const canClose = await parliamentModel.canPropose();
+    if (!canClose) { ctx.status = 403; ctx.body = 'Forbidden'; return; }
     await parliamentModel.closeProposal(ctx.params.id).catch(e => ctx.throw(400, String(e?.message || e)));
     await parliamentModel.closeProposal(ctx.params.id).catch(e => ctx.throw(400, String(e?.message || e)));
     ctx.redirect('/parliament?filter=proposals');
     ctx.redirect('/parliament?filter=proposals');
   })
   })
@@ -3316,7 +3417,7 @@ router
     if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
     const chatId = ctx.request.body.chatId;
     const chatId = ctx.request.body.chatId;
     const code = await chatsModel.generateInvite(chatId);
     const code = await chatsModel.generateInvite(chatId);
-    ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(chatId)}?inviteCode=${encodeURIComponent(code)}`, ['/chats']));
+    ctx.body = renderChatInvitePage(code);
   })
   })
   .post("/chats/join-code", koaBody(), async (ctx) => {
   .post("/chats/join-code", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
@@ -3346,6 +3447,14 @@ router
   .post("/chats/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'chats', 'remove'))
   .post("/chats/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'chats', 'remove'))
   .post("/chats/:chatId/message", koaBody({ multipart: true }), async (ctx) => {
   .post("/chats/:chatId/message", koaBody({ multipart: true }), async (ctx) => {
     if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
+    const uid = getViewerId();
+    const chat = await chatsModel.getChatById(ctx.params.chatId);
+    if (chat && chat.tribeId) {
+      try {
+        const t = await tribesModel.getTribeById(chat.tribeId);
+        if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; }
+      } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
+    }
     const text = stripDangerousTags(String(ctx.request.body.text || '').trim());
     const text = stripDangerousTags(String(ctx.request.body.text || '').trim());
     const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : null;
     const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : null;
     if (!text && !imageBlob) { ctx.redirect(`/chats/${encodeURIComponent(ctx.params.chatId)}`); return; }
     if (!text && !imageBlob) { ctx.redirect(`/chats/${encodeURIComponent(ctx.params.chatId)}`); return; }
@@ -3391,7 +3500,7 @@ router
   .post("/pads/generate-invite/:id", koaBody(), async (ctx) => {
   .post("/pads/generate-invite/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
     const code = await padsModel.generateInvite(ctx.params.id);
     const code = await padsModel.generateInvite(ctx.params.id);
-    ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}?inviteCode=${encodeURIComponent(code)}`);
+    ctx.body = renderPadInvitePage(code);
   })
   })
   .post("/pads/join-code", koaBody(), async (ctx) => {
   .post("/pads/join-code", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
@@ -3411,6 +3520,14 @@ router
   })
   })
   .post("/pads/entry/:id", koaBody(), async (ctx) => {
   .post("/pads/entry/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
+    const uid = getViewerId();
+    const pad = await padsModel.getPadById(ctx.params.id);
+    if (pad && pad.tribeId) {
+      try {
+        const t = await tribesModel.getTribeById(pad.tribeId);
+        if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; }
+      } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
+    }
     const b = ctx.request.body || {};
     const b = ctx.request.body || {};
     const text = stripDangerousTags(String(b.text || "").trim());
     const text = stripDangerousTags(String(b.text || "").trim());
     if (text) await padsModel.addEntry(ctx.params.id, text);
     if (text) await padsModel.addEntry(ctx.params.id, text);
@@ -3473,6 +3590,14 @@ router
   })
   })
   .post("/calendars/add-date/:id", koaBody(), async (ctx) => {
   .post("/calendars/add-date/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    const uid = getViewerId();
+    const calForGate = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null);
+    if (calForGate && calForGate.tribeId) {
+      try {
+        const t = await tribesModel.getTribeById(calForGate.tribeId);
+        if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; }
+      } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
+    }
     const b = ctx.request.body || {};
     const b = ctx.request.body || {};
     const intervalWeekly  = [].concat(b.intervalWeekly).includes("1");
     const intervalWeekly  = [].concat(b.intervalWeekly).includes("1");
     const intervalMonthly = [].concat(b.intervalMonthly).includes("1");
     const intervalMonthly = [].concat(b.intervalMonthly).includes("1");
@@ -3494,6 +3619,14 @@ router
   })
   })
   .post("/calendars/add-note/:id", koaBody(), async (ctx) => {
   .post("/calendars/add-note/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    const uid = getViewerId();
+    const calForGate = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null);
+    if (calForGate && calForGate.tribeId) {
+      try {
+        const t = await tribesModel.getTribeById(calForGate.tribeId);
+        if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; }
+      } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
+    }
     const b = ctx.request.body || {};
     const b = ctx.request.body || {};
     const text = stripDangerousTags(String(b.text || "").trim());
     const text = stripDangerousTags(String(b.text || "").trim());
     if (text) {
     if (text) {
@@ -3676,7 +3809,7 @@ router
     ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`);
     ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`);
   })
   })
   .post("/banking/addresses/delete", koaBody(), async (ctx) => {
   .post("/banking/addresses/delete", koaBody(), async (ctx) => {
-    const res = await bankingModel.removeAddress({ userId: ((ctx.request.body?.userId) || "").trim() });
+    const res = await bankingModel.removeAddress({ userId: getViewerId() });
     ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`);
     ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`);
   })
   })
   .post("/favorites/remove/:kind/:id", koaBody(), async (ctx) => {
   .post("/favorites/remove/:kind/:id", koaBody(), async (ctx) => {

+ 11 - 9
src/client/assets/styles/style.css

@@ -3694,7 +3694,7 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .calendar-interval-block { margin: 8px 0 16px; }
 .calendar-interval-block { margin: 8px 0 16px; }
 .calendar-interval-label { display: block; margin-bottom: 6px; }
 .calendar-interval-label { display: block; margin-bottom: 6px; }
 .calendar-interval-until { margin-top: 10px; }
 .calendar-interval-until { margin-top: 10px; }
-.calendar-interval-row { display: flex; gap: 16px; flex-wrap: wrap; border: 0px; }
+.calendar-interval-row { display: flex; gap: 16px; flex-wrap: wrap; }
 .calendar-interval-option { display: flex; align-items: center; gap: 4px; cursor: pointer; white-space: nowrap; }
 .calendar-interval-option { display: flex; align-items: center; gap: 4px; cursor: pointer; white-space: nowrap; }
 
 
 .tribe-thumb-grid {
 .tribe-thumb-grid {
@@ -4891,8 +4891,8 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .pad-status-invite{color:#FFA500;font-weight:bold}
 .pad-status-invite{color:#FFA500;font-weight:bold}
 .pad-status-closed{font-weight:bold}
 .pad-status-closed{font-weight:bold}
 .pad-deadline{color:#FFA500;font-size:0.9rem}
 .pad-deadline{color:#FFA500;font-size:0.9rem}
-.pad-editor-container{display:flex;gap:16px;margin-top:12px}
-.pad-editor-area{flex:2;display:flex;flex-direction:column;gap:8px}
+.pad-editor-container{margin-top:12px}
+.pad-editor-area{display:flex;flex-direction:column;gap:8px;}
 .pad-editor-area textarea{width:100%;min-height:200px;background:#1a1a1a;color:#eee;border:1px solid #444;border-radius:4px;padding:10px;font-family:monospace;font-size:0.95rem;resize:vertical;box-sizing:border-box}
 .pad-editor-area textarea{width:100%;min-height:200px;background:#1a1a1a;color:#eee;border:1px solid #444;border-radius:4px;padding:10px;font-family:monospace;font-size:0.95rem;resize:vertical;box-sizing:border-box}
 .pad-members-list{flex:1;min-width:160px;background:#1a1a1a;border:1px solid #333;border-radius:4px;padding:10px}
 .pad-members-list{flex:1;min-width:160px;background:#1a1a1a;border:1px solid #333;border-radius:4px;padding:10px}
 .pad-members-list h4{margin:0 0 8px 0;color:#aaa;font-size:0.85rem;text-transform:uppercase}
 .pad-members-list h4{margin:0 0 8px 0;color:#aaa;font-size:0.85rem;text-transform:uppercase}
@@ -4944,7 +4944,7 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 
 
 .visit-btn-centered{display:flex;justify-content:center;margin-top:10px}
 .visit-btn-centered{display:flex;justify-content:center;margin-top:10px}
 
 
-.pad-editor-white{width:100%;box-sizing:border-box;background:#fff;color:#111;border:1px solid #aaa;border-radius:4px;padding:10px;resize:vertical;font-family:inherit;font-size:0.95rem}
+.pad-editor-white{width:100%;box-sizing:border-box;background:#fff;color:#111;border-radius:4px;padding:10px;resize:vertical;font-family:inherit;font-size:0.95rem}
 .pad-readonly-text{background:#fff;color:#111;border:1px solid #aaa;border-radius:4px;padding:10px;white-space:pre-wrap;word-break:break-word;font-size:0.95rem}
 .pad-readonly-text{background:#fff;color:#111;border:1px solid #aaa;border-radius:4px;padding:10px;white-space:pre-wrap;word-break:break-word;font-size:0.95rem}
 .pad-version-list{margin-top:12px}
 .pad-version-list{margin-top:12px}
 .pad-version-item{display:flex;flex-direction:column;gap:4px;padding:8px;border-bottom:1px solid #333;font-size:0.85rem}
 .pad-version-item{display:flex;flex-direction:column;gap:4px;padding:8px;border-bottom:1px solid #333;font-size:0.85rem}
@@ -4970,9 +4970,7 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .pad-members-below-tags{margin-top:12px;border:none}
 .pad-members-below-tags{margin-top:12px;border:none}
 .pad-member-card{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:0.85rem}
 .pad-member-card{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:0.85rem}
 .pad-members-grid{display:flex;flex-direction:column;gap:4px;margin-bottom:12px}
 .pad-members-grid{display:flex;flex-direction:column;gap:4px;margin-bottom:12px}
-.pad-main-layout{display:flex;gap:20px}
-.pad-main-left{flex:2;min-width:0}
-.pad-main-right{flex:1;min-width:180px}
+.pad-version-section{margin-top:16px;border-top:1px solid #333;padding-top:12px}
 
 
 .chat-full-width{flex:1;min-width:0}
 .chat-full-width{flex:1;min-width:0}
 .chat-participants-section{margin-top:12px;padding:12px;background:#1a1a1a;border:none;border-radius:4px}
 .chat-participants-section{margin-top:12px;padding:12px;background:#1a1a1a;border:none;border-radius:4px}
@@ -4996,8 +4994,10 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .game-shell-section{padding:0!important;margin:0}
 .game-shell-section{padding:0!important;margin:0}
 .game-iframe{width:100%;height:82vh;border:none;background:#000;display:block}
 .game-iframe{width:100%;height:82vh;border:none;background:#000;display:block}
 .game-iframe-ecoinflow{height:95vh}
 .game-iframe-ecoinflow{height:95vh}
+.game-iframe-neoninfiltrator{height:95vh}
 .game-iframe-audiopendulum{height:95vh}
 .game-iframe-audiopendulum{height:95vh}
 .game-iframe-flipflop{height:720px}
 .game-iframe-flipflop{height:720px}
+.game-iframe-rockpaperscissors{height:580px}
 .game-iframe-tiktaktoe{height:580px}
 .game-iframe-tiktaktoe{height:580px}
 .game-desc-yellow{color:yellow}
 .game-desc-yellow{color:yellow}
 .game-new-record-label{color:#FFA500;font-weight:bold}
 .game-new-record-label{color:#FFA500;font-weight:bold}
@@ -5010,10 +5010,12 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .calendar-day-marked a{color:#000;text-decoration:none;display:block}
 .calendar-day-marked a{color:#000;text-decoration:none;display:block}
 .calendar-nav{display:flex;justify-content:space-between;align-items:center;margin:8px 0}
 .calendar-nav{display:flex;justify-content:space-between;align-items:center;margin:8px 0}
 .calendar-date-list{margin-top:16px}
 .calendar-date-list{margin-top:16px}
-.calendar-date-item{background:#333;border-radius:6px;margin-bottom:14px;position:relative;min-height:64px}
+.calendar-date-item{background:#333;padding:18px 108px 18px 18px;border-radius:6px;margin-bottom:14px;position:relative;min-height:64px}
 .calendar-date-delete{position:absolute;top:12px;right:12px;margin:0}
 .calendar-date-delete{position:absolute;top:12px;right:12px;margin:0}
-.calendar-date-delete button{padding:4px 10px;font-size:0.8rem;top:12px;margin-top: 25px;margin-right: 35px;}
+.calendar-date-delete button{padding:4px 10px;font-size:0.8rem}
 .calendar-date-item-header{color:#FFA500;font-weight:bold;margin-bottom:6px}
 .calendar-date-item-header{color:#FFA500;font-weight:bold;margin-bottom:6px}
 .calendar-participants-count{color:#FFA500;font-weight:bold}
 .calendar-participants-count{color:#FFA500;font-weight:bold}
 .calendar-day-notes{margin-top:16px}
 .calendar-day-notes{margin-top:16px}
 .pad-viewer-back{margin-bottom:12px}
 .pad-viewer-back{margin-bottom:12px}
+
+.access-denied-msg{margin:12px 0;padding:12px;background:#1a1a1a;border:1px solid #555;border-radius:4px;color:#ccc;font-style:italic}

+ 13 - 2
src/client/assets/translations/oasis_ar.js

@@ -2942,6 +2942,8 @@ module.exports = {
     gamesCocolandDesc: "جوزة هند بعيون تقفز فوق أشجار النخيل وتجمع ECOins.",
     gamesCocolandDesc: "جوزة هند بعيون تقفز فوق أشجار النخيل وتجمع ECOins.",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "اربط PUBs بالسكان عبر المدققين والمتاجر والمجمّعات. انجُ من تهديد CBDC!",
     gamesTheFlowDesc: "اربط PUBs بالسكان عبر المدققين والمتاجر والمجمّعات. انجُ من تهديد CBDC!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "تسلل عبر الشبكة، اجمع بيانات سرية، تجنب طائرات الأمن واهرب. كم مستوى تستطيع اجتيازه؟",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "أوقف الغزو الفضائي! أسقط موجات الغزاة قبل أن يصلوا إلى الأرض.",
     gamesSpaceInvadersDesc: "أوقف الغزو الفضائي! أسقط موجات الغزاة قبل أن يصلوا إلى الأرض.",
     gamesArkanoidTitle: "Arkanoid",
     gamesArkanoidTitle: "Arkanoid",
@@ -2952,8 +2954,10 @@ module.exports = {
     gamesOutrunDesc: "سباق ضد الوقت! تجنب السيارات وصل إلى خط النهاية قبل انتهاء الوقت.",
     gamesOutrunDesc: "سباق ضد الوقت! تجنب السيارات وصل إلى خط النهاية قبل انتهاء الوقت.",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "قد مركبتك عبر حقل الكويكبات. دمرها قبل أن تصطدم بك.",
     gamesAsteroidsDesc: "قد مركبتك عبر حقل الكويكبات. دمرها قبل أن تصطدم بك.",
+    gamesRockPaperScissorsTitle: "حجر ورقة مقص",
+    gamesRockPaperScissorsDesc: "حجر، ورقة أو مقص ضد ذكاء اصطناعي. الأفضل في ثلاث جولات يفوز.",
     gamesTikTakToeTitle: "TikTakToe",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "حجر، ورقة أو مقص ضد ذكاء اصطناعي. الأفضل في ثلاث جولات يفوز.",
+    gamesTikTakToeDesc: "تيك-تاك-تو الكلاسيكي ضد الذكاء الاصطناعي. ثلاثة في صف واحد للفوز.",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "اقلب عملة معدنية وراهن على الوجه أو الظهر. كم أنت محظوظ?",
     gamesFlipFlopDesc: "اقلب عملة معدنية وراهن على الوجه أو الظهر. كم أنت محظوظ?",
     games8BallTitle: "8Ball Pool",
     games8BallTitle: "8Ball Pool",
@@ -3041,6 +3045,13 @@ module.exports = {
     calendarLeave: "مغادرة التقويم",
     calendarLeave: "مغادرة التقويم",
     statsCalendar: "التقاويم",
     statsCalendar: "التقاويم",
     statsCalendarDate: "تواريخ التقويم",
     statsCalendarDate: "تواريخ التقويم",
-    statsCalendarNote: "ملاحظات التقويم"
+    statsCalendarNote: "ملاحظات التقويم",
+    chatAccessDenied: "ليس لديك حق الوصول إلى هذه المحادثة. اطلب دعوة للوصول إلى المحتوى.",
+    padAccessDenied: "ليس لديك حق الوصول إلى هذا الوسادة. اطلب دعوة للوصول إلى المحتوى.",
+    contentAccessDenied: "ليس لديك حق الوصول إلى هذا المحتوى.",
+    blockAccessRestricted: "الوصول مقيد",
+    tribeContentAccessDenied: "تم رفض الوصول",
+    tribeContentAccessDeniedMsg: "هذا المحتوى ينتمي إلى قبيلة. يجب أن تكون عضواً للوصول إليه.",
+    tribeViewTribes: "عرض القبائل"
     }
     }
 };
 };

+ 13 - 2
src/client/assets/translations/oasis_de.js

@@ -2885,6 +2885,8 @@ module.exports = {
     gamesCocolandDesc: "Eine Kokosnuss mit Augen, die über Palmen springt und ECOins sammelt.",
     gamesCocolandDesc: "Eine Kokosnuss mit Augen, die über Palmen springt und ECOins sammelt.",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "Verbinde PUBs mit Bewohnern über Validatoren, Shops und Akkumulatoren. Überstehe die CBDC-Bedrohung!",
     gamesTheFlowDesc: "Verbinde PUBs mit Bewohnern über Validatoren, Shops und Akkumulatoren. Überstehe die CBDC-Bedrohung!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "Infiltriere das Gitter, sammle vertrauliche Daten, weiche Sicherheitsdrohnen aus und fliehe. Wie viele Level schaffst du?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "Stoppt die Alien-Invasion! Schießt Wellen von Eindringlingen ab.",
     gamesSpaceInvadersDesc: "Stoppt die Alien-Invasion! Schießt Wellen von Eindringlingen ab.",
     gamesArkanoidTitle: "Arkanoid",
     gamesArkanoidTitle: "Arkanoid",
@@ -2895,8 +2897,10 @@ module.exports = {
     gamesOutrunDesc: "Rennen gegen die Zeit! Weiche dem Verkehr aus und erreiche das Ziel rechtzeitig.",
     gamesOutrunDesc: "Rennen gegen die Zeit! Weiche dem Verkehr aus und erreiche das Ziel rechtzeitig.",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "Steuere dein Raumschiff durch ein tödliches Asteroidenfeld. Schieße sie ab, bevor sie dich treffen.",
     gamesAsteroidsDesc: "Steuere dein Raumschiff durch ein tödliches Asteroidenfeld. Schieße sie ab, bevor sie dich treffen.",
+    gamesRockPaperScissorsTitle: "Schere Stein Papier",
+    gamesRockPaperScissorsDesc: "Schere, Stein, Papier gegen eine KI. Das Beste aus drei Runden gewinnt.",
     gamesTikTakToeTitle: "TikTakToe",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "Schere, Stein, Papier gegen eine KI. Das Beste aus drei Runden gewinnt.",
+    gamesTikTakToeDesc: "Klassisches Tic-Tac-Toe gegen die KI. Drei in einer Reihe gewinnt.",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "Werfe eine Münze und wette auf Kopf oder Zahl. Wie viel Glück hast du?",
     gamesFlipFlopDesc: "Werfe eine Münze und wette auf Kopf oder Zahl. Wie viel Glück hast du?",
     games8BallTitle: "8Ball Pool",
     games8BallTitle: "8Ball Pool",
@@ -3037,6 +3041,13 @@ module.exports = {
     calendarLeave: "Kalender verlassen",
     calendarLeave: "Kalender verlassen",
     statsCalendar: "Kalender",
     statsCalendar: "Kalender",
     statsCalendarDate: "Kalenderdaten",
     statsCalendarDate: "Kalenderdaten",
-    statsCalendarNote: "Kalendernotizen"
+    statsCalendarNote: "Kalendernotizen",
+    chatAccessDenied: "Sie haben keinen Zugang zu diesem Chat. Fordern Sie eine Einladung an.",
+    padAccessDenied: "Sie haben keinen Zugang zu diesem Pad. Fordern Sie eine Einladung an.",
+    contentAccessDenied: "Sie haben keinen Zugang zu diesem Inhalt.",
+    blockAccessRestricted: "Zugang gesperrt",
+    tribeContentAccessDenied: "Zugang Verweigert",
+    tribeContentAccessDeniedMsg: "Dieser Inhalt gehört zu einem Stamm. Sie müssen Mitglied sein, um darauf zuzugreifen.",
+    tribeViewTribes: "Stämme Anzeigen"
     }
     }
 }
 }

+ 13 - 2
src/client/assets/translations/oasis_en.js

@@ -3019,6 +3019,8 @@ module.exports = {
     gamesCocolandDesc: "A coconut with eyes jumping over palm trees and collecting ECOins. How far can you go?",
     gamesCocolandDesc: "A coconut with eyes jumping over palm trees and collecting ECOins. How far can you go?",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "Connect PUBs to habitants through validators, shops and accumulators. Survive the CBDC threat!",
     gamesTheFlowDesc: "Connect PUBs to habitants through validators, shops and accumulators. Survive the CBDC threat!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "Infiltrate the grid, collect confidential data, evade security drones and escape. How many levels can you clear?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "Stop the alien invasion! Shoot down waves of invaders before they reach the ground.",
     gamesSpaceInvadersDesc: "Stop the alien invasion! Shoot down waves of invaders before they reach the ground.",
     gamesArkanoidTitle: "Arkanoid",
     gamesArkanoidTitle: "Arkanoid",
@@ -3029,8 +3031,10 @@ module.exports = {
     gamesOutrunDesc: "Race against time! Dodge traffic and reach the finish line before the clock runs out.",
     gamesOutrunDesc: "Race against time! Dodge traffic and reach the finish line before the clock runs out.",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "Pilot your ship through a deadly asteroid field. Shoot them down before they hit you.",
     gamesAsteroidsDesc: "Pilot your ship through a deadly asteroid field. Shoot them down before they hit you.",
+    gamesRockPaperScissorsTitle: "Rock Paper Scissors",
+    gamesRockPaperScissorsDesc: "Rock, Paper, Scissors against an AI. Best of three rounds wins.",
     gamesTikTakToeTitle: "TikTakToe",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "Rock, Paper, Scissors against an AI. Best of three rounds wins.",
+    gamesTikTakToeDesc: "Classic Tic-Tac-Toe against an AI. Get three in a row to win.",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "Flip a coin and bet on heads or tails. How lucky are you?",
     gamesFlipFlopDesc: "Flip a coin and bet on heads or tails. How lucky are you?",
     games8BallTitle: "8Ball Pool",
     games8BallTitle: "8Ball Pool",
@@ -3060,7 +3064,14 @@ module.exports = {
     statsChatMessage: "Chat messages",
     statsChatMessage: "Chat messages",
     statsPad: "Pads",
     statsPad: "Pads",
     statsPadEntry: "Pad entries",
     statsPadEntry: "Pad entries",
-    statsGameScore: "Game scores"
+    statsGameScore: "Game scores",
+    chatAccessDenied: "You do not have access to the chat. Ask for an invitation to access the content.",
+    padAccessDenied: "You do not have access to the pad. Ask for an invitation to access the content.",
+    contentAccessDenied: "You do not have access to content.",
+    blockAccessRestricted: "Access restricted",
+    tribeContentAccessDenied: "Access Denied",
+    tribeContentAccessDeniedMsg: "This content belongs to a tribe. You must be a member to access it.",
+    tribeViewTribes: "View Tribes"
 
 
     }
     }
 };
 };

+ 13 - 2
src/client/assets/translations/oasis_es.js

@@ -3019,6 +3019,8 @@ module.exports = {
     gamesCocolandDesc: "Un coco con ojos saltando palmeras y coleccionando ECOins. ¿Hasta dónde puedes llegar?",
     gamesCocolandDesc: "Un coco con ojos saltando palmeras y coleccionando ECOins. ¿Hasta dónde puedes llegar?",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "Conecta PUBs a habitantes mediante validadores, tiendas y acumuladores. ¡Sobrevive a la amenaza CBDC!",
     gamesTheFlowDesc: "Conecta PUBs a habitantes mediante validadores, tiendas y acumuladores. ¡Sobrevive a la amenaza CBDC!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "Infiltra la cuadricula, recolecta datos confidenciales, evade los drones de seguridad y escapa. ¿Cuantos niveles puedes superar?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "¡Detén la invasión alienígena! Destruye oleadas de invasores antes de que lleguen al suelo.",
     gamesSpaceInvadersDesc: "¡Detén la invasión alienígena! Destruye oleadas de invasores antes de que lleguen al suelo.",
     gamesArkanoidTitle: "Arkanoid",
     gamesArkanoidTitle: "Arkanoid",
@@ -3029,8 +3031,10 @@ module.exports = {
     gamesOutrunDesc: "¡Corre contra el tiempo! Esquiva el tráfico y llega a la meta antes de que se acabe el tiempo.",
     gamesOutrunDesc: "¡Corre contra el tiempo! Esquiva el tráfico y llega a la meta antes de que se acabe el tiempo.",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "Pilota tu nave por un campo de asteroides mortal. Dispárales antes de que te alcancen.",
     gamesAsteroidsDesc: "Pilota tu nave por un campo de asteroides mortal. Dispárales antes de que te alcancen.",
+    gamesRockPaperScissorsTitle: "Piedra Papel Tijeras",
+    gamesRockPaperScissorsDesc: "Piedra, papel o tijera contra una IA. Gana el mejor de tres rondas.",
     gamesTikTakToeTitle: "TikTakToe",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "Piedra, papel o tijera contra una IA. Gana el mejor de tres rondas.",
+    gamesTikTakToeDesc: "Tres en raya clásico contra la IA. Consigue tres en línea para ganar.",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "Lanza una moneda y apuesta cara o cruz. ¿Cuánta suerte tienes?",
     gamesFlipFlopDesc: "Lanza una moneda y apuesta cara o cruz. ¿Cuánta suerte tienes?",
     games8BallTitle: "8Ball Pool",
     games8BallTitle: "8Ball Pool",
@@ -3051,6 +3055,13 @@ module.exports = {
     gamesHallOfFame: "Hall of Fame",
     gamesHallOfFame: "Hall of Fame",
     gamesHallPlayer: "Player",
     gamesHallPlayer: "Player",
     gamesHallScore: "Score",
     gamesHallScore: "Score",
-    gamesNoScores: "No scores yet."
+    gamesNoScores: "No scores yet.",
+    chatAccessDenied: "No tienes acceso a este chat. Solicita una invitación para acceder al contenido.",
+    padAccessDenied: "No tienes acceso a este pad. Solicita una invitación para acceder al contenido.",
+    contentAccessDenied: "No tienes acceso a este contenido.",
+    blockAccessRestricted: "Acceso restringido",
+    tribeContentAccessDenied: "Acceso Denegado",
+    tribeContentAccessDeniedMsg: "Este contenido pertenece a una tribu. Debes ser miembro para acceder.",
+    tribeViewTribes: "Ver Tribus"
     }
     }
 };
 };

+ 13 - 2
src/client/assets/translations/oasis_eu.js

@@ -2912,6 +2912,8 @@ module.exports = {
     gamesCocolandDesc: "Begiak dituen koko bat palmondoen gainetik jauzika ECOins biltzen.",
     gamesCocolandDesc: "Begiak dituen koko bat palmondoen gainetik jauzika ECOins biltzen.",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "Lotu PUBak biztanleekin baliozkotzaileen, denden eta metatzaileen bidez. Iraun CBDC mehatxuari!",
     gamesTheFlowDesc: "Lotu PUBak biztanleekin baliozkotzaileen, denden eta metatzaileen bidez. Iraun CBDC mehatxuari!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "Sartu saretik, bildu datu konfidentzialak, saihestu segurtasun dronak eta ihes egin. Zenbat maila gainditu ditzakezu?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "Gelditu inbasio extraterrestrea! Inbasore olatuak bota.",
     gamesSpaceInvadersDesc: "Gelditu inbasio extraterrestrea! Inbasore olatuak bota.",
     gamesArkanoidTitle: "Arkanoid",
     gamesArkanoidTitle: "Arkanoid",
@@ -2922,8 +2924,10 @@ module.exports = {
     gamesOutrunDesc: "Denboraren aurka lasterketa! Saihestu trafikoa eta iritsi helmugara garaiz.",
     gamesOutrunDesc: "Denboraren aurka lasterketa! Saihestu trafikoa eta iritsi helmugara garaiz.",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "Gidatu zure ontzia asteroide eremuaren bidez. Tiro egin haiek jo aurretik.",
     gamesAsteroidsDesc: "Gidatu zure ontzia asteroide eremuaren bidez. Tiro egin haiek jo aurretik.",
+    gamesRockPaperScissorsTitle: "Harria Paper Artaziak",
+    gamesRockPaperScissorsDesc: "Harria, papera edo artaziak IA baten aurka. Hiru txandako onena irabazten du.",
     gamesTikTakToeTitle: "TikTakToe",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "Harria, papera edo artaziak IA baten aurka. Hiru txandako onena irabazten du.",
+    gamesTikTakToeDesc: "Tic-Tac-Toe klasikoa IA aurka. Hiru jarraian lortu irabazteko.",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "Bota txanpon bat eta apostatu aurpegian edo buztanean. Zenbat zorte duzu?",
     gamesFlipFlopDesc: "Bota txanpon bat eta apostatu aurpegian edo buztanean. Zenbat zorte duzu?",
     games8BallTitle: "8Ball Pool",
     games8BallTitle: "8Ball Pool",
@@ -3011,6 +3015,13 @@ module.exports = {
     calendarLeave: "Irten Egutegitik",
     calendarLeave: "Irten Egutegitik",
     statsCalendar: "Egutegiak",
     statsCalendar: "Egutegiak",
     statsCalendarDate: "Egutegi datak",
     statsCalendarDate: "Egutegi datak",
-    statsCalendarNote: "Egutegi oharrak"
+    statsCalendarNote: "Egutegi oharrak",
+    chatAccessDenied: "Ez duzu sarbiderik txat honetara. Eskatu gonbidapen bat edukira sartzeko.",
+    padAccessDenied: "Ez duzu sarbiderik pad honetara. Eskatu gonbidapen bat edukira sartzeko.",
+    contentAccessDenied: "Ez duzu sarbiderik eduki honetara.",
+    blockAccessRestricted: "Sarbidea mugatuta",
+    tribeContentAccessDenied: "Sarbidea Ukatua",
+    tribeContentAccessDeniedMsg: "Eduki hau tribu batena da. Kide izan behar zara sartzeko.",
+    tribeViewTribes: "Tribuak Ikusi"
   }
   }
 };
 };

+ 13 - 3
src/client/assets/translations/oasis_fr.js

@@ -2940,6 +2940,8 @@ module.exports = {
     gamesCocolandDesc: "Une noix de coco avec des yeux qui saute par-dessus des palmiers en collectant des ECOins.",
     gamesCocolandDesc: "Une noix de coco avec des yeux qui saute par-dessus des palmiers en collectant des ECOins.",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "Connectez les PUBs aux habitants via validateurs, boutiques et accumulateurs. Résistez à la menace CBDC !",
     gamesTheFlowDesc: "Connectez les PUBs aux habitants via validateurs, boutiques et accumulateurs. Résistez à la menace CBDC !",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "Infiltrez la grille, collectez les données confidentielles, évitez les drones et échappez-vous. Combien de niveaux pouvez-vous passer?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "Arrêtez l'invasion extraterrestre! Abattez les vagues d'envahisseurs.",
     gamesSpaceInvadersDesc: "Arrêtez l'invasion extraterrestre! Abattez les vagues d'envahisseurs.",
     gamesArkanoidTitle: "Arkanoid",
     gamesArkanoidTitle: "Arkanoid",
@@ -2950,8 +2952,10 @@ module.exports = {
     gamesOutrunDesc: "Course contre la montre! Évitez les obstacles et atteignez l'arrivée à temps.",
     gamesOutrunDesc: "Course contre la montre! Évitez les obstacles et atteignez l'arrivée à temps.",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "Pilotez votre vaisseau dans un champ d'astéroïdes. Détruisez-les avant qu'ils ne vous touchent.",
     gamesAsteroidsDesc: "Pilotez votre vaisseau dans un champ d'astéroïdes. Détruisez-les avant qu'ils ne vous touchent.",
+    gamesRockPaperScissorsTitle: "Pierre Feuille Ciseaux",
+    gamesRockPaperScissorsDesc: "Pierre, papier, ciseaux contre une IA. Le meilleur des trois manches gagne.",
     gamesTikTakToeTitle: "TikTakToe",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "Pierre, papier, ciseaux contre une IA. Le meilleur des trois manches gagne.",
+    gamesTikTakToeDesc: "Morpion classique contre l'IA. Alignez trois symboles pour gagner.",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "Lancez une pièce et pariez sur pile ou face. Quelle est votre chance?",
     gamesFlipFlopDesc: "Lancez une pièce et pariez sur pile ou face. Quelle est votre chance?",
     games8BallTitle: "8Ball Pool",
     games8BallTitle: "8Ball Pool",
@@ -3039,7 +3043,13 @@ module.exports = {
     calendarLeave: "Quitter le calendrier",
     calendarLeave: "Quitter le calendrier",
     statsCalendar: "Calendriers",
     statsCalendar: "Calendriers",
     statsCalendarDate: "Dates de calendrier",
     statsCalendarDate: "Dates de calendrier",
-    statsCalendarNote: "Notes de calendrier"
+    statsCalendarNote: "Notes de calendrier",
+    chatAccessDenied: "Vous n'avez pas accès à ce chat. Demandez une invitation pour accéder au contenu.",
+    padAccessDenied: "Vous n'avez pas accès à ce pad. Demandez une invitation pour accéder au contenu.",
+    contentAccessDenied: "Vous n'avez pas accès à ce contenu.",
+    blockAccessRestricted: "Accès restreint",
+    tribeContentAccessDenied: "Accès Refusé",
+    tribeContentAccessDeniedMsg: "Ce contenu appartient à une tribu. Vous devez en être membre pour y accéder.",
+    tribeViewTribes: "Voir les Tribus"
     }
     }
 };
 };
-// calendar keys added

+ 13 - 2
src/client/assets/translations/oasis_hi.js

@@ -2942,6 +2942,8 @@ module.exports = {
     gamesCocolandDesc: "आंखों वाला एक नारियल ताड़ के पेड़ों के ऊपर कूदता है और ECOins इकट्ठा करता है।",
     gamesCocolandDesc: "आंखों वाला एक नारियल ताड़ के पेड़ों के ऊपर कूदता है और ECOins इकट्ठा करता है।",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "PUBs को validators, shops और accumulators के ज़रिए habitants से जोड़ें। CBDC के खतरे से बचें!",
     gamesTheFlowDesc: "PUBs को validators, shops और accumulators के ज़रिए habitants से जोड़ें। CBDC के खतरे से बचें!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "ग्रिड में घुसें, गोपनीय डेटा इकट्ठा करें, सुरक्षा ड्रोन से बचें और भागें. कितने लेवल साफ कर सकते हैं?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "एलियन आक्रमण रोकें! आक्रमणकारियों की लहरों को मार गिराएं।",
     gamesSpaceInvadersDesc: "एलियन आक्रमण रोकें! आक्रमणकारियों की लहरों को मार गिराएं।",
     gamesArkanoidTitle: "Arkanoid",
     gamesArkanoidTitle: "Arkanoid",
@@ -2952,8 +2954,10 @@ module.exports = {
     gamesOutrunDesc: "समय के खिलाफ दौड़! यातायात से बचें और समय से पहले मंजिल पर पहुंचें।",
     gamesOutrunDesc: "समय के खिलाफ दौड़! यातायात से बचें और समय से पहले मंजिल पर पहुंचें।",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "क्षुद्रग्रह क्षेत्र में अपना यान उड़ाएं। उन्हें आने से पहले नष्ट करें।",
     gamesAsteroidsDesc: "क्षुद्रग्रह क्षेत्र में अपना यान उड़ाएं। उन्हें आने से पहले नष्ट करें।",
+    gamesRockPaperScissorsTitle: "पत्थर कागज कैंची",
+    gamesRockPaperScissorsDesc: "AI के खिलाफ पत्थर, कागज या कैंची। तीन में से दो जीतने वाला विजेता।",
     gamesTikTakToeTitle: "TikTakToe",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "AI के खिलाफ पत्थर, कागज या कैंची। तीन में से दो जीतने वाला विजेता।",
+    gamesTikTakToeDesc: "AI के खिलाफ क्लासिक टिक-टैक-टो। तीन एक पंक्ति में जीत है।",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "एक सिक्का उछालें और हेड या टेल पर दांव लगाएं। आप कितने भाग्यशाली हैं?",
     gamesFlipFlopDesc: "एक सिक्का उछालें और हेड या टेल पर दांव लगाएं। आप कितने भाग्यशाली हैं?",
     games8BallTitle: "8Ball Pool",
     games8BallTitle: "8Ball Pool",
@@ -3041,6 +3045,13 @@ module.exports = {
     calendarLeave: "कैलेंडर छोड़ें",
     calendarLeave: "कैलेंडर छोड़ें",
     statsCalendar: "कैलेंडर",
     statsCalendar: "कैलेंडर",
     statsCalendarDate: "कैलेंडर तारीखें",
     statsCalendarDate: "कैलेंडर तारीखें",
-    statsCalendarNote: "कैलेंडर नोट"
+    statsCalendarNote: "कैलेंडर नोट",
+    chatAccessDenied: "आपके पास इस चैट तक पहुंच नहीं है। सामग्री तक पहुंचने के लिए आमंत्रण मांगें।",
+    padAccessDenied: "आपके पास इस पैड तक पहुंच नहीं है। सामग्री तक पहुंचने के लिए आमंत्रण मांगें।",
+    contentAccessDenied: "आपके पास इस सामग्री तक पहुंच नहीं है।",
+    blockAccessRestricted: "पहुंच प्रतिबंधित",
+    tribeContentAccessDenied: "पहुंच अस्वीकृत",
+    tribeContentAccessDeniedMsg: "यह सामग्री एक जनजाति की है। इसे देखने के लिए आपको सदस्य होना चाहिए।",
+    tribeViewTribes: "जनजातियाँ देखें"
     }
     }
 };
 };

+ 13 - 2
src/client/assets/translations/oasis_it.js

@@ -2943,6 +2943,8 @@ module.exports = {
     gamesCocolandDesc: "Una noce di cocco con occhi che salta palme e raccoglie ECOins.",
     gamesCocolandDesc: "Una noce di cocco con occhi che salta palme e raccoglie ECOins.",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "Collega i PUB agli abitanti tramite validatori, negozi e accumulatori. Sopravvivi alla minaccia CBDC!",
     gamesTheFlowDesc: "Collega i PUB agli abitanti tramite validatori, negozi e accumulatori. Sopravvivi alla minaccia CBDC!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "Infiltra la griglia, raccogli dati riservati, evita i droni di sicurezza e fuggi. Quanti livelli riesci a superare?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "Fermate l'invasione aliena! Abbattete le ondate di invasori.",
     gamesSpaceInvadersDesc: "Fermate l'invasione aliena! Abbattete le ondate di invasori.",
     gamesArkanoidTitle: "Arkanoid",
     gamesArkanoidTitle: "Arkanoid",
@@ -2953,8 +2955,10 @@ module.exports = {
     gamesOutrunDesc: "Corsa contro il tempo! Evita il traffico e raggiungi il traguardo prima che scada.",
     gamesOutrunDesc: "Corsa contro il tempo! Evita il traffico e raggiungi il traguardo prima che scada.",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "Pilota la tua navicella in un campo di asteroidi. Sparali prima che ti colpiscano.",
     gamesAsteroidsDesc: "Pilota la tua navicella in un campo di asteroidi. Sparali prima che ti colpiscano.",
+    gamesRockPaperScissorsTitle: "Carta Forbici Sasso",
+    gamesRockPaperScissorsDesc: "Carta, forbici, sasso contro una IA. Il migliore dei tre round vince.",
     gamesTikTakToeTitle: "TikTakToe",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "Carta, forbici, sasso contro una IA. Il migliore dei tre round vince.",
+    gamesTikTakToeDesc: "Tris classico contro l'IA. Allinea tre simboli per vincere.",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "Lancia una moneta e scommetti su testa o croce. Quanta fortuna hai?",
     gamesFlipFlopDesc: "Lancia una moneta e scommetti su testa o croce. Quanta fortuna hai?",
     games8BallTitle: "8Ball Pool",
     games8BallTitle: "8Ball Pool",
@@ -3042,6 +3046,13 @@ module.exports = {
     calendarLeave: "Lascia Calendario",
     calendarLeave: "Lascia Calendario",
     statsCalendar: "Calendari",
     statsCalendar: "Calendari",
     statsCalendarDate: "Date calendario",
     statsCalendarDate: "Date calendario",
-    statsCalendarNote: "Note calendario"
+    statsCalendarNote: "Note calendario",
+    chatAccessDenied: "Non hai accesso a questa chat. Chiedi un invito per accedere al contenuto.",
+    padAccessDenied: "Non hai accesso a questo pad. Chiedi un invito per accedere al contenuto.",
+    contentAccessDenied: "Non hai accesso a questo contenuto.",
+    blockAccessRestricted: "Accesso limitato",
+    tribeContentAccessDenied: "Accesso Negato",
+    tribeContentAccessDeniedMsg: "Questo contenuto appartiene a una tribù. Devi essere membro per accedervi.",
+    tribeViewTribes: "Visualizza Tribù"
     }
     }
 };
 };

+ 13 - 2
src/client/assets/translations/oasis_pt.js

@@ -2943,6 +2943,8 @@ module.exports = {
     gamesCocolandDesc: "Um coco com olhos a saltar palmeiras e a colecionar ECOins.",
     gamesCocolandDesc: "Um coco com olhos a saltar palmeiras e a colecionar ECOins.",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "Liga PUBs a habitantes através de validadores, lojas e acumuladores. Sobrevive à ameaça CBDC!",
     gamesTheFlowDesc: "Liga PUBs a habitantes através de validadores, lojas e acumuladores. Sobrevive à ameaça CBDC!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "Infiltre a grade, colete dados confidenciais, evite os drones de segurança e escape. Quantos níveis consegue passar?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "Pare a invasão alienígena! Destrua ondas de invasores.",
     gamesSpaceInvadersDesc: "Pare a invasão alienígena! Destrua ondas de invasores.",
     gamesArkanoidTitle: "Arkanoid",
     gamesArkanoidTitle: "Arkanoid",
@@ -2953,8 +2955,10 @@ module.exports = {
     gamesOutrunDesc: "Corrida contra o tempo! Desvie do tráfego e chegue à meta antes do tempo acabar.",
     gamesOutrunDesc: "Corrida contra o tempo! Desvie do tráfego e chegue à meta antes do tempo acabar.",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "Pilote sua nave por um campo de asteroides mortais. Destrua-os antes que te atinjam.",
     gamesAsteroidsDesc: "Pilote sua nave por um campo de asteroides mortais. Destrua-os antes que te atinjam.",
+    gamesRockPaperScissorsTitle: "Pedra Papel Tesoura",
+    gamesRockPaperScissorsDesc: "Pedra, papel ou tesoura contra uma IA. Melhor de três rodadas vence.",
     gamesTikTakToeTitle: "TikTakToe",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "Pedra, papel ou tesoura contra uma IA. Melhor de três rodadas vence.",
+    gamesTikTakToeDesc: "Jogo da Velha clássico contra a IA. Alinhe três para ganhar.",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "Lance uma moeda e aposte em cara ou coroa. Qual é a sua sorte?",
     gamesFlipFlopDesc: "Lance uma moeda e aposte em cara ou coroa. Qual é a sua sorte?",
     games8BallTitle: "8Ball Pool",
     games8BallTitle: "8Ball Pool",
@@ -3042,6 +3046,13 @@ module.exports = {
     calendarLeave: "Sair do Calendário",
     calendarLeave: "Sair do Calendário",
     statsCalendar: "Calendários",
     statsCalendar: "Calendários",
     statsCalendarDate: "Datas de calendário",
     statsCalendarDate: "Datas de calendário",
-    statsCalendarNote: "Notas de calendário"
+    statsCalendarNote: "Notas de calendário",
+    chatAccessDenied: "Você não tem acesso a este chat. Solicite um convite para acessar o conteúdo.",
+    padAccessDenied: "Você não tem acesso a este pad. Solicite um convite para acessar o conteúdo.",
+    contentAccessDenied: "Você não tem acesso a este conteúdo.",
+    blockAccessRestricted: "Acesso restrito",
+    tribeContentAccessDenied: "Acesso Negado",
+    tribeContentAccessDeniedMsg: "Este conteúdo pertence a uma tribo. Você deve ser membro para acessá-lo.",
+    tribeViewTribes: "Ver Tribos"
     }
     }
 };
 };

+ 13 - 2
src/client/assets/translations/oasis_ru.js

@@ -2905,6 +2905,8 @@ module.exports = {
     gamesCocolandDesc: "Кокос с глазами прыгает через пальмы и собирает ECOins.",
     gamesCocolandDesc: "Кокос с глазами прыгает через пальмы и собирает ECOins.",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "Соединяй PUB'ы с жителями через валидаторы, магазины и аккумуляторы. Противостой угрозе CBDC!",
     gamesTheFlowDesc: "Соединяй PUB'ы с жителями через валидаторы, магазины и аккумуляторы. Противостой угрозе CBDC!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "Проникни в сеть, собери секретные данные, избегай дронов безопасности и сбеги. Сколько уровней ты пройдёшь?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "Остановите вторжение пришельцев! Уничтожайте волны захватчиков.",
     gamesSpaceInvadersDesc: "Остановите вторжение пришельцев! Уничтожайте волны захватчиков.",
     gamesArkanoidTitle: "Arkanoid",
     gamesArkanoidTitle: "Arkanoid",
@@ -2915,8 +2917,10 @@ module.exports = {
     gamesOutrunDesc: "Гонка со временем! Уворачивайтесь от машин и доберитесь до финиша вовремя.",
     gamesOutrunDesc: "Гонка со временем! Уворачивайтесь от машин и доберитесь до финиша вовремя.",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "Пилотируйте корабль через астероидное поле. Уничтожайте их до столкновения.",
     gamesAsteroidsDesc: "Пилотируйте корабль через астероидное поле. Уничтожайте их до столкновения.",
+    gamesRockPaperScissorsTitle: "Камень Ножницы Бумага",
+    gamesRockPaperScissorsDesc: "Камень, ножницы, бумага против ИИ. Лучший из трёх раундов побеждает.",
     gamesTikTakToeTitle: "TikTakToe",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "Камень, ножницы, бумага против ИИ. Лучший из трёх раундов побеждает.",
+    gamesTikTakToeDesc: "Классические крестики-нолики против ИИ. Три в ряд — победа.",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "Подбросьте монету и угадайте орёл или решку. Насколько вы удачливы?",
     gamesFlipFlopDesc: "Подбросьте монету и угадайте орёл или решку. Насколько вы удачливы?",
     games8BallTitle: "8Ball Pool",
     games8BallTitle: "8Ball Pool",
@@ -3004,6 +3008,13 @@ module.exports = {
     calendarLeave: "Покинуть календарь",
     calendarLeave: "Покинуть календарь",
     statsCalendar: "Календари",
     statsCalendar: "Календари",
     statsCalendarDate: "Даты календаря",
     statsCalendarDate: "Даты календаря",
-    statsCalendarNote: "Заметки календаря"
+    statsCalendarNote: "Заметки календаря",
+    chatAccessDenied: "У вас нет доступа к этому чату. Запросите приглашение для доступа к содержимому.",
+    padAccessDenied: "У вас нет доступа к этому блокноту. Запросите приглашение для доступа к содержимому.",
+    contentAccessDenied: "У вас нет доступа к этому содержимому.",
+    blockAccessRestricted: "Доступ ограничен",
+    tribeContentAccessDenied: "Доступ Запрещён",
+    tribeContentAccessDeniedMsg: "Этот контент принадлежит племени. Вы должны быть участником, чтобы получить к нему доступ.",
+    tribeViewTribes: "Посмотреть Племена"
     }
     }
 };
 };

+ 13 - 2
src/client/assets/translations/oasis_zh.js

@@ -2943,6 +2943,8 @@ module.exports = {
     gamesCocolandDesc: "一颗有眼睛的椰子跳过棕榈树,收集ECOins。",
     gamesCocolandDesc: "一颗有眼睛的椰子跳过棕榈树,收集ECOins。",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "通过验证者、商店和累积器将PUB连接到居民。抵御CBDC威胁!",
     gamesTheFlowDesc: "通过验证者、商店和累积器将PUB连接到居民。抵御CBDC威胁!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "渗透网格,收集机密数据,辺开安全无人机并逃脱。能通过多少层关?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "阻止外星人入侵!在入侵者抵达地面之前击落它们。",
     gamesSpaceInvadersDesc: "阻止外星人入侵!在入侵者抵达地面之前击落它们。",
     gamesArkanoidTitle: "Arkanoid",
     gamesArkanoidTitle: "Arkanoid",
@@ -2953,8 +2955,10 @@ module.exports = {
     gamesOutrunDesc: "与时间赛跑!躲避交通障碍,在时间用完前到达终点。",
     gamesOutrunDesc: "与时间赛跑!躲避交通障碍,在时间用完前到达终点。",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "驾驶飞船穿越小行星带。在它们撞上你之前将其摧毁。",
     gamesAsteroidsDesc: "驾驶飞船穿越小行星带。在它们撞上你之前将其摧毁。",
+    gamesRockPaperScissorsTitle: "石头剪刀布",
+    gamesRockPaperScissorsDesc: "与AI对战石头、剪刀、布。三局两胜获胜。",
     gamesTikTakToeTitle: "TikTakToe",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "与AI对战石头、剪刀、布。三局两胜获胜。",
+    gamesTikTakToeDesc: "与AI对战经典井字游戏。连成三个即可获胜。",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "抛硬币,猜正面还是反面。你有多幸运?",
     gamesFlipFlopDesc: "抛硬币,猜正面还是反面。你有多幸运?",
     games8BallTitle: "8Ball Pool",
     games8BallTitle: "8Ball Pool",
@@ -3042,6 +3046,13 @@ module.exports = {
     calendarLeave: "离开日历",
     calendarLeave: "离开日历",
     statsCalendar: "日历",
     statsCalendar: "日历",
     statsCalendarDate: "日历日期",
     statsCalendarDate: "日历日期",
-    statsCalendarNote: "日历笔记"
+    statsCalendarNote: "日历笔记",
+    chatAccessDenied: "您没有访问此聊天室的权限。请申请邀请以访问内容。",
+    padAccessDenied: "您没有访问此协作板的权限。请申请邀请以访问内容。",
+    contentAccessDenied: "您没有访问此内容的权限。",
+    blockAccessRestricted: "访问受限",
+    tribeContentAccessDenied: "访问被拒绝",
+    tribeContentAccessDeniedMsg: "此内容属于一个部落。您必须是成员才能访问它。",
+    tribeViewTribes: "查看部落"
     }
     }
 };
 };

+ 3 - 3
src/client/gui.js

@@ -73,7 +73,7 @@ const ensureConnection = (customConfig) => {
   return pendingConnection;
   return pendingConnection;
 };
 };
 
 
-module.exports = ({ offline }) => {
+module.exports = ({ offline, port = 3000, host = 'localhost', isPublic = false }) => {
   const customConfig = JSON.parse(JSON.stringify(ssbConfig));
   const customConfig = JSON.parse(JSON.stringify(ssbConfig));
   if (offline === true) {
   if (offline === true) {
     lodash.set(customConfig, "conn.autostart", false);
     lodash.set(customConfig, "conn.autostart", false);
@@ -89,7 +89,7 @@ module.exports = ({ offline }) => {
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
         if (internalSSB) {
         if (internalSSB) {
           const { printMetadata, colors } = require('../server/ssb_metadata');
           const { printMetadata, colors } = require('../server/ssb_metadata');
-          printMetadata('OASIS GUI running at: http://localhost:3000', colors.yellow);
+          printMetadata('OASIS GUI', colors.yellow, port, host, offline, isPublic);
           return resolve(internalSSB);
           return resolve(internalSSB);
         }
         }
 
 
@@ -105,7 +105,7 @@ module.exports = ({ offline }) => {
             reject(new Error("Closing Oasis"));
             reject(new Error("Closing Oasis"));
           } else {
           } else {
             const { printMetadata, colors } = require('../server/ssb_metadata');
             const { printMetadata, colors } = require('../server/ssb_metadata');
-            printMetadata('OASIS GUI running at: http://localhost:3000', colors.yellow);
+            printMetadata('OASIS GUI', colors.yellow, port, host, offline, isPublic);
             resolve(ssb);
             resolve(ssb);
           }
           }
         }).catch(reject);
         }).catch(reject);

+ 1 - 0
src/configs/media-favorites.json

@@ -1,6 +1,7 @@
 {
 {
   "audios": [],
   "audios": [],
   "bookmarks": [],
   "bookmarks": [],
+  "chats": [],
   "documents": [],
   "documents": [],
   "images": [],
   "images": [],
   "maps": [],
   "maps": [],

+ 351 - 0
src/games/neoninfiltrator/index.html

@@ -0,0 +1,351 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
+<title>Neon Infiltrator</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; user-select: none; -webkit-tap-highlight-color: transparent; }
+html, body { overflow: hidden; }
+body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
+#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
+#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
+#ui { display: flex; gap: 16px; padding: 6px 8px; font-size: 14px; color: #FFA500; flex-wrap: wrap; justify-content: center; width: 100%; }
+canvas { display: block; width: 100%; height: auto; max-width: 700px; border: 1px solid #333; image-rendering: crisp-edges; }
+#msg { font-size: 13px; color: #FFA500; margin: 3px; text-align: center; min-height: 18px; }
+#dpad { display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 4px; }
+.dpad-row { display: flex; gap: 4px; }
+#dpad button { width: 50px; height: 50px; background: #1a0d00; border: 1px solid #FF6600; color: #FF6600; font-size: 20px; cursor: pointer; border-radius: 4px; }
+#dpad button:active { background: #3a1a00; }
+#controls { color: #666; font-size: 12px; text-align: center; margin-bottom: 3px; }
+#scoreSubmit { text-align: center; margin: 6px; }
+#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
+@media (min-width: 768px) { #dpad, #controls { display: none; } }
+</style>
+</head>
+<body>
+<div id="topbar">
+  <a href="/games" target="_top">&#8592; Back to Games</a>
+  <span style="color:#FFA500;font-weight:bold">NEON INFILTRATOR</span>
+</div>
+<div id="ui">
+  <span>DATA: <b id="dataEl">0/0</b></span>
+  <span>LEVEL: <b id="levelEl">1</b></span>
+  <span>SCORE: <b id="scoreEl">0</b></span>
+  <span>BEST: <b id="bestEl">-</b></span>
+  <span>ENEMIES: <b id="enemyEl">0</b></span>
+</div>
+<canvas id="c"></canvas>
+<div id="msg">Press any arrow key or tap to start</div>
+<div id="dpad">
+  <div class="dpad-row"><button id="btn-up">&#8593;</button></div>
+  <div class="dpad-row">
+    <button id="btn-left">&#8592;</button>
+    <button id="btn-down">&#8595;</button>
+    <button id="btn-right">&#8594;</button>
+  </div>
+</div>
+<div id="controls">Arrow keys / WASD &nbsp;|&nbsp; SPACE — new game</div>
+<div id="scoreSubmit" style="display:none">
+  <form method="POST" action="/games/submit-score" target="_top">
+    <input type="hidden" name="game" value="neoninfiltrator">
+    <input type="hidden" id="scoreInput" name="score" value="0">
+    <button type="submit">Submit Score to Hall of Fame</button>
+  </form>
+</div>
+<script>
+const canvas = document.getElementById('c');
+const ctx = canvas.getContext('2d');
+
+const CELL = 28;
+let MAP_W = 0, MAP_H = 0;
+let walls = [], enemies = [], dataItems = [], exitPos = { x: -1, y: -1 };
+let player = { x: 1, y: 1 };
+let level = 1, dataCollected = 0, totalData = 0;
+let score = 0;
+let best = parseInt(localStorage.getItem('ni_best') || '-1');
+let gameState = 'idle';
+let lastPlayerMove = 0, lastEnemyStep = 0;
+const MOVE_DELAY = 140, ENEMY_INTERVAL = 500;
+const keys = {};
+
+function setMsg(txt) { document.getElementById('msg').textContent = txt; }
+
+function updateUI() {
+  document.getElementById('dataEl').textContent = dataCollected + '/' + totalData;
+  document.getElementById('levelEl').textContent = level;
+  document.getElementById('scoreEl').textContent = score;
+  document.getElementById('bestEl').textContent = best < 0 ? '-' : best;
+  document.getElementById('enemyEl').textContent = enemies.length;
+}
+
+function resizeCanvas() {
+  let bw = 18, bh = 14;
+  if (level >= 5) { bw = 22; bh = 18; }
+  else if (level >= 3) { bw = 20; bh = 16; }
+  MAP_W = bw; MAP_H = bh;
+  canvas.width = MAP_W * CELL;
+  canvas.height = MAP_H * CELL;
+}
+
+function generateLevel() {
+  resizeCanvas();
+  walls = Array(MAP_H).fill(null).map(() => Array(MAP_W).fill(false));
+  for (let i = 0; i < MAP_W; i++) { walls[0][i] = true; walls[MAP_H - 1][i] = true; }
+  for (let i = 0; i < MAP_H; i++) { walls[i][0] = true; walls[i][MAP_W - 1] = true; }
+
+  for (let i = 0; i < Math.floor(MAP_W * MAP_H / 7); i++) {
+    const x = 2 + Math.floor(Math.random() * (MAP_W - 4));
+    const y = 2 + Math.floor(Math.random() * (MAP_H - 4));
+    if (!walls[y][x] && !(Math.abs(x - player.x) < 2 && Math.abs(y - player.y) < 2)) {
+      if (Math.random() < 0.55) walls[y][x] = true;
+    }
+  }
+  for (let i = 0; i < MAP_W * 2; i++) {
+    const x = 1 + Math.floor(Math.random() * (MAP_W - 2));
+    const y = 1 + Math.floor(Math.random() * (MAP_H - 2));
+    if (walls[y][x] && x > 1 && x < MAP_W - 2 && y > 1 && y < MAP_H - 2) {
+      if (Math.random() < 0.5) walls[y][x] = false;
+    }
+  }
+
+  player.x = 1; player.y = 1;
+  if (walls[player.y][player.x]) {
+    outer: for (let y = 1; y < MAP_H - 1; y++) {
+      for (let x = 1; x < MAP_W - 1; x++) {
+        if (!walls[y][x]) { player.x = x; player.y = y; break outer; }
+      }
+    }
+  }
+
+  exitPos = { x: MAP_W - 2, y: MAP_H - 2 };
+  for (let t = 0; t < 200; t++) {
+    const ex = MAP_W - 2 - Math.floor(Math.random() * 3);
+    const ey = MAP_H - 2 - Math.floor(Math.random() * 3);
+    if (ex >= 1 && ey >= 1 && !walls[ey][ex] && (Math.abs(ex - player.x) + Math.abs(ey - player.y)) > 6) {
+      exitPos = { x: ex, y: ey }; break;
+    }
+  }
+
+  totalData = Math.min(12, 3 + Math.floor(level / 1.5));
+  dataItems = [];
+  for (let i = 0; i < totalData; i++) {
+    let placed = false;
+    for (let t = 0; t < 100; t++) {
+      const dx = 1 + Math.floor(Math.random() * (MAP_W - 2));
+      const dy = 1 + Math.floor(Math.random() * (MAP_H - 2));
+      if (!walls[dy][dx] && !(dx === player.x && dy === player.y) && !(dx === exitPos.x && dy === exitPos.y) && !dataItems.some(d => d.x === dx && d.y === dy)) {
+        dataItems.push({ x: dx, y: dy, collected: false }); placed = true; break;
+      }
+    }
+    if (!placed) {
+      outer2: for (let y = 1; y < MAP_H - 1; y++) {
+        for (let x = 1; x < MAP_W - 1; x++) {
+          if (!walls[y][x] && !(x === player.x && y === player.y) && !(x === exitPos.x && y === exitPos.y) && !dataItems.some(d => d.x === x && d.y === y)) {
+            dataItems.push({ x, y, collected: false }); break outer2;
+          }
+        }
+      }
+    }
+  }
+  dataCollected = 0;
+
+  const enemyCount = Math.min(6, 1 + Math.floor(level / 2));
+  enemies = [];
+  for (let i = 0; i < enemyCount; i++) {
+    let placed = false;
+    for (let t = 0; t < 150; t++) {
+      const ex = 2 + Math.floor(Math.random() * (MAP_W - 4));
+      const ey = 2 + Math.floor(Math.random() * (MAP_H - 4));
+      if (!walls[ey][ex] && (Math.abs(ex - player.x) + Math.abs(ey - player.y)) > 4 && !(ex === exitPos.x && ey === exitPos.y) && !dataItems.some(d => d.x === ex && d.y === ey)) {
+        enemies.push({ x: ex, y: ey }); placed = true; break;
+      }
+    }
+    if (!placed) {
+      outer3: for (let y = 2; y < MAP_H - 2; y++) {
+        for (let x = 2; x < MAP_W - 2; x++) {
+          if (!walls[y][x] && (Math.abs(x - player.x) + Math.abs(y - player.y)) > 3 && !enemies.some(e => e.x === x && e.y === y)) {
+            enemies.push({ x, y }); break outer3;
+          }
+        }
+      }
+    }
+  }
+  updateUI();
+  setMsg(`Level ${level} — Collect ${totalData} data items and reach the exit`);
+}
+
+function checkEnemyCollision() {
+  for (const e of enemies) {
+    if (e.x === player.x && e.y === player.y) { endGame(); return true; }
+  }
+  return false;
+}
+
+function tryMove(dx, dy) {
+  if (gameState !== 'play') return;
+  const nx = player.x + dx, ny = player.y + dy;
+  if (nx < 0 || nx >= MAP_W || ny < 0 || ny >= MAP_H || walls[ny][nx]) return;
+  player.x = nx; player.y = ny;
+
+  for (const d of dataItems) {
+    if (!d.collected && d.x === player.x && d.y === player.y) {
+      d.collected = true; dataCollected++; score += 100;
+      updateUI(); setMsg(`Data collected! (${dataCollected}/${totalData})`); break;
+    }
+  }
+
+  if (dataCollected === totalData && exitPos.x === player.x && exitPos.y === player.y) {
+    const bonus = level * 50;
+    score += bonus;
+    updateUI();
+    setMsg(`Level ${level} complete! +${bonus} bonus. Advancing...`);
+    gameState = 'transit';
+    setTimeout(() => { level++; generateLevel(); gameState = 'play'; lastPlayerMove = performance.now(); lastEnemyStep = performance.now(); }, 1400);
+    return;
+  }
+
+  checkEnemyCollision();
+}
+
+function moveEnemies() {
+  if (gameState !== 'play') return;
+  for (const e of enemies) {
+    let dx = 0, dy = 0;
+    if (Math.random() < 0.6) {
+      if (Math.abs(e.x - player.x) > Math.abs(e.y - player.y)) dx = e.x > player.x ? -1 : 1;
+      else dy = e.y > player.y ? -1 : 1;
+    } else {
+      const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
+      [dx, dy] = dirs[Math.floor(Math.random() * 4)];
+    }
+    const nx = e.x + dx, ny = e.y + dy;
+    if (nx >= 0 && nx < MAP_W && ny >= 0 && ny < MAP_H && !walls[ny][nx] && !(nx === exitPos.x && ny === exitPos.y)) {
+      e.x = nx; e.y = ny;
+    } else {
+      for (const [sx, sy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+        const bx = e.x + sx, by = e.y + sy;
+        if (bx >= 0 && bx < MAP_W && by >= 0 && by < MAP_H && !walls[by][bx] && !(bx === exitPos.x && by === exitPos.y)) {
+          e.x = bx; e.y = by; break;
+        }
+      }
+    }
+  }
+  checkEnemyCollision();
+  updateUI();
+}
+
+function endGame() {
+  gameState = 'over';
+  if (best < 0 || score > best) { best = score; localStorage.setItem('ni_best', best); }
+  updateUI();
+  setMsg(`DETECTED! Score: ${score}. Press SPACE for new game.`);
+  document.getElementById('scoreInput').value = score;
+  document.getElementById('scoreSubmit').style.display = 'block';
+}
+
+function newGame() {
+  score = 0; level = 1;
+  document.getElementById('scoreSubmit').style.display = 'none';
+  generateLevel();
+  gameState = 'play';
+  lastPlayerMove = performance.now();
+  lastEnemyStep = performance.now();
+}
+
+function draw() {
+  ctx.clearRect(0, 0, canvas.width, canvas.height);
+  ctx.fillStyle = '#050505';
+  ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+  ctx.strokeStyle = '#1a0d00'; ctx.lineWidth = 0.6;
+  for (let i = 0; i <= MAP_W; i++) {
+    ctx.beginPath(); ctx.moveTo(i * CELL, 0); ctx.lineTo(i * CELL, canvas.height); ctx.stroke();
+  }
+  for (let i = 0; i <= MAP_H; i++) {
+    ctx.beginPath(); ctx.moveTo(0, i * CELL); ctx.lineTo(canvas.width, i * CELL); ctx.stroke();
+  }
+
+  for (let y = 0; y < MAP_H; y++) {
+    for (let x = 0; x < MAP_W; x++) {
+      if (walls[y][x]) {
+        ctx.fillStyle = '#1a0a05';
+        ctx.fillRect(x * CELL, y * CELL, CELL - 0.5, CELL - 0.5);
+        ctx.strokeStyle = '#FF6600'; ctx.lineWidth = 1;
+        ctx.strokeRect(x * CELL, y * CELL, CELL - 0.5, CELL - 0.5);
+      }
+    }
+  }
+
+  for (const d of dataItems) {
+    if (!d.collected) {
+      ctx.fillStyle = '#FFB347'; ctx.shadowBlur = 6; ctx.shadowColor = '#FF6600';
+      ctx.beginPath(); ctx.arc(d.x * CELL + CELL / 2, d.y * CELL + CELL / 2, CELL * 0.25, 0, Math.PI * 2); ctx.fill();
+      ctx.fillStyle = '#FF884D';
+      ctx.beginPath(); ctx.arc(d.x * CELL + CELL / 2, d.y * CELL + CELL / 2, CELL * 0.12, 0, Math.PI * 2); ctx.fill();
+      ctx.shadowBlur = 0;
+    }
+  }
+
+  ctx.fillStyle = '#FFA500'; ctx.globalAlpha = 0.7;
+  ctx.fillRect(exitPos.x * CELL + 4, exitPos.y * CELL + 4, CELL - 8, CELL - 8);
+  ctx.globalAlpha = 1; ctx.fillStyle = '#FFA500';
+  ctx.font = `${CELL - 6}px monospace`;
+  ctx.fillText('\u25C8', exitPos.x * CELL + CELL * 0.28, exitPos.y * CELL + CELL * 0.78);
+
+  if (dataCollected === totalData && gameState === 'play') {
+    ctx.fillStyle = '#FFB347'; ctx.shadowBlur = 4; ctx.shadowColor = '#FF6600';
+    ctx.font = 'bold 14px monospace'; ctx.textAlign = 'center';
+    ctx.fillText('\u2192 EXIT AVAILABLE \u2190', canvas.width / 2, 20);
+    ctx.shadowBlur = 0; ctx.textAlign = 'left';
+  }
+
+  for (const e of enemies) {
+    ctx.fillStyle = '#FF4400'; ctx.shadowBlur = 6; ctx.shadowColor = '#FF2200';
+    ctx.fillRect(e.x * CELL + 4, e.y * CELL + 4, CELL - 8, CELL - 8);
+    ctx.shadowBlur = 0;
+  }
+
+  ctx.fillStyle = '#FF6600'; ctx.shadowBlur = 8; ctx.shadowColor = '#FF6600';
+  ctx.fillRect(player.x * CELL + 6, player.y * CELL + 6, CELL - 12, CELL - 12);
+  ctx.fillStyle = '#FFB347';
+  ctx.fillRect(player.x * CELL + 10, player.y * CELL + 10, 5, 5);
+  ctx.shadowBlur = 0;
+}
+
+function gameLoop(now) {
+  if (gameState === 'play') {
+    let dx = 0, dy = 0;
+    if (keys.ArrowUp || keys.w) dy = -1;
+    if (keys.ArrowDown || keys.s) dy = 1;
+    if (keys.ArrowLeft || keys.a) dx = -1;
+    if (keys.ArrowRight || keys.d) dx = 1;
+    if ((dx !== 0 || dy !== 0) && now - lastPlayerMove >= MOVE_DELAY) {
+      tryMove(dx, dy); lastPlayerMove = now;
+    }
+    if (now - lastEnemyStep >= ENEMY_INTERVAL) { moveEnemies(); lastEnemyStep = now; }
+  }
+  draw();
+  requestAnimationFrame(gameLoop);
+}
+
+document.addEventListener('keydown', e => {
+  const key = e.key;
+  if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'w', 's', 'a', 'd', ' '].includes(key)) e.preventDefault();
+  if (key === ' ') { if (gameState === 'idle' || gameState === 'over') { newGame(); } return; }
+  keys[key] = true;
+  if (gameState === 'idle') newGame();
+});
+document.addEventListener('keyup', e => { keys[e.key] = false; });
+
+const dpadStart = () => { if (gameState === 'idle' || gameState === 'over') newGame(); };
+document.getElementById('btn-up').addEventListener('click', () => { dpadStart(); tryMove(0, -1); });
+document.getElementById('btn-down').addEventListener('click', () => { dpadStart(); tryMove(0, 1); });
+document.getElementById('btn-left').addEventListener('click', () => { dpadStart(); tryMove(-1, 0); });
+document.getElementById('btn-right').addEventListener('click', () => { dpadStart(); tryMove(1, 0); });
+
+generateLevel();
+requestAnimationFrame(gameLoop);
+</script>
+</body>
+</html>

+ 73 - 0
src/games/neoninfiltrator/thumbnail.svg

@@ -0,0 +1,73 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#050505">
+  <rect width="400" height="220" fill="#050505"/>
+
+  <line x1="28" y1="10" x2="28" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="56" y1="10" x2="56" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="84" y1="10" x2="84" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="112" y1="10" x2="112" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="140" y1="10" x2="140" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="168" y1="10" x2="168" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="196" y1="10" x2="196" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="224" y1="10" x2="224" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="252" y1="10" x2="252" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="280" y1="10" x2="280" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="308" y1="10" x2="308" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="336" y1="10" x2="336" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="364" y1="10" x2="364" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="0" y1="38" x2="392" y2="38" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="0" y1="66" x2="392" y2="66" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="0" y1="94" x2="392" y2="94" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="0" y1="122" x2="392" y2="122" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="0" y1="150" x2="392" y2="150" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="0" y1="178" x2="392" y2="178" stroke="#1a0d00" stroke-width="0.6"/>
+
+  <rect x="0" y="10" width="392" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="0" y="37.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="0" y="65" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="0" y="92.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="0" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="0" y="147.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="364.5" y="37.5" width="27.5" height="140" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="56" y="37.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="112" y="65" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="140" y="37.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="196" y="92.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="224" y="65" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="252" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="280" y="37.5" width="27.5" height="55" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="308" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="168" y="147.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="84" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+
+  <circle cx="71" cy="51" r="6" fill="#FFB347">
+    <animate attributeName="opacity" values="1;0.4;1" dur="1.2s" repeatCount="indefinite"/>
+  </circle>
+  <circle cx="71" cy="51" r="3" fill="#FF884D"/>
+
+  <circle cx="183" cy="107" r="6" fill="#FFB347">
+    <animate attributeName="opacity" values="0.4;1;0.4" dur="1.2s" repeatCount="indefinite"/>
+  </circle>
+  <circle cx="183" cy="107" r="3" fill="#FF884D"/>
+
+  <circle cx="323" cy="79" r="6" fill="#FFB347">
+    <animate attributeName="opacity" values="1;0.4;1" dur="0.9s" repeatCount="indefinite"/>
+  </circle>
+  <circle cx="323" cy="79" r="3" fill="#FF884D"/>
+
+  <rect x="212" y="129" width="12" height="12" fill="#FFA500" opacity="0.7"/>
+  <text x="218" y="140" font-family="monospace" font-size="11" fill="#FFA500" text-anchor="middle">&#9672;</text>
+
+  <rect x="237" y="46" width="20" height="20" fill="#FF4400">
+    <animate attributeName="opacity" values="1;0.6;1" dur="0.8s" repeatCount="indefinite"/>
+  </rect>
+  <rect x="353" y="101" width="20" height="20" fill="#FF4400">
+    <animate attributeName="opacity" values="0.6;1;0.6" dur="0.8s" repeatCount="indefinite"/>
+  </rect>
+
+  <rect x="35" y="46" width="18" height="18" fill="#FF6600">
+    <animate attributeName="opacity" values="1;0.7;1" dur="1s" repeatCount="indefinite"/>
+  </rect>
+  <rect x="39" y="50" width="7" height="7" fill="#FFB347"/>
+
+  <text x="200" y="212" font-family="'Courier New', monospace" font-size="13" fill="#FFA500" text-anchor="middle" font-weight="bold" letter-spacing="3">NEON INFILTRATOR</text>
+</svg>

+ 182 - 0
src/games/rockpaperscissors/index.html

@@ -0,0 +1,182 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Rock Paper Scissors</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+html, body { overflow: hidden; }
+body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; min-height: 100vh; }
+#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
+#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
+#topbar a:hover { text-decoration: underline; }
+h1 { color: #FFA500; font-size: 1.4rem; margin: 18px 0 6px; }
+#score-bar { display: flex; gap: 32px; font-size: 15px; color: #aaa; margin-bottom: 14px; }
+#score-bar span b { color: #FFA500; }
+#arena { display: flex; flex-direction: column; align-items: center; gap: 20px; width: 100%; max-width: 480px; padding: 0 12px; }
+#choices { display: flex; gap: 16px; }
+.choice-btn { font-size: 3rem; background: #111; border: 2px solid #333; border-radius: 12px; cursor: pointer; padding: 14px 20px; transition: border-color 0.15s, background 0.15s; line-height: 1; }
+.choice-btn:hover { border-color: #FFA500; background: #1a1a00; }
+.choice-btn.selected { border-color: #FFA500; background: #2a1a00; }
+#battle { display: flex; align-items: center; gap: 24px; font-size: 2.2rem; min-height: 72px; }
+#vs { font-size: 1rem; color: #555; }
+#result { font-size: 1.3rem; min-height: 32px; text-align: center; }
+.win { color: #4CAF50; } .lose { color: #e74c3c; } .draw { color: #FFA500; }
+#rounds { display: flex; gap: 8px; margin-top: 4px; }
+.round-dot { width: 14px; height: 14px; border-radius: 50%; background: #222; border: 2px solid #444; }
+.round-dot.win { background: #4CAF50; border-color: #4CAF50; }
+.round-dot.lose { background: #e74c3c; border-color: #e74c3c; }
+.round-dot.draw { background: #FFA500; border-color: #FFA500; }
+#play-btn { margin-top: 10px; padding: 10px 32px; font-family: monospace; font-size: 1rem; background: #FFA500; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; }
+#play-btn:hover { background: #ffb700; }
+#play-btn:disabled { background: #333; color: #666; cursor: default; }
+#final { font-size: 1.4rem; min-height: 36px; text-align: center; margin-top: 8px; }
+#hint { color: #555; font-size: 13px; margin-top: 4px; }
+</style>
+</head>
+<body>
+<div id="topbar">
+  <a href="/games" target="_top">&#8592; Back to Games</a>
+  <span style="color:#FFA500;font-weight:bold">ROCK PAPER SCISSORS</span>
+</div>
+<h1>Rock &middot; Paper &middot; Scissors</h1>
+<div id="score-bar">
+  <span>YOU: <b id="playerWins">0</b></span>
+  <span>AI: <b id="aiWins">0</b></span>
+  <span>DRAWS: <b id="draws">0</b></span>
+</div>
+<div id="arena">
+  <div id="choices">
+    <button class="choice-btn" data-choice="rock" title="Rock">&#9994;</button>
+    <button class="choice-btn" data-choice="paper" title="Paper">&#9995;</button>
+    <button class="choice-btn" data-choice="scissors" title="Scissors">✌️</button>
+  </div>
+  <div id="battle">
+    <span id="playerEmoji">?</span>
+    <span id="vs">VS</span>
+    <span id="aiEmoji">?</span>
+  </div>
+  <div id="result">&nbsp;</div>
+  <div id="rounds"></div>
+  <button id="play-btn" disabled>Choose a move!</button>
+  <div id="final">&nbsp;</div>
+  <div id="hint">Best of 5 rounds</div>
+  <div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
+    <form method="POST" action="/games/submit-score" target="_top">
+      <input type="hidden" name="game" value="rockpaperscissors">
+      <input type="hidden" id="scoreInput" name="score" value="0">
+      <button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
+    </form>
+  </div>
+</div>
+<script>
+const EMOJI = { rock: '\u270A', paper: '\u270B', scissors: '✌️' };
+const BEATS = { rock: 'scissors', scissors: 'paper', paper: 'rock' };
+const NAMES = { rock: 'Rock', paper: 'Paper', scissors: 'Scissors' };
+
+let playerWins = 0, aiWins = 0, draws = 0;
+let roundHistory = [];
+let chosen = null;
+let gameOver = false;
+let streak = 0, bestStreak = 0;
+const MAX_ROUNDS = 5;
+
+const playerWinsEl = document.getElementById('playerWins');
+const aiWinsEl = document.getElementById('aiWins');
+const drawsEl = document.getElementById('draws');
+const playerEmojiEl = document.getElementById('playerEmoji');
+const aiEmojiEl = document.getElementById('aiEmoji');
+const resultEl = document.getElementById('result');
+const roundsEl = document.getElementById('rounds');
+const playBtn = document.getElementById('play-btn');
+const finalEl = document.getElementById('final');
+
+function updateRounds() {
+  while (roundsEl.firstChild) roundsEl.removeChild(roundsEl.firstChild);
+  for (let i = 0; i < MAX_ROUNDS; i++) {
+    const dot = document.createElement('div');
+    dot.className = 'round-dot' + (roundHistory[i] ? ' ' + roundHistory[i] : '');
+    roundsEl.appendChild(dot);
+  }
+}
+
+document.querySelectorAll('.choice-btn').forEach(btn => {
+  btn.addEventListener('click', () => {
+    if (gameOver) return;
+    document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
+    btn.classList.add('selected');
+    chosen = btn.dataset.choice;
+    playBtn.disabled = false;
+    playBtn.textContent = 'PLAY!';
+  });
+});
+
+function onPlay() {
+  if (!chosen || gameOver) return;
+  const choices = ['rock', 'paper', 'scissors'];
+  const aiChoice = choices[Math.floor(Math.random() * 3)];
+
+  playerEmojiEl.textContent = EMOJI[chosen];
+  aiEmojiEl.textContent = EMOJI[aiChoice];
+
+  let outcome, outcomeClass;
+  if (chosen === aiChoice) {
+    outcome = 'DRAW!'; outcomeClass = 'draw';
+    draws++;
+    roundHistory.push('draw');
+    drawsEl.textContent = draws;
+  } else if (BEATS[chosen] === aiChoice) {
+    outcome = NAMES[chosen] + ' beats ' + NAMES[aiChoice] + ' \u2014 YOU WIN!'; outcomeClass = 'win';
+    playerWins++; streak++;
+    if (streak > bestStreak) bestStreak = streak;
+    roundHistory.push('win');
+    playerWinsEl.textContent = playerWins;
+  } else {
+    outcome = NAMES[aiChoice] + ' beats ' + NAMES[chosen] + ' \u2014 AI WINS!'; outcomeClass = 'lose';
+    aiWins++; streak = 0;
+    roundHistory.push('lose');
+    aiWinsEl.textContent = aiWins;
+  }
+
+  resultEl.className = outcomeClass;
+  resultEl.textContent = outcome;
+  updateRounds();
+
+  const total = playerWins + aiWins + draws;
+  if (playerWins >= 3 || aiWins >= 3 || total >= MAX_ROUNDS) {
+    gameOver = true;
+    if (playerWins > aiWins) { finalEl.className = 'win'; finalEl.textContent = 'YOU WIN THE MATCH! Best streak: ' + bestStreak; document.getElementById('scoreInput').value = bestStreak; document.getElementById('scoreSubmit').style.display = 'block'; }
+    else if (aiWins > playerWins) { finalEl.className = 'lose'; finalEl.textContent = 'AI WINS THE MATCH!'; }
+    else { finalEl.className = 'draw'; finalEl.textContent = "IT'S A TIE!"; }
+    playBtn.textContent = 'New Game';
+    playBtn.disabled = false;
+    playBtn.onclick = resetGame;
+  } else {
+    chosen = null;
+    document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
+    playBtn.disabled = true;
+    playBtn.textContent = 'Choose a move!';
+  }
+}
+
+playBtn.addEventListener('click', onPlay);
+
+function resetGame() {
+  playerWins = 0; aiWins = 0; draws = 0; roundHistory = []; chosen = null; gameOver = false; streak = 0; bestStreak = 0;
+  playerWinsEl.textContent = '0'; aiWinsEl.textContent = '0'; drawsEl.textContent = '0';
+  playerEmojiEl.textContent = '?'; aiEmojiEl.textContent = '?';
+  resultEl.textContent = '\u00a0'; resultEl.className = '';
+  finalEl.textContent = '\u00a0'; finalEl.className = '';
+  document.getElementById('scoreSubmit').style.display = 'none';
+  document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
+  playBtn.disabled = true;
+  playBtn.textContent = 'Choose a move!';
+  playBtn.onclick = onPlay;
+  updateRounds();
+}
+
+updateRounds();
+</script>
+</body>
+</html>

+ 17 - 0
src/games/rockpaperscissors/thumbnail.svg

@@ -0,0 +1,17 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220">
+  <rect width="400" height="220" fill="#000"/>
+  <rect x="30" y="55" width="90" height="100" rx="14" fill="#111" stroke="#FFA500" stroke-width="2"/>
+  <rect x="42" y="38" width="66" height="26" rx="10" fill="#111" stroke="#FFA500" stroke-width="2"/>
+  <rect x="42" y="56" width="22" height="10" rx="4" fill="#FFA500" opacity="0.3"/>
+  <rect x="68" y="56" width="22" height="10" rx="4" fill="#FFA500" opacity="0.3"/>
+  <ellipse cx="75" cy="105" rx="28" ry="34" fill="#FFA500" opacity="0.15"/>
+  <text x="75" y="115" font-family="monospace" font-size="30" fill="#FFA500" text-anchor="middle">✊</text>
+  <text x="75" y="170" font-family="monospace" font-size="10" fill="#FFA500" text-anchor="middle">ROCK</text>
+  <rect x="155" y="40" width="90" height="115" rx="14" fill="#111" stroke="#4CAF50" stroke-width="2"/>
+  <text x="200" y="115" font-family="monospace" font-size="30" fill="#4CAF50" text-anchor="middle">✋</text>
+  <text x="200" y="170" font-family="monospace" font-size="10" fill="#4CAF50" text-anchor="middle">PAPER</text>
+  <rect x="280" y="55" width="90" height="100" rx="14" fill="#111" stroke="#FFA500" stroke-width="2"/>
+  <text x="325" y="115" font-family="monospace" font-size="30" fill="#FFA500" text-anchor="middle">✌️</text>
+  <text x="325" y="170" font-family="monospace" font-size="10" fill="#FFA500" text-anchor="middle">SCISSORS</text>
+  <text x="200" y="210" font-family="monospace" font-size="11" fill="#FFA500" text-anchor="middle">Rock · Paper · Scissors</text>
+</svg>

+ 144 - 136
src/games/tiktaktoe/index.html

@@ -3,7 +3,7 @@
 <head>
 <head>
 <meta charset="UTF-8">
 <meta charset="UTF-8">
 <meta name="viewport" content="width=device-width, initial-scale=1.0">
 <meta name="viewport" content="width=device-width, initial-scale=1.0">
-<title>TikTakToe — Rock Paper Scissors</title>
+<title>TikTakToe</title>
 <style>
 <style>
 * { margin: 0; padding: 0; box-sizing: border-box; }
 * { margin: 0; padding: 0; box-sizing: border-box; }
 html, body { overflow: hidden; }
 html, body { overflow: hidden; }
@@ -14,169 +14,177 @@ body { background: #000; color: #eee; font-family: monospace; display: flex; fle
 h1 { color: #FFA500; font-size: 1.4rem; margin: 18px 0 6px; }
 h1 { color: #FFA500; font-size: 1.4rem; margin: 18px 0 6px; }
 #score-bar { display: flex; gap: 32px; font-size: 15px; color: #aaa; margin-bottom: 14px; }
 #score-bar { display: flex; gap: 32px; font-size: 15px; color: #aaa; margin-bottom: 14px; }
 #score-bar span b { color: #FFA500; }
 #score-bar span b { color: #FFA500; }
-#arena { display: flex; flex-direction: column; align-items: center; gap: 20px; width: 100%; max-width: 480px; padding: 0 12px; }
-#choices { display: flex; gap: 16px; }
-.choice-btn { font-size: 3rem; background: #111; border: 2px solid #333; border-radius: 12px; cursor: pointer; padding: 14px 20px; transition: border-color 0.15s, background 0.15s; line-height: 1; }
-.choice-btn:hover { border-color: #FFA500; background: #1a1a00; }
-.choice-btn.selected { border-color: #FFA500; background: #2a1a00; }
-#battle { display: flex; align-items: center; gap: 24px; font-size: 2.2rem; min-height: 72px; }
-#vs { font-size: 1rem; color: #555; }
-#result { font-size: 1.3rem; min-height: 32px; text-align: center; }
-.win { color: #4CAF50; } .lose { color: #e74c3c; } .draw { color: #FFA500; }
-#rounds { display: flex; gap: 8px; margin-top: 4px; }
-.round-dot { width: 14px; height: 14px; border-radius: 50%; background: #222; border: 2px solid #444; }
-.round-dot.win { background: #4CAF50; border-color: #4CAF50; }
-.round-dot.lose { background: #e74c3c; border-color: #e74c3c; }
-.round-dot.draw { background: #FFA500; border-color: #FFA500; }
-#play-btn { margin-top: 10px; padding: 10px 32px; font-family: monospace; font-size: 1rem; background: #FFA500; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; }
-#play-btn:hover { background: #ffb700; }
-#play-btn:disabled { background: #333; color: #666; cursor: default; }
-#final { font-size: 1.4rem; min-height: 36px; text-align: center; margin-top: 8px; }
-#hint { color: #555; font-size: 13px; margin-top: 4px; }
+#board { display: grid; grid-template-columns: repeat(3, 90px); grid-template-rows: repeat(3, 90px); gap: 6px; margin: 10px 0; }
+.cell { width: 90px; height: 90px; background: #111; border: 2px solid #333; border-radius: 8px; font-size: 2.6rem; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: border-color 0.15s, background 0.15s; }
+.cell:hover:not(.taken) { border-color: #FFA500; background: #1a1a00; }
+.cell.x { color: #FFA500; cursor: default; }
+.cell.o { color: #4CAF50; cursor: default; }
+.cell.taken { cursor: default; }
+.cell.win-cell { border-color: #FFA500; background: #1a1200; }
+#status { font-size: 1.2rem; min-height: 32px; text-align: center; margin: 8px 0; }
+.win { color: #4CAF50; } .lose { color: #e74c3c; } .draw { color: #FFA500; } .thinking { color: #555; }
+#new-btn { margin-top: 10px; padding: 10px 32px; font-family: monospace; font-size: 1rem; background: #FFA500; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; }
+#new-btn:hover { background: #ffb700; }
+#scoreSubmit { display: none; text-align: center; margin: 8px; }
+#scoreSubmit form button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
 </style>
 </style>
 </head>
 </head>
 <body>
 <body>
 <div id="topbar">
 <div id="topbar">
   <a href="/games" target="_top">&#8592; Back to Games</a>
   <a href="/games" target="_top">&#8592; Back to Games</a>
-  <span style="color:#FFA500;font-weight:bold">ROCK PAPER SCISSORS</span>
+  <span style="color:#FFA500;font-weight:bold">TIKTAKTOE</span>
 </div>
 </div>
-<h1>Rock &middot; Paper &middot; Scissors</h1>
+<h1>Tic &middot; Tac &middot; Toe</h1>
 <div id="score-bar">
 <div id="score-bar">
-  <span>YOU: <b id="playerWins">0</b></span>
-  <span>AI: <b id="aiWins">0</b></span>
-  <span>DRAWS: <b id="draws">0</b></span>
+  <span>YOU (X): <b id="winsEl">0</b></span>
+  <span>AI (O): <b id="lossesEl">0</b></span>
+  <span>DRAWS: <b id="drawsEl">0</b></span>
 </div>
 </div>
-<div id="arena">
-  <div id="choices">
-    <button class="choice-btn" data-choice="rock" title="Rock">&#9994;</button>
-    <button class="choice-btn" data-choice="paper" title="Paper">&#9995;</button>
-    <button class="choice-btn" data-choice="scissors" title="Scissors">✌️</button>
-  </div>
-  <div id="battle">
-    <span id="playerEmoji">?</span>
-    <span id="vs">VS</span>
-    <span id="aiEmoji">?</span>
-  </div>
-  <div id="result">&nbsp;</div>
-  <div id="rounds"></div>
-  <button id="play-btn" disabled>Choose a move!</button>
-  <div id="final">&nbsp;</div>
-  <div id="hint">Best of 5 rounds</div>
-  <div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
-    <form method="POST" action="/games/submit-score" target="_top">
-      <input type="hidden" name="game" value="tiktaktoe">
-      <input type="hidden" id="scoreInput" name="score" value="0">
-      <button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
-    </form>
-  </div>
+<div id="board"></div>
+<div id="status">&nbsp;</div>
+<button id="new-btn">New Game</button>
+<div id="scoreSubmit">
+  <form method="POST" action="/games/submit-score" target="_top">
+    <input type="hidden" name="game" value="tiktaktoe">
+    <input type="hidden" id="scoreInput" name="score" value="0">
+    <button type="submit">Submit Score to Hall of Fame</button>
+  </form>
 </div>
 </div>
 <script>
 <script>
-const EMOJI = { rock: '\u270A', paper: '\u270B', scissors: '✌️' };
-const BEATS = { rock: 'scissors', scissors: 'paper', paper: 'rock' };
-const NAMES = { rock: 'Rock', paper: 'Paper', scissors: 'Scissors' };
+const LINES = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]];
+let board, gameOver, wins, losses, draws;
 
 
-let playerWins = 0, aiWins = 0, draws = 0;
-let roundHistory = [];
-let chosen = null;
-let gameOver = false;
-let streak = 0, bestStreak = 0;
-const MAX_ROUNDS = 5;
+function init() {
+  wins = 0; losses = 0; draws = 0;
+  newGame();
+}
 
 
-const playerWinsEl = document.getElementById('playerWins');
-const aiWinsEl = document.getElementById('aiWins');
-const drawsEl = document.getElementById('draws');
-const playerEmojiEl = document.getElementById('playerEmoji');
-const aiEmojiEl = document.getElementById('aiEmoji');
-const resultEl = document.getElementById('result');
-const roundsEl = document.getElementById('rounds');
-const playBtn = document.getElementById('play-btn');
-const finalEl = document.getElementById('final');
+function newGame() {
+  board = Array(9).fill(null);
+  gameOver = false;
+  document.getElementById('scoreSubmit').style.display = 'none';
+  setStatus('\u00a0', '');
+  renderBoard();
+}
 
 
-function updateRounds() {
-  while (roundsEl.firstChild) roundsEl.removeChild(roundsEl.firstChild);
-  for (let i = 0; i < MAX_ROUNDS; i++) {
-    const dot = document.createElement('div');
-    dot.className = 'round-dot' + (roundHistory[i] ? ' ' + roundHistory[i] : '');
-    roundsEl.appendChild(dot);
+function renderBoard() {
+  const el = document.getElementById('board');
+  while (el.firstChild) el.removeChild(el.firstChild);
+  const winner = checkWinner(board);
+  for (let i = 0; i < 9; i++) {
+    const cell = document.createElement('div');
+    cell.className = 'cell' + (board[i] ? ' taken ' + board[i] : '');
+    if (winner && winner.line.includes(i)) cell.className += ' win-cell';
+    if (board[i]) {
+      const t = document.createTextNode(board[i] === 'x' ? 'X' : 'O');
+      cell.appendChild(t);
+    }
+    const idx = i;
+    cell.addEventListener('click', () => playerMove(idx));
+    el.appendChild(cell);
   }
   }
 }
 }
 
 
-document.querySelectorAll('.choice-btn').forEach(btn => {
-  btn.addEventListener('click', () => {
-    if (gameOver) return;
-    document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
-    btn.classList.add('selected');
-    chosen = btn.dataset.choice;
-    playBtn.disabled = false;
-    playBtn.textContent = 'PLAY!';
-  });
-});
+function setStatus(msg, cls) {
+  const el = document.getElementById('status');
+  el.className = cls;
+  el.textContent = msg;
+}
 
 
-function onPlay() {
-  if (!chosen || gameOver) return;
-  const choices = ['rock', 'paper', 'scissors'];
-  const aiChoice = choices[Math.floor(Math.random() * 3)];
+function checkWinner(b) {
+  for (const [a, c, d] of LINES) {
+    if (b[a] && b[a] === b[c] && b[a] === b[d]) return { winner: b[a], line: [a, c, d] };
+  }
+  return null;
+}
 
 
-  playerEmojiEl.textContent = EMOJI[chosen];
-  aiEmojiEl.textContent = EMOJI[aiChoice];
+function playerMove(idx) {
+  if (gameOver || board[idx]) return;
+  board[idx] = 'x';
+  renderBoard();
+  const res = checkWinner(board);
+  if (res) { endGame('win'); return; }
+  if (board.every(c => c)) { endGame('draw'); return; }
+  setStatus('Thinking...', 'thinking');
+  setTimeout(aiMove, 200);
+}
 
 
-  let outcome, outcomeClass;
-  if (chosen === aiChoice) {
-    outcome = 'DRAW!'; outcomeClass = 'draw';
-    draws++;
-    roundHistory.push('draw');
-    drawsEl.textContent = draws;
-  } else if (BEATS[chosen] === aiChoice) {
-    outcome = NAMES[chosen] + ' beats ' + NAMES[aiChoice] + ' \u2014 YOU WIN!'; outcomeClass = 'win';
-    playerWins++; streak++;
-    if (streak > bestStreak) bestStreak = streak;
-    roundHistory.push('win');
-    playerWinsEl.textContent = playerWins;
+function aiMove() {
+  const move = bestMove(board);
+  if (move === -1) { endGame('draw'); return; }
+  board[move] = 'o';
+  renderBoard();
+  const res = checkWinner(board);
+  if (res) { endGame('lose'); return; }
+  if (board.every(c => c)) { endGame('draw'); return; }
+  setStatus('\u00a0', '');
+}
+
+function endGame(outcome) {
+  gameOver = true;
+  if (outcome === 'win') {
+    wins++;
+    document.getElementById('winsEl').textContent = wins;
+    setStatus('You win!', 'win');
+    document.getElementById('scoreInput').value = wins;
+    document.getElementById('scoreSubmit').style.display = 'block';
+  } else if (outcome === 'lose') {
+    losses++;
+    document.getElementById('lossesEl').textContent = losses;
+    setStatus('AI wins!', 'lose');
   } else {
   } else {
-    outcome = NAMES[aiChoice] + ' beats ' + NAMES[chosen] + ' \u2014 AI WINS!'; outcomeClass = 'lose';
-    aiWins++; streak = 0;
-    roundHistory.push('lose');
-    aiWinsEl.textContent = aiWins;
+    draws++;
+    document.getElementById('drawsEl').textContent = draws;
+    setStatus("It's a draw!", 'draw');
   }
   }
+}
 
 
-  resultEl.className = outcomeClass;
-  resultEl.textContent = outcome;
-  updateRounds();
-
-  const total = playerWins + aiWins + draws;
-  if (playerWins >= 3 || aiWins >= 3 || total >= MAX_ROUNDS) {
-    gameOver = true;
-    if (playerWins > aiWins) { finalEl.className = 'win'; finalEl.textContent = 'YOU WIN THE MATCH! Best streak: ' + bestStreak; document.getElementById('scoreInput').value = bestStreak; document.getElementById('scoreSubmit').style.display = 'block'; }
-    else if (aiWins > playerWins) { finalEl.className = 'lose'; finalEl.textContent = 'AI WINS THE MATCH!'; }
-    else { finalEl.className = 'draw'; finalEl.textContent = "IT'S A TIE!"; }
-    playBtn.textContent = 'New Game';
-    playBtn.disabled = false;
-    playBtn.onclick = resetGame;
+function minimax(b, isMax, alpha, beta) {
+  const res = checkWinner(b);
+  if (res) return res.winner === 'o' ? 10 : -10;
+  if (b.every(c => c)) return 0;
+  if (isMax) {
+    let best = -Infinity;
+    for (let i = 0; i < 9; i++) {
+      if (!b[i]) {
+        b[i] = 'o';
+        best = Math.max(best, minimax(b, false, alpha, beta));
+        b[i] = null;
+        alpha = Math.max(alpha, best);
+        if (beta <= alpha) break;
+      }
+    }
+    return best;
   } else {
   } else {
-    chosen = null;
-    document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
-    playBtn.disabled = true;
-    playBtn.textContent = 'Choose a move!';
+    let best = Infinity;
+    for (let i = 0; i < 9; i++) {
+      if (!b[i]) {
+        b[i] = 'x';
+        best = Math.min(best, minimax(b, true, alpha, beta));
+        b[i] = null;
+        beta = Math.min(beta, best);
+        if (beta <= alpha) break;
+      }
+    }
+    return best;
   }
   }
 }
 }
 
 
-playBtn.addEventListener('click', onPlay);
-
-function resetGame() {
-  playerWins = 0; aiWins = 0; draws = 0; roundHistory = []; chosen = null; gameOver = false; streak = 0; bestStreak = 0;
-  playerWinsEl.textContent = '0'; aiWinsEl.textContent = '0'; drawsEl.textContent = '0';
-  playerEmojiEl.textContent = '?'; aiEmojiEl.textContent = '?';
-  resultEl.textContent = '\u00a0'; resultEl.className = '';
-  finalEl.textContent = '\u00a0'; finalEl.className = '';
-  document.getElementById('scoreSubmit').style.display = 'none';
-  document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
-  playBtn.disabled = true;
-  playBtn.textContent = 'Choose a move!';
-  playBtn.onclick = onPlay;
-  updateRounds();
+function bestMove(b) {
+  let best = -Infinity, move = -1;
+  for (let i = 0; i < 9; i++) {
+    if (!b[i]) {
+      b[i] = 'o';
+      const score = minimax(b, false, -Infinity, Infinity);
+      b[i] = null;
+      if (score > best) { best = score; move = i; }
+    }
+  }
+  return move;
 }
 }
 
 
-updateRounds();
+document.getElementById('new-btn').addEventListener('click', newGame);
+init();
 </script>
 </script>
 </body>
 </body>
 </html>
 </html>

+ 15 - 24
src/games/tiktaktoe/thumbnail.svg

@@ -1,27 +1,18 @@
 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220">
 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220">
   <rect width="400" height="220" fill="#000"/>
   <rect width="400" height="220" fill="#000"/>
-  <rect x="120" y="20" width="160" height="180" rx="8" fill="#111"/>
-  <line x1="174" y1="28" x2="174" y2="192" stroke="#444" stroke-width="3"/>
-  <line x1="226" y1="28" x2="226" y2="192" stroke="#444" stroke-width="3"/>
-  <line x1="128" y1="80" x2="272" y2="80" stroke="#444" stroke-width="3"/>
-  <line x1="128" y1="132" x2="272" y2="132" stroke="#444" stroke-width="3"/>
-  <g stroke="#FFA500" stroke-width="4" fill="none" stroke-linecap="round">
-    <line x1="135" y1="35" x2="165" y2="68"/>
-    <line x1="165" y1="35" x2="135" y2="68"/>
-  </g>
-  <circle cx="200" cy="52" r="16" stroke="#4CAF50" stroke-width="4" fill="none"/>
-  <g stroke="#FFA500" stroke-width="4" fill="none" stroke-linecap="round">
-    <line x1="238" y1="35" x2="268" y2="68"/>
-    <line x1="268" y1="35" x2="238" y2="68"/>
-  </g>
-  <circle cx="151" cy="106" r="16" stroke="#4CAF50" stroke-width="4" fill="none"/>
-  <g stroke="#FFA500" stroke-width="4" fill="none" stroke-linecap="round">
-    <line x1="187" y1="88" x2="217" y2="122"/>
-    <line x1="217" y1="88" x2="187" y2="122"/>
-  </g>
-  <circle cx="253" cy="106" r="16" stroke="#4CAF50" stroke-width="4" fill="none"/>
-  <circle cx="151" cy="158" r="16" stroke="#4CAF50" stroke-width="4" fill="none"/>
-  <circle cx="200" cy="158" r="16" stroke="#4CAF50" stroke-width="4" fill="none"/>
-  <line x1="120" y1="158" x2="280" y2="158" stroke="#FFA500" stroke-width="5" opacity="0.6"/>
-  <text x="200" y="215" font-family="monospace" font-size="11" fill="#FFA500" text-anchor="middle">Rock · Paper · Scissors</text>
+  <line x1="147" y1="40" x2="147" y2="180" stroke="#444" stroke-width="3"/>
+  <line x1="253" y1="40" x2="253" y2="180" stroke="#444" stroke-width="3"/>
+  <line x1="80" y1="93" x2="320" y2="93" stroke="#444" stroke-width="3"/>
+  <line x1="80" y1="127" x2="320" y2="127" stroke="#444" stroke-width="3"/>
+  <text x="113" y="88" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
+  <text x="200" y="88" font-family="monospace" font-size="36" font-weight="bold" fill="#4CAF50" text-anchor="middle">O</text>
+  <text x="287" y="88" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
+  <text x="113" y="122" font-family="monospace" font-size="36" font-weight="bold" fill="#4CAF50" text-anchor="middle">O</text>
+  <text x="200" y="122" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
+  <text x="287" y="122" font-family="monospace" font-size="36" font-weight="bold" fill="#4CAF50" text-anchor="middle">O</text>
+  <text x="113" y="170" font-family="monospace" font-size="36" font-weight="bold" fill="#4CAF50" text-anchor="middle">O</text>
+  <text x="200" y="170" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
+  <text x="287" y="170" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
+  <line x1="80" y1="40" x2="320" y2="180" stroke="#FFA500" stroke-width="3" opacity="0.5"/>
+  <text x="200" y="210" font-family="monospace" font-size="11" fill="#FFA500" text-anchor="middle">Tic · Tac · Toe</text>
 </svg>
 </svg>

+ 4 - 1
src/models/activity_model.js

@@ -228,6 +228,7 @@ module.exports = ({ cooler }) => {
             const baseId = tip.id;
             const baseId = tip.id;
             const baseTitle = (tip.content && tip.content.title) || '';
             const baseTitle = (tip.content && tip.content.title) || '';
             const isAnonymous = tip.content && typeof tip.content.isAnonymous === 'boolean' ? tip.content.isAnonymous : false;
             const isAnonymous = tip.content && typeof tip.content.isAnonymous === 'boolean' ? tip.content.isAnonymous : false;
+            if (isAnonymous) continue;
 
 
             const uniq = (xs) => Array.from(new Set((Array.isArray(xs) ? xs : []).filter(x => typeof x === 'string' && x.trim().length)));
             const uniq = (xs) => Array.from(new Set((Array.isArray(xs) ? xs : []).filter(x => typeof x === 'string' && x.trim().length)));
             const toSet = (xs) => new Set(uniq(xs));
             const toSet = (xs) => new Set(uniq(xs));
@@ -494,13 +495,15 @@ module.exports = ({ cooler }) => {
       deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x });
       deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x });
 
 
       const tribeInternalTypes = new Set(['tribeLeave', 'tribeFeedPost', 'tribeFeedRefeed', 'tribe-content']);
       const tribeInternalTypes = new Set(['tribeLeave', 'tribeFeedPost', 'tribeFeedRefeed', 'tribe-content']);
-      const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent']);
+      const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'feed-action']);
       const isAllowedTribeActivity = (a) => !tribeInternalTypes.has(a.type);
       const isAllowedTribeActivity = (a) => !tribeInternalTypes.has(a.type);
       const isVisible = (a) => {
       const isVisible = (a) => {
         if (hiddenTypes.has(a.type)) return false;
         if (hiddenTypes.has(a.type)) return false;
         if (a.type === 'pad' && (a.content || {}).status !== 'OPEN') return false;
         if (a.type === 'pad' && (a.content || {}).status !== 'OPEN') return false;
         if (a.type === 'chat' && (a.content || {}).status !== 'OPEN') return false;
         if (a.type === 'chat' && (a.content || {}).status !== 'OPEN') return false;
         if (a.type === 'calendar' && (a.content || {}).status !== 'OPEN') return false;
         if (a.type === 'calendar' && (a.content || {}).status !== 'OPEN') return false;
+        if (a.type === 'event' && String((a.content || {}).isPublic || '').toLowerCase() === 'private' && (a.content || {}).organizer !== userId && !(Array.isArray((a.content || {}).attendees) && (a.content || {}).attendees.includes(userId))) return false;
+        if (a.type === 'task' && String((a.content || {}).isPublic || '').toUpperCase() === 'PRIVATE' && a.author !== userId && !(Array.isArray((a.content || {}).assignees) && (a.content || {}).assignees.includes(userId))) return false;
         return true;
         return true;
       };
       };
 
 

+ 4 - 1
src/models/chats_model.js

@@ -447,7 +447,10 @@ module.exports = ({ cooler, tribeCrypto }) => {
       const chat = await this.getChatById(chatId)
       const chat = await this.getChatById(chatId)
       if (!chat) throw new Error("Chat not found")
       if (!chat) throw new Error("Chat not found")
       if (chat.status === "CLOSED") throw new Error("Chat is closed")
       if (chat.status === "CLOSED") throw new Error("Chat is closed")
-      if (!chat.members.includes(userId)) throw new Error("Not a participant")
+      if (!chat.members.includes(userId)) {
+        if (chat.status === "OPEN") await this.joinChat(chatId)
+        else throw new Error("Not a participant")
+      }
 
 
       const messages = await readAll(ssbClient)
       const messages = await readAll(ssbClient)
       const oneHourAgo = Date.now() - 60 * 60 * 1000
       const oneHourAgo = Date.now() - 60 * 60 * 1000

+ 183 - 32
src/models/courts_model.js

@@ -9,7 +9,13 @@ const CASE_DECISION_DAYS = 21;
 const POPULAR_DAYS = 14;
 const POPULAR_DAYS = 14;
 const FEED_ID_RE = /^@.+\.ed25519$/;
 const FEED_ID_RE = /^@.+\.ed25519$/;
 
 
-module.exports = ({ cooler, services = {} }) => {
+const CASE_FIELDS = ['title', 'accuser', 'respondentId', 'mediatorsAccuser', 'mediatorsRespondent'];
+const EVIDENCE_FIELDS = ['text', 'link', 'imageUrl'];
+const ANSWER_FIELDS = ['stance', 'text'];
+const VERDICT_FIELDS = ['result', 'orders'];
+const SETTLEMENT_FIELDS = ['terms'];
+
+module.exports = ({ cooler, services = {}, tribeCrypto }) => {
   let ssb;
   let ssb;
   let userId;
   let userId;
 
 
@@ -24,6 +30,45 @@ module.exports = ({ cooler, services = {} }) => {
   const nowISO = () => new Date().toISOString();
   const nowISO = () => new Date().toISOString();
   const ensureArray = (x) => (Array.isArray(x) ? x : x ? [x] : []);
   const ensureArray = (x) => (Array.isArray(x) ? x : x ? [x] : []);
 
 
+  const encryptFields = (content, keyHex, fields) => {
+    const payload = {};
+    for (const f of fields) {
+      if (content[f] !== undefined) payload[f] = content[f];
+    }
+    const enc = tribeCrypto.encryptWithKey(JSON.stringify(payload), keyHex);
+    const result = { ...content };
+    for (const f of fields) delete result[f];
+    result.encryptedPayload = enc;
+    return result;
+  };
+
+  const decryptFields = (content, keyHex) => {
+    if (!content || !content.encryptedPayload) return content;
+    try {
+      const plain = tribeCrypto.decryptWithKey(content.encryptedPayload, keyHex);
+      const payload = JSON.parse(plain);
+      const result = { ...content };
+      delete result.encryptedPayload;
+      return { ...result, ...payload };
+    } catch (e) {
+      return { ...content, encrypted: true };
+    }
+  };
+
+  const getCaseKey = (obj) => {
+    if (!tribeCrypto || !obj) return null;
+    return tribeCrypto.getKey(obj.rootCaseId || obj.id);
+  };
+
+  const distributeKey = async (caseKey, caseRootId, recipientId) => {
+    if (!tribeCrypto || !recipientId) return;
+    const ssbClient = await openSsb();
+    const ssbKeys = require('../server/node_modules/ssb-keys');
+    const boxed = tribeCrypto.boxKeyForMember(caseKey, recipientId, ssbKeys);
+    const content = { type: 'courts-key', caseRootId, for: recipientId, memberKey: boxed };
+    await new Promise((res, rej) => ssbClient.publish(content, (e) => e ? rej(e) : res()));
+  };
+
   async function readLog() {
   async function readLog() {
     const ssbClient = await openSsb();
     const ssbClient = await openSsb();
     return new Promise((resolve, reject) => {
     return new Promise((resolve, reject) => {
@@ -119,14 +164,36 @@ module.exports = ({ cooler, services = {} }) => {
       mediatorsRespondent: [],
       mediatorsRespondent: [],
       createdAt: openedAt
       createdAt: openedAt
     };
     };
-    return await new Promise((resolve, reject) =>
+
+    if (tribeCrypto) {
+      const initialMsg = await new Promise((res, rej) =>
+        ssbClient.publish(content, (err, msg) => (err ? rej(err) : res(msg)))
+      );
+      const caseRootId = initialMsg.key;
+      const caseKey = tribeCrypto.generateTribeKey();
+      tribeCrypto.setKey(caseRootId, caseKey, 1);
+      const encrypted = encryptFields({ ...content, rootCaseId: caseRootId }, caseKey, CASE_FIELDS);
+      const update = { ...encrypted, replaces: caseRootId, updatedAt: openedAt };
+      const finalMsg = await new Promise((res, rej) =>
+        ssbClient.publish(update, (err, msg) => (err ? rej(err) : res(msg)))
+      );
+      await distributeKey(caseKey, caseRootId, resp.id);
+      return finalMsg;
+    }
+
+    return new Promise((resolve, reject) =>
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
     );
     );
   }
   }
 
 
   async function listCases(filter = 'open') {
   async function listCases(filter = 'open') {
     const all = await listByType('courtsCase');
     const all = await listByType('courtsCase');
-    const sorted = all.sort((a, b) => {
+    const decrypted = all.map(c => {
+      if (!tribeCrypto) return c;
+      const key = getCaseKey(c);
+      return key ? decryptFields(c, key) : c;
+    });
+    const sorted = decrypted.sort((a, b) => {
       const ta = new Date(a.openedAt || a.createdAt || 0).getTime();
       const ta = new Date(a.openedAt || a.createdAt || 0).getTime();
       const tb = new Date(b.openedAt || b.createdAt || 0).getTime();
       const tb = new Date(b.openedAt || b.createdAt || 0).getTime();
       return tb - ta;
       return tb - ta;
@@ -150,7 +217,8 @@ module.exports = ({ cooler, services = {} }) => {
     const all = await listByType('courtsCase');
     const all = await listByType('courtsCase');
     const id = String(uid || userId || '');
     const id = String(uid || userId || '');
     const rows = [];
     const rows = [];
-    for (const c of all) {
+    for (const raw of all) {
+      const c = tribeCrypto ? (getCaseKey(raw) ? decryptFields(raw, getCaseKey(raw)) : raw) : raw;
       const isAccuser = String(c.accuser || '') === id;
       const isAccuser = String(c.accuser || '') === id;
       const isRespondent = String(c.respondentId || '') === id;
       const isRespondent = String(c.respondentId || '') === id;
       const ma = ensureArray(c.mediatorsAccuser || []);
       const ma = ensureArray(c.mediatorsAccuser || []);
@@ -190,19 +258,24 @@ module.exports = ({ cooler, services = {} }) => {
     const id = String(caseId || '').trim();
     const id = String(caseId || '').trim();
     if (!id) return null;
     if (!id) return null;
     const all = await listByType('courtsCase');
     const all = await listByType('courtsCase');
-    return all.find((c) => c.id === id) || null;
+    const found = all.find((c) => c.id === id) || null;
+    if (!found || !tribeCrypto) return found;
+    const caseKey = getCaseKey(found);
+    if (!caseKey) return found;
+    return decryptFields(found, caseKey);
   }
   }
 
 
   async function upsertCase(obj) {
   async function upsertCase(obj) {
     const ssbClient = await openSsb();
     const ssbClient = await openSsb();
     const { id, ...rest } = obj;
     const { id, ...rest } = obj;
-    const updated = {
-      ...rest,
-      type: 'courtsCase',
-      replaces: id,
-      updatedAt: nowISO()
-    };
-    return await new Promise((resolve, reject) =>
+    let updated = { ...rest, type: 'courtsCase', replaces: id, updatedAt: nowISO() };
+    if (tribeCrypto) {
+      const caseKey = getCaseKey(updated);
+      if (caseKey) {
+        updated = encryptFields(updated, caseKey, CASE_FIELDS);
+      }
+    }
+    return new Promise((resolve, reject) =>
       ssbClient.publish(updated, (err, msg) => (err ? reject(err) : resolve(msg)))
       ssbClient.publish(updated, (err, msg) => (err ? reject(err) : resolve(msg)))
     );
     );
   }
   }
@@ -237,6 +310,17 @@ module.exports = ({ cooler, services = {} }) => {
     const clean = list.filter((id) => id !== c.accuser && id !== c.respondentId);
     const clean = list.filter((id) => id !== c.accuser && id !== c.respondentId);
     if (side === 'accuser') c.mediatorsAccuser = clean;
     if (side === 'accuser') c.mediatorsAccuser = clean;
     else c.mediatorsRespondent = clean;
     else c.mediatorsRespondent = clean;
+
+    if (tribeCrypto) {
+      const caseKey = getCaseKey(c);
+      if (caseKey) {
+        const caseRootId = c.rootCaseId || c.id;
+        for (const mediatorId of clean) {
+          await distributeKey(caseKey, caseRootId, mediatorId);
+        }
+      }
+    }
+
     await upsertCase(c);
     await upsertCase(c);
     return c;
     return c;
   }
   }
@@ -255,6 +339,14 @@ module.exports = ({ cooler, services = {} }) => {
       throw new Error('Judge cannot be a party of the case.');
       throw new Error('Judge cannot be a party of the case.');
     }
     }
     c.judgeId = id;
     c.judgeId = id;
+
+    if (tribeCrypto) {
+      const caseKey = getCaseKey(c);
+      if (caseKey) {
+        await distributeKey(caseKey, c.rootCaseId || c.id, id);
+      }
+    }
+
     await upsertCase(c);
     await upsertCase(c);
     return c;
     return c;
   }
   }
@@ -273,9 +365,9 @@ module.exports = ({ cooler, services = {} }) => {
     }
     }
     if (!t && !l && !imageUrl) throw new Error('Text, link or image is required.');
     if (!t && !l && !imageUrl) throw new Error('Text, link or image is required.');
     const ssbClient = await openSsb();
     const ssbClient = await openSsb();
-    const content = {
+    let content = {
       type: 'courtsEvidence',
       type: 'courtsEvidence',
-      caseId: c.id,
+      caseId: c.rootCaseId || c.id,
       author: userId,
       author: userId,
       role,
       role,
       text: t,
       text: t,
@@ -283,7 +375,11 @@ module.exports = ({ cooler, services = {} }) => {
       imageUrl,
       imageUrl,
       createdAt: nowISO()
       createdAt: nowISO()
     };
     };
-    return await new Promise((resolve, reject) =>
+    if (tribeCrypto) {
+      const caseKey = getCaseKey(c);
+      if (caseKey) content = encryptFields(content, caseKey, EVIDENCE_FIELDS);
+    }
+    return new Promise((resolve, reject) =>
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
     );
     );
   }
   }
@@ -298,14 +394,18 @@ module.exports = ({ cooler, services = {} }) => {
     const t = String(text || '').trim();
     const t = String(text || '').trim();
     if (!t) throw new Error('Response text is required.');
     if (!t) throw new Error('Response text is required.');
     const ssbClient = await openSsb();
     const ssbClient = await openSsb();
-    const content = {
+    let content = {
       type: 'courtsAnswer',
       type: 'courtsAnswer',
-      caseId: c.id,
+      caseId: c.rootCaseId || c.id,
       respondent: userId,
       respondent: userId,
       stance: s,
       stance: s,
       text: t,
       text: t,
       createdAt: nowISO()
       createdAt: nowISO()
     };
     };
+    if (tribeCrypto) {
+      const caseKey = getCaseKey(c);
+      if (caseKey) content = encryptFields(content, caseKey, ANSWER_FIELDS);
+    }
     await new Promise((resolve, reject) =>
     await new Promise((resolve, reject) =>
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
     );
     );
@@ -328,14 +428,18 @@ module.exports = ({ cooler, services = {} }) => {
     if (!r) throw new Error('Result is required.');
     if (!r) throw new Error('Result is required.');
     const o = String(orders || '').trim();
     const o = String(orders || '').trim();
     const ssbClient = await openSsb();
     const ssbClient = await openSsb();
-    const content = {
+    let content = {
       type: 'courtsVerdict',
       type: 'courtsVerdict',
-      caseId: c.id,
+      caseId: c.rootCaseId || c.id,
       judgeId: userId,
       judgeId: userId,
       result: r,
       result: r,
       orders: o,
       orders: o,
       createdAt: nowISO()
       createdAt: nowISO()
     };
     };
+    if (tribeCrypto) {
+      const caseKey = getCaseKey(c);
+      if (caseKey) content = encryptFields(content, caseKey, VERDICT_FIELDS);
+    }
     await new Promise((resolve, reject) =>
     await new Promise((resolve, reject) =>
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
     );
     );
@@ -354,14 +458,18 @@ module.exports = ({ cooler, services = {} }) => {
     const t = String(terms || '').trim();
     const t = String(terms || '').trim();
     if (!t) throw new Error('Terms are required.');
     if (!t) throw new Error('Terms are required.');
     const ssbClient = await openSsb();
     const ssbClient = await openSsb();
-    const content = {
+    let content = {
       type: 'courtsSettlementProposal',
       type: 'courtsSettlementProposal',
-      caseId: c.id,
+      caseId: c.rootCaseId || c.id,
       proposer: userId,
       proposer: userId,
       terms: t,
       terms: t,
       createdAt: nowISO()
       createdAt: nowISO()
     };
     };
-    return await new Promise((resolve, reject) =>
+    if (tribeCrypto) {
+      const caseKey = getCaseKey(c);
+      if (caseKey) content = encryptFields(content, caseKey, SETTLEMENT_FIELDS);
+    }
+    return new Promise((resolve, reject) =>
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
     );
     );
   }
   }
@@ -374,7 +482,7 @@ module.exports = ({ cooler, services = {} }) => {
     const ssbClient = await openSsb();
     const ssbClient = await openSsb();
     const content = {
     const content = {
       type: 'courtsSettlementAccepted',
       type: 'courtsSettlementAccepted',
-      caseId: c.id,
+      caseId: c.rootCaseId || c.id,
       by: userId,
       by: userId,
       createdAt: nowISO()
       createdAt: nowISO()
     };
     };
@@ -462,7 +570,7 @@ module.exports = ({ cooler, services = {} }) => {
       judgeId: id,
       judgeId: id,
       createdAt: nowISO()
       createdAt: nowISO()
     };
     };
-    return await new Promise((resolve, reject) =>
+    return new Promise((resolve, reject) =>
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
     );
     );
   }
   }
@@ -490,7 +598,7 @@ module.exports = ({ cooler, services = {} }) => {
       voter: userId,
       voter: userId,
       createdAt: nowISO()
       createdAt: nowISO()
     };
     };
-    return await new Promise((resolve, reject) =>
+    return new Promise((resolve, reject) =>
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
     );
     );
   }
   }
@@ -550,26 +658,45 @@ module.exports = ({ cooler, services = {} }) => {
       myPublicPreference = base.publicPrefRespondent;
       myPublicPreference = base.publicPrefRespondent;
     }
     }
     const publicDetails = base.publicPrefAccuser === true && base.publicPrefRespondent === true;
     const publicDetails = base.publicPrefAccuser === true && base.publicPrefRespondent === true;
+
+    const caseKey = getCaseKey(base);
+    const caseRootId = base.rootCaseId || base.id;
+
+    const matchCase = (e) => {
+      const eCaseId = String(e.caseId || '');
+      return eCaseId === id || eCaseId === caseRootId;
+    };
+
+    const tryDecrypt = (item) => {
+      if (!caseKey) return item;
+      return decryptFields(item, caseKey);
+    };
+
     const evidencesAll = await listByType('courtsEvidence');
     const evidencesAll = await listByType('courtsEvidence');
     const answersAll = await listByType('courtsAnswer');
     const answersAll = await listByType('courtsAnswer');
     const settlementsAll = await listByType('courtsSettlementProposal');
     const settlementsAll = await listByType('courtsSettlementProposal');
     const verdictsAll = await listByType('courtsVerdict');
     const verdictsAll = await listByType('courtsVerdict');
     const acceptedAll = await listByType('courtsSettlementAccepted');
     const acceptedAll = await listByType('courtsSettlementAccepted');
+
     const evidences = evidencesAll
     const evidences = evidencesAll
-      .filter((e) => String(e.caseId || '') === id)
+      .filter(matchCase)
+      .map(tryDecrypt)
       .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
       .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
     const answers = answersAll
     const answers = answersAll
-      .filter((a) => String(a.caseId || '') === id)
+      .filter(matchCase)
+      .map(tryDecrypt)
       .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
       .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
     const settlements = settlementsAll
     const settlements = settlementsAll
-      .filter((s) => String(s.caseId || '') === id)
+      .filter(matchCase)
+      .map(tryDecrypt)
       .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
       .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
     const verdicts = verdictsAll
     const verdicts = verdictsAll
-      .filter((v) => String(v.caseId || '') === id)
+      .filter(matchCase)
+      .map(tryDecrypt)
       .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
       .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
     const verdict = verdicts.length ? verdicts[verdicts.length - 1] : null;
     const verdict = verdicts.length ? verdicts[verdicts.length - 1] : null;
     const acceptedSettlements = acceptedAll
     const acceptedSettlements = acceptedAll
-      .filter((s) => String(s.caseId || '') === id)
+      .filter(matchCase)
       .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
       .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
     const decidedAt =
     const decidedAt =
       base.verdictAt ||
       base.verdictAt ||
@@ -601,6 +728,30 @@ module.exports = ({ cooler, services = {} }) => {
     };
     };
   }
   }
 
 
+  async function processIncomingCourtsKeys() {
+    if (!tribeCrypto) return;
+    const ssbKeys = require('../server/node_modules/ssb-keys');
+    const ssbConfig = require('../server/ssb_config');
+    const ssbClient = await openSsb();
+    const msgs = await new Promise((res, rej) => {
+      pull(
+        ssbClient.createLogStream({ limit: logLimit }),
+        pull.collect((err, arr) => (err ? rej(err) : res(arr)))
+      );
+    });
+    for (const m of msgs) {
+      const c = m.value?.content;
+      if (!c || c.type !== 'courts-key') continue;
+      if (c.for !== ssbClient.id) continue;
+      if (!c.memberKey || !c.caseRootId) continue;
+      if (tribeCrypto.getKey(c.caseRootId)) continue;
+      try {
+        const key = tribeCrypto.unboxKeyFromMember(c.memberKey, ssbConfig.keys, ssbKeys);
+        if (key) tribeCrypto.setKey(c.caseRootId, key, 1);
+      } catch (e) {}
+    }
+  }
+
   return {
   return {
     getCurrentUserId,
     getCurrentUserId,
     openCase,
     openCase,
@@ -619,7 +770,7 @@ module.exports = ({ cooler, services = {} }) => {
     nominateJudge,
     nominateJudge,
     voteNomination,
     voteNomination,
     listNominations,
     listNominations,
-    getCaseDetails
+    getCaseDetails,
+    processIncomingCourtsKeys
   };
   };
 };
 };
-

+ 1 - 2
src/models/feed_model.js

@@ -322,7 +322,6 @@ module.exports = ({ cooler }) => {
     if (filter === "TOP") {
     if (filter === "TOP") {
       feeds.sort(
       feeds.sort(
         (a, b) =>
         (a, b) =>
-          totalVotes(b) - totalVotes(a) ||
           (b.value?.content?.refeeds || 0) - (a.value?.content?.refeeds || 0) ||
           (b.value?.content?.refeeds || 0) - (a.value?.content?.refeeds || 0) ||
           getTs(b) - getTs(a)
           getTs(b) - getTs(a)
       );
       );
@@ -349,7 +348,7 @@ module.exports = ({ cooler }) => {
     let commentCount = 0;
     let commentCount = 0;
     for (const a of actions) {
     for (const a of actions) {
       const ac = a?.value?.content || {};
       const ac = a?.value?.content || {};
-      if (ac.type === "feed-action" && ac.action === "opinion" && ac.category) {
+      if (ac.type === "feed-action" && ac.action === "vote" && ac.category) {
         opinions[ac.category] = (opinions[ac.category] || 0) + 1;
         opinions[ac.category] = (opinions[ac.category] || 0) + 1;
         if (ac.author || a?.value?.author) opinionsInhabitants.push(ac.author || a.value.author);
         if (ac.author || a?.value?.author) opinionsInhabitants.push(ac.author || a.value.author);
       }
       }

+ 1 - 1
src/models/games_model.js

@@ -3,7 +3,7 @@ const { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 5000;
 const logLimit = getConfig().ssbLogStream?.limit || 5000;
 
 
 const VALID_GAMES = new Set([
 const VALID_GAMES = new Set([
-  'cocoland', 'ecoinflow', 'spaceinvaders', 'arkanoid', 'pingpong',
+  'cocoland', 'ecoinflow', 'neoninfiltrator', 'spaceinvaders', 'arkanoid', 'pingpong',
   'asteroids', 'tiktaktoe', 'flipflop',
   'asteroids', 'tiktaktoe', 'flipflop',
   '8ball', 'artillery', 'labyrinth', 'cocoman', 'tetris'
   '8ball', 'artillery', 'labyrinth', 'cocoman', 'tetris'
 ]);
 ]);

+ 11 - 3
src/models/jobs_model.js

@@ -33,7 +33,7 @@ const matchSearch = (job, q) => {
   return hay.includes(qq)
   return hay.includes(qq)
 }
 }
 
 
-module.exports = ({ cooler }) => {
+module.exports = ({ cooler, tribeCrypto }) => {
   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 }
 
 
@@ -114,6 +114,7 @@ module.exports = ({ cooler }) => {
   }
   }
 
 
   const buildJobObject = (node, rootId, subscribers) => {
   const buildJobObject = (node, rootId, subscribers) => {
+    const visibleSubs = (tribeCrypto && tribeCrypto.getKey(rootId)) || ssb?.id === (node.c?.author || node.author) ? subscribers : [];
     const c = node.c || {}
     const c = node.c || {}
     let blobId = c.image || null
     let blobId = c.image || null
     if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
     if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
@@ -141,7 +142,7 @@ module.exports = ({ cooler }) => {
       updatedAt: c.updatedAt || null,
       updatedAt: c.updatedAt || null,
       status: c.status || "OPEN",
       status: c.status || "OPEN",
       tags: Array.isArray(c.tags) ? c.tags : normalizeTags(c.tags),
       tags: Array.isArray(c.tags) ? c.tags : normalizeTags(c.tags),
-      subscribers: Array.isArray(subscribers) ? subscribers : [],
+      subscribers: Array.isArray(visibleSubs) ? visibleSubs : [],
       mapUrl: c.mapUrl || ""
       mapUrl: c.mapUrl || ""
     }
     }
   }
   }
@@ -196,7 +197,14 @@ module.exports = ({ cooler }) => {
         mapUrl: String(jobData.mapUrl || "").trim()
         mapUrl: String(jobData.mapUrl || "").trim()
       }
       }
 
 
-      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) => {
+        if (e) return rej(e)
+        if (m && m.key && tribeCrypto) {
+          const key = tribeCrypto.generateTribeKey()
+          tribeCrypto.setKey(m.key, key, 1)
+        }
+        res(m)
+      }))
     },
     },
 
 
     async resolveCurrentId(jobId) {
     async resolveCurrentId(jobId) {

+ 11 - 4
src/models/market_model.js

@@ -53,7 +53,7 @@ const hasBidder = (poll, userId) => {
   return false
   return false
 }
 }
 
 
-module.exports = ({ cooler }) => {
+module.exports = ({ cooler, tribeCrypto }) => {
   let ssb
   let ssb
   const openSsb = async () => {
   const openSsb = async () => {
     if (!ssb) ssb = await cooler.open()
     if (!ssb) ssb = await cooler.open()
@@ -138,7 +138,14 @@ module.exports = ({ cooler }) => {
       }
       }
 
 
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
-        ssbClient.publish(itemContent, (err, res) => (err ? reject(err) : resolve(res)))
+        ssbClient.publish(itemContent, (err, res) => {
+          if (err) return reject(err)
+          if (res && res.key && tribeCrypto) {
+            const key = tribeCrypto.generateTribeKey()
+            tribeCrypto.setKey(res.key, key, 1)
+          }
+          resolve(res)
+        })
       })
       })
     },
     },
 
 
@@ -326,7 +333,7 @@ module.exports = ({ cooler }) => {
           includesShipping: !!c.includesShipping,
           includesShipping: !!c.includesShipping,
           stock: Number(c.stock) || 0,
           stock: Number(c.stock) || 0,
           deadline: c.deadline || null,
           deadline: c.deadline || null,
-          auctions_poll: Array.isArray(c.auctions_poll) ? c.auctions_poll : [],
+          auctions_poll: (tribeCrypto && tribeCrypto.getKey(rootId)) ? (Array.isArray(c.auctions_poll) ? c.auctions_poll : []) : [],
           mapUrl: c.mapUrl || "",
           mapUrl: c.mapUrl || "",
           shopProductId: c.shopProductId || "",
           shopProductId: c.shopProductId || "",
           shopId: c.shopId || "",
           shopId: c.shopId || "",
@@ -464,7 +471,7 @@ module.exports = ({ cooler }) => {
         includesShipping: !!c.includesShipping,
         includesShipping: !!c.includesShipping,
         stock: Number(c.stock) || 0,
         stock: Number(c.stock) || 0,
         deadline: c.deadline,
         deadline: c.deadline,
-        auctions_poll: Array.isArray(c.auctions_poll) ? c.auctions_poll : [],
+        auctions_poll: (tribeCrypto && tribeCrypto.getKey(rootId)) ? (Array.isArray(c.auctions_poll) ? c.auctions_poll : []) : [],
         mapUrl: c.mapUrl || "",
         mapUrl: c.mapUrl || "",
         shopProductId: c.shopProductId || "",
         shopProductId: c.shopProductId || "",
         shopId: c.shopId || "",
         shopId: c.shopId || "",

+ 1 - 0
src/models/pm_model.js

@@ -65,6 +65,7 @@ module.exports = ({ cooler }) => {
       const author = decrypted?.value?.author;
       const author = decrypted?.value?.author;
       const originalRecps = Array.isArray(content?.to) ? content.to : [];
       const originalRecps = Array.isArray(content?.to) ? content.to : [];
       if (!content || !author) throw new Error("Malformed message.");
       if (!content || !author) throw new Error("Malformed message.");
+      if (author !== userId) throw new Error("Not the author.");
       if (content.type === 'tombstone') throw new Error("Message already deleted.");
       if (content.type === 'tombstone') throw new Error("Message already deleted.");
       const tombstone = {
       const tombstone = {
         type: 'tombstone',
         type: 'tombstone',

+ 10 - 3
src/models/shops_model.js

@@ -12,7 +12,7 @@ const normalizeTags = (raw) => {
 }
 }
 const voteSum = (opinions = {}) => Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)
 const voteSum = (opinions = {}) => Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)
 
 
-module.exports = ({ cooler }) => {
+module.exports = ({ cooler, tribeCrypto }) => {
   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 }
 
 
@@ -91,7 +91,7 @@ module.exports = ({ cooler }) => {
       updatedAt: c.updatedAt || null,
       updatedAt: c.updatedAt || null,
       opinions: c.opinions || {},
       opinions: c.opinions || {},
       opinions_inhabitants: safeArr(c.opinions_inhabitants),
       opinions_inhabitants: safeArr(c.opinions_inhabitants),
-      buyers: safeArr(c.buyers)
+      buyers: (tribeCrypto && tribeCrypto.getKey(rootId)) || ssb?.id === (c.author || node.author) ? safeArr(c.buyers) : []
     }
     }
   }
   }
 
 
@@ -301,7 +301,14 @@ module.exports = ({ cooler }) => {
       }
       }
 
 
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
-        ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
+        ssbClient.publish(content, (err, msg) => {
+          if (err) return reject(err)
+          if (msg && msg.key && tribeCrypto) {
+            const key = tribeCrypto.generateTribeKey()
+            tribeCrypto.setKey(msg.key, key, 1)
+          }
+          resolve(msg)
+        })
       })
       })
     },
     },
 
 

+ 19 - 3
src/models/tags_model.js

@@ -2,7 +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;
 
 
-module.exports = ({ cooler, padsModel }) => {
+module.exports = ({ cooler, padsModel, tribesModel }) => {
   let ssb;
   let ssb;
   const openSsb = async () => {
   const openSsb = async () => {
     if (!ssb) ssb = await cooler.open();
     if (!ssb) ssb = await cooler.open();
@@ -130,11 +130,25 @@ module.exports = ({ cooler, padsModel }) => {
 
 
       for (const oldId of replacesMap.keys()) latestByKey.delete(oldId);
       for (const oldId of replacesMap.keys()) latestByKey.delete(oldId);
 
 
+      const anonTribeIds = new Set();
+      if (tribesModel) {
+        const allTribes = await tribesModel.listAll().catch(() => []);
+        for (const tribe of allTribes) {
+          if (tribe.isAnonymous === true) anonTribeIds.add(tribe.id);
+        }
+      }
+
       let filtered = Array.from(latestByKey.values()).filter(msg => {
       let filtered = Array.from(latestByKey.values()).filter(msg => {
         const c = msg?.value?.content;
         const c = msg?.value?.content;
         if (!c || c.type === 'tombstone') return false;
         if (!c || c.type === 'tombstone') return false;
         if (tombstoned.has(msg.key)) return false;
         if (tombstoned.has(msg.key)) return false;
-        return Array.isArray(c.tags) && c.tags.filter(Boolean).length > 0;
+        if (!Array.isArray(c.tags) || !c.tags.filter(Boolean).length) return false;
+        if (c.tribeId && anonTribeIds.has(c.tribeId)) return false;
+        if (c.type === 'event' && c.isPublic === 'private') return false;
+        if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE') return false;
+        if ((c.type === 'chat' || c.type === 'pad') && c.status === 'INVITE-ONLY') return false;
+        if (c.type === 'shop' && c.visibility === 'CLOSED') return false;
+        return true;
       });
       });
 
 
       filtered = dedupeKeepLatest(filtered);
       filtered = dedupeKeepLatest(filtered);
@@ -153,9 +167,11 @@ module.exports = ({ cooler, padsModel }) => {
       }
       }
 
 
       if (padsModel) {
       if (padsModel) {
-        const viewerId = '';
+        const ssbClient2 = await openSsb();
+        const viewerId = ssbClient2.id;
         const pads = await padsModel.listAll({ filter: 'all', viewerId }).catch(() => []);
         const pads = await padsModel.listAll({ filter: 'all', viewerId }).catch(() => []);
         for (const pad of pads) {
         for (const pad of pads) {
+          if (pad.status === 'INVITE-ONLY' && pad.author !== viewerId && !(Array.isArray(pad.members) && pad.members.includes(viewerId))) continue;
           if (!Array.isArray(pad.tags)) continue;
           if (!Array.isArray(pad.tags)) continue;
           const uniquePadTags = new Set(pad.tags.map(tagKey).filter(Boolean));
           const uniquePadTags = new Set(pad.tags.map(tagKey).filter(Boolean));
           for (const k of uniquePadTags) {
           for (const k of uniquePadTags) {

+ 15 - 0
src/server/nodemon.json

@@ -0,0 +1,15 @@
+{
+  "watch": [
+    "../backend",
+    "../models",
+    "../views",
+    "../client"
+  ],
+  "exec": "node ../backend/backend.js",
+  "ext": "js,json",
+  "ignore": [
+    "../client/assets/"
+  ],
+  "signal": "SIGTERM",
+  "delay": 500
+}

Разлика између датотеке није приказан због своје велике величине
+ 328 - 147
src/server/package-lock.json


+ 6 - 1
src/server/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@krakenslab/oasis",
   "name": "@krakenslab/oasis",
-  "version": "0.7.0",
+  "version": "0.7.1",
   "description": "Oasis - Social Networking Utopia",
   "description": "Oasis - Social Networking Utopia",
   "repository": {
   "repository": {
     "type": "git",
     "type": "git",
@@ -16,6 +16,8 @@
     "start": "npm run start:ssb && sleep 10 && npm run start:backend",
     "start": "npm run start:ssb && sleep 10 && npm run start:backend",
     "start:backend": "node ../backend/backend.js",
     "start:backend": "node ../backend/backend.js",
     "start:ssb": "node SSB_server.js start &",
     "start:ssb": "node SSB_server.js start &",
+    "dev": "npm run dev:backend",
+    "dev:backend": "nodemon",
     "postinstall": "node ../../scripts/patch-node-modules.js"
     "postinstall": "node ../../scripts/patch-node-modules.js"
   },
   },
   "dependencies": {
   "dependencies": {
@@ -137,6 +139,9 @@
     "fsevents": "^2.3.2",
     "fsevents": "^2.3.2",
     "sharp": "^0.33.5"
     "sharp": "^0.33.5"
   },
   },
+  "devDependencies": {
+    "nodemon": "^3.1.0"
+  },
   "bugs": {
   "bugs": {
     "url": "https://github.com/epsylon/oasis/issues"
     "url": "https://github.com/epsylon/oasis/issues"
   },
   },

+ 11 - 2
src/server/ssb_metadata.js

@@ -37,7 +37,7 @@ async function checkForUpdate() {
   await updater.getRemoteVersion();
   await updater.getRemoteVersion();
 }
 }
 
 
-async function printMetadata(mode, modeColor = colors.cyan) {
+async function printMetadata(mode, modeColor = colors.cyan, httpPort = 3000, httpHost = 'localhost', offline = false, isPublic = false) {
   if (printed) return;
   if (printed) return;
   printed = true;
   printed = true;
 
 
@@ -46,17 +46,26 @@ async function printMetadata(mode, modeColor = colors.cyan) {
   const name = pkg.name;
   const name = pkg.name;
   const logLevel = config.logging?.level || 'info';
   const logLevel = config.logging?.level || 'info';
   const publicKey = config.keys?.public || '';
   const publicKey = config.keys?.public || '';
+  const httpUrl = `http://${httpHost}:${httpPort}`;
+  const oscLink = `\x1b]8;;${httpUrl}\x07${httpUrl}\x1b]8;;\x07`;
+  const ssbPort = config.connections?.incoming?.net?.[0]?.port || config.port || 8008;
+  const localDiscovery = config.local === true;
+  const hops = config.conn?.hops ?? config.friends?.hops ?? 2;
 
 
   console.log("=========================");
   console.log("=========================");
   console.log(`Running mode: ${modeColor}${mode}${colors.reset}`);
   console.log(`Running mode: ${modeColor}${mode}${colors.reset}`);
   console.log("=========================");
   console.log("=========================");
   console.log(`- Package: ${colors.blue}${name} ${colors.yellow}[Version: ${version}]${colors.reset}`);
   console.log(`- Package: ${colors.blue}${name} ${colors.yellow}[Version: ${version}]${colors.reset}`);
-  console.log("- Logging Level:", logLevel);
+  console.log(`- URL: ${colors.cyan}${oscLink}${colors.reset}`);
   console.log(`- Oasis ID: [ ${colors.orange}@${publicKey}${colors.reset} ]`);
   console.log(`- Oasis ID: [ ${colors.orange}@${publicKey}${colors.reset} ]`);
+  console.log("- Logging Level:", logLevel);
   const ifaces = os.networkInterfaces();
   const ifaces = os.networkInterfaces();
   const isOnline = Object.values(ifaces).some(list =>
   const isOnline = Object.values(ifaces).some(list =>
     list && list.some(i => !i.internal && i.family === 'IPv4')
     list && list.some(i => !i.internal && i.family === 'IPv4')
   );
   );
+  console.log(`- Protocol (port): ${ssbPort}`);
+  console.log(`- LAN broadcasting (UDP): ${localDiscovery ? 'enabled' : 'disabled'}`);
+  console.log(`- Replication (hops): ${hops}`);
   console.log(`- Mode: ${isOnline ? 'online' : 'offline'}`);
   console.log(`- Mode: ${isOnline ? 'online' : 'offline'}`);
   console.log("");
   console.log("");
   console.log("=========================");
   console.log("=========================");

+ 32 - 14
src/views/blockchain_view.js

@@ -215,7 +215,7 @@ const renderBlockDiagram = (blocks, qs) => {
   );
   );
 };
 };
 
 
-const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, viewMode = 'block') => {
+const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, viewMode = 'block', restricted = false) => {
   if (!block) {
   if (!block) {
     return template(
     return template(
       i18n.blockchain,
       i18n.blockchain,
@@ -232,9 +232,8 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, vi
   const qs = toQueryString(filter, search);
   const qs = toQueryString(filter, search);
   const isDatagram = viewMode === 'datagram';
   const isDatagram = viewMode === 'datagram';
 
 
-  const blockContent = isDatagram
-    ? renderBlockDiagram([block], qs)
-    : div(
+  const blockContent = restricted
+    ? div(
         div({ class: 'block-single' },
         div({ class: 'block-single' },
           div({ class: 'block-row block-row--meta' },
           div({ class: 'block-row block-row--meta' },
             span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockID}:`),
             span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockID}:`),
@@ -245,17 +244,36 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, vi
             span({ class: 'blockchain-card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ')),
             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 block-row--meta-spaced' },
-            a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author)
           )
           )
         ),
         ),
-        div({ class:'block-row block-row--content' },
-          div({ class:'block-content-preview' },
-            pre({ class:'json-content' }, JSON.stringify(block.content,null,2))
-          )
+        div({ class: 'block-row block-row--content' },
+          p({ class: 'access-denied-msg' }, i18n.blockAccessRestricted)
         )
         )
-      );
+      )
+    : isDatagram
+      ? renderBlockDiagram([block], qs)
+      : div(
+          div({ class: 'block-single' },
+            div({ class: 'block-row block-row--meta' },
+              span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockID}:`),
+              span({ class: 'blockchain-card-value' }, block.id)
+            ),
+            div({ class: 'block-row block-row--meta' },
+              span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockTimestamp}:`),
+              span({ class: 'blockchain-card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ')),
+              span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockType}:`),
+              span({ class: 'blockchain-card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())
+            ),
+            div({ class: 'block-row block-row--meta block-row--meta-spaced' },
+              a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author)
+            )
+          ),
+          div({ class:'block-row block-row--content' },
+            div({ class:'block-content-preview' },
+              pre({ class:'json-content' }, JSON.stringify(block.content,null,2))
+            )
+          )
+        );
 
 
   return template(
   return template(
     i18n.blockchain,
     i18n.blockchain,
@@ -363,8 +381,8 @@ const renderBlockchainView = (blocks, filter, userId, search = {}) => {
             .map(block=>
             .map(block=>
               div({ class:'block' },
               div({ class:'block' },
                 div({ class:'block-buttons' },
                 div({ class:'block-buttons' },
-                  a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}`, class:'btn-singleview', title:i18n.blockchainDetails }, '⦿'),
-                  a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}&view=datagram`, class:'btn-singleview btn-datagram', title:i18n.blockchainDatagram || 'Datagram' }, '⊞'),
+                  block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}`, class:'btn-singleview', title:i18n.blockchainDetails }, '⦿'),
+                  block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}&view=datagram`, class:'btn-singleview btn-datagram', title:i18n.blockchainDatagram || 'Datagram' }, '⊞'),
                   !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
                   !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
                     form({ method:'GET', action:getViewDetailsAction(block.type, block) },
                     form({ method:'GET', action:getViewDetailsAction(block.type, block) },
                       button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
                       button({ type:'submit', class:'filter-btn' }, i18n.visitContent)

+ 29 - 23
src/views/chats_view.js

@@ -148,6 +148,17 @@ const renderMessage = (msg, chatAuthor) => {
 }
 }
 
 
 
 
+exports.renderChatInvitePage = (code) => {
+  const pageContent = div({ class: "invite-page" },
+    h2(i18n.tribeInviteCodeText, code),
+    form({ method: "GET", action: "/chats" },
+      input({ type: "hidden", name: "filter", value: "all" }),
+      button({ type: "submit", class: "filter-btn" }, i18n.walletBack)
+    )
+  )
+  return template(i18n.chatInviteMode || "Invite", section(pageContent))
+}
+
 exports.chatsView = async (chats, filter, chatToEdit = null, params = {}) => {
 exports.chatsView = async (chats, filter, chatToEdit = null, params = {}) => {
   const q = safeText(params.q || "")
   const q = safeText(params.q || "")
   const list = safeArr(chats)
   const list = safeArr(chats)
@@ -204,6 +215,7 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
   const isAuthor = String(chat.author) === String(userId)
   const isAuthor = String(chat.author) === String(userId)
   const isMember = safeArr(chat.members).includes(userId)
   const isMember = safeArr(chat.members).includes(userId)
   const fullShareUrl = `/chats/${encodeURIComponent(chat.key)}`
   const fullShareUrl = `/chats/${encodeURIComponent(chat.key)}`
+  const isRestrictedInviteOnly = !isMember && !isAuthor && chat.status === "INVITE-ONLY"
 
 
   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
@@ -223,7 +235,7 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
         td({ class: "tribe-info-label" }, i18n.chatCreatedAt),
         td({ class: "tribe-info-label" }, i18n.chatCreatedAt),
         td({ class: "tribe-info-value", colspan: "3" }, moment(chat.createdAt).format("YYYY/MM/DD HH:mm"))
         td({ class: "tribe-info-value", colspan: "3" }, moment(chat.createdAt).format("YYYY/MM/DD HH:mm"))
       ),
       ),
-      tr(
+      isRestrictedInviteOnly ? null : tr(
         td({ class: "tribe-info-value", colspan: "4" },
         td({ class: "tribe-info-value", colspan: "4" },
           a({ href: `/author/${encodeURIComponent(chat.author)}`, class: "user-link" }, chat.author)
           a({ href: `/author/${encodeURIComponent(chat.author)}`, class: "user-link" }, chat.author)
         )
         )
@@ -232,12 +244,12 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
         td({ class: "tribe-info-label" }, i18n.chatStatus),
         td({ class: "tribe-info-label" }, i18n.chatStatus),
         td({ class: "tribe-info-value", colspan: "3" }, statusLabel)
         td({ class: "tribe-info-value", colspan: "3" }, statusLabel)
       ),
       ),
-      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))
       ) : null
       ) : null
     ),
     ),
-    div({ class: "tribe-side-actions" },
+    isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" },
       isAuthor
       isAuthor
         ? form({ method: "POST", action: `/chats/generate-invite` },
         ? form({ method: "POST", action: `/chats/generate-invite` },
             input({ type: "hidden", name: "chatId", value: chat.key }),
             input({ type: "hidden", name: "chatId", value: chat.key }),
@@ -279,27 +291,19 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
           )
           )
         : null
         : null
     ),
     ),
-    !isMember && chat.status !== "CLOSED"
+    !isMember && chat.status === "INVITE-ONLY"
       ? div({ class: "chat-join-section" },
       ? div({ class: "chat-join-section" },
-          chat.status === "INVITE-ONLY"
-            ? div({ class: "chat-invite-form" },
-                form({ method: "POST", action: "/chats/join-code" },
-                  input({ type: "hidden", name: "returnTo", value: returnTo }),
-                  label(i18n.chatInviteCodeLabel), br(),
-                  input({ type: "text", name: "code", required: true, placeholder: i18n.chatInviteCode }), br(), br(),
-                  button({ type: "submit", class: "filter-btn" }, i18n.chatJoinByInvite)
-                )
-              )
-            : null,
-          chat.status === "OPEN"
-            ? form({ method: "POST", action: `/chats/join/${encodeURIComponent(chat.key)}` },
-                input({ type: "hidden", name: "returnTo", value: returnTo }),
-                button({ type: "submit", class: "create-button" }, i18n.chatStartChatting)
-              )
-            : null
+          div({ class: "chat-invite-form" },
+            form({ method: "POST", action: "/chats/join-code" },
+              input({ type: "hidden", name: "returnTo", value: `/chats/${encodeURIComponent(chat.key)}` }),
+              label(i18n.chatInviteCodeLabel), br(),
+              input({ type: "text", name: "code", required: true, placeholder: i18n.chatInviteCode }), br(), br(),
+              button({ type: "submit", class: "filter-btn" }, i18n.chatJoinByInvite)
+            )
+          )
         )
         )
       : null,
       : null,
-    safeArr(chat.tags).length
+    !isRestrictedInviteOnly && safeArr(chat.tags).length
       ? div({ class: "tribe-side-tags" },
       ? div({ class: "tribe-side-tags" },
           safeArr(chat.tags).map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
           safeArr(chat.tags).map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
         )
         )
@@ -307,9 +311,11 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
   )
   )
 
 
   const msgList = safeArr(messages)
   const msgList = safeArr(messages)
-  const canWrite = isMember && chat.status !== "CLOSED"
+  const canWrite = (isMember || chat.status === "OPEN") && chat.status !== "CLOSED"
 
 
-  const chatMain = div({ class: "tribe-main chat-full-width" },
+  const chatMain = isRestrictedInviteOnly
+    ? div({ class: "tribe-main chat-full-width" }, p({ class: "access-denied-msg" }, i18n.chatAccessDenied))
+    : div({ class: "tribe-main chat-full-width" },
     canWrite
     canWrite
       ? div({ class: "chat-message-form" },
       ? div({ class: "chat-message-form" },
           form({ method: "POST", action: `/chats/${encodeURIComponent(chat.key)}/message`, enctype: "multipart/form-data" },
           form({ method: "POST", action: `/chats/${encodeURIComponent(chat.key)}/message`, enctype: "multipart/form-data" },

+ 1 - 4
src/views/cipher_view.js

@@ -67,10 +67,7 @@ const cipherView = async (encryptedText = "", decryptedText = "", iv = "", passw
     ? div({ class: "cipher-result visible encrypted-result" }, 
     ? div({ class: "cipher-result visible encrypted-result" }, 
         label(i18n.cipherEncryptedMessageLabel),
         label(i18n.cipherEncryptedMessageLabel),
         br(),br(),
         br(),br(),
-        div({ class: "cipher-text" }, encryptedText),
-        label(i18n.cipherPasswordUsedLabel),
-        br(),br(),
-        div({ class: "cipher-text" }, password) 
+        div({ class: "cipher-text" }, encryptedText)
       )
       )
     : null;
     : null;
 
 

+ 27 - 14
src/views/event_view.js

@@ -412,25 +412,38 @@ exports.singleEventView = async (event, filter, comments = [], params = {}) => {
   const attendees = safeArray(event.attendees);
   const attendees = safeArray(event.attendees);
   const urlHref = safeExternalHref(event.url);
   const urlHref = safeExternalHref(event.url);
 
 
+  const isPrivateNoAccess = normalizePrivacy(event.isPublic) === "private" &&
+    String(event.organizer) !== String(userId) &&
+    !attendees.includes(userId);
+
+  const filterBar = div(
+    { class: "filters" },
+    form(
+      { method: "GET", action: "/events" },
+      button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterAll),
+      button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMine),
+      button({ type: "submit", name: "filter", value: "today", class: currentFilter === "today" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterToday),
+      button({ type: "submit", name: "filter", value: "week", class: currentFilter === "week" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterWeek),
+      button({ type: "submit", name: "filter", value: "month", class: currentFilter === "month" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMonth),
+      button({ type: "submit", name: "filter", value: "year", class: currentFilter === "year" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterYear),
+      button({ type: "submit", name: "filter", value: "archived", class: currentFilter === "archived" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterArchived),
+      button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.eventCreateButton)
+    )
+  );
+
+  if (isPrivateNoAccess) {
+    return template(
+      event.title,
+      section(filterBar, p({ class: "access-denied-msg" }, i18n.contentAccessDenied))
+    );
+  }
+
   const topbar = renderEventTopbar(event, currentFilter, { single: true });
   const topbar = renderEventTopbar(event, currentFilter, { single: true });
 
 
   return template(
   return template(
     event.title,
     event.title,
     section(
     section(
-      div(
-        { class: "filters" },
-        form(
-          { method: "GET", action: "/events" },
-          button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterAll),
-          button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMine),
-          button({ type: "submit", name: "filter", value: "today", class: currentFilter === "today" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterToday),
-          button({ type: "submit", name: "filter", value: "week", class: currentFilter === "week" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterWeek),
-          button({ type: "submit", name: "filter", value: "month", class: currentFilter === "month" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMonth),
-          button({ type: "submit", name: "filter", value: "year", class: currentFilter === "year" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterYear),
-          button({ type: "submit", name: "filter", value: "archived", class: currentFilter === "archived" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterArchived),
-          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.eventCreateButton)
-        )
-      ),
+      filterBar,
       div(
       div(
         { class: "card card-section event" },
         { class: "card card-section event" },
         topbar ? topbar : null,
         topbar ? topbar : null,

+ 3 - 3
src/views/feed_view.js

@@ -170,7 +170,7 @@ const renderFeedCard = (feed) => {
                 h1(String(refeedsNum)),
                 h1(String(refeedsNum)),
                 form(
                 form(
                     { method: "POST", action: `/feed/refeed/${encodeURIComponent(feed.key)}` },
                     { method: "POST", action: `/feed/refeed/${encodeURIComponent(feed.key)}` },
-                    button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", disabled: !!alreadyRefeeded }, i18n.refeedButton)
+                    button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", ...(alreadyRefeeded ? { disabled: true } : {}) }, i18n.refeedButton)
                 ),
                 ),
                 alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null
                 alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null
             ),
             ),
@@ -344,7 +344,7 @@ exports.singleFeedView = (feed, comments = []) => {
             h1(String(refeedsNum)),
             h1(String(refeedsNum)),
             form(
             form(
               { method: "POST", action: `/feed/refeed/${encodeURIComponent(feed.key)}` },
               { method: "POST", action: `/feed/refeed/${encodeURIComponent(feed.key)}` },
-              button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", disabled: !!alreadyRefeeded }, i18n.refeedButton)
+              button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", ...(alreadyRefeeded ? { disabled: true } : {}) }, i18n.refeedButton)
             ),
             ),
             alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null
             alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null
           ),
           ),
@@ -372,7 +372,7 @@ exports.singleFeedView = (feed, comments = []) => {
             form(
             form(
               { method: "POST", action: `/feed/opinions/${encodeURIComponent(feed.key)}/${cat}` },
               { method: "POST", action: `/feed/opinions/${encodeURIComponent(feed.key)}/${cat}` },
               button(
               button(
-                { class: alreadyVoted ? "vote-btn disabled" : "vote-btn", type: "submit", disabled: !!alreadyVoted },
+                { class: alreadyVoted ? "vote-btn disabled" : "vote-btn", type: "submit", ...(alreadyVoted ? { disabled: true } : {}) },
                 `${i18n["vote" + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${content.opinions?.[cat] || 0}]`
                 `${i18n["vote" + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${content.opinions?.[cat] || 0}]`
               )
               )
             )
             )

+ 3 - 1
src/views/games_view.js

@@ -5,11 +5,13 @@ const moment = require("../server/node_modules/moment");
 const getGames = () => [
 const getGames = () => [
   { id: 'cocoland', title: () => i18n.gamesCocolandTitle, desc: () => i18n.gamesCocolandDesc },
   { id: 'cocoland', title: () => i18n.gamesCocolandTitle, desc: () => i18n.gamesCocolandDesc },
   { id: 'ecoinflow', title: () => i18n.gamesTheFlowTitle, desc: () => i18n.gamesTheFlowDesc },
   { id: 'ecoinflow', title: () => i18n.gamesTheFlowTitle, desc: () => i18n.gamesTheFlowDesc },
+  { id: 'neoninfiltrator', title: () => i18n.gamesNeonInfiltratorTitle, desc: () => i18n.gamesNeonInfiltratorDesc },
   { id: 'audiopendulum', title: () => i18n.gamesAudioPendulumTitle, desc: () => i18n.gamesAudioPendulumDesc },
   { id: 'audiopendulum', title: () => i18n.gamesAudioPendulumTitle, desc: () => i18n.gamesAudioPendulumDesc },
   { id: 'spaceinvaders', title: () => i18n.gamesSpaceInvadersTitle, desc: () => i18n.gamesSpaceInvadersDesc },
   { id: 'spaceinvaders', title: () => i18n.gamesSpaceInvadersTitle, desc: () => i18n.gamesSpaceInvadersDesc },
   { id: 'arkanoid', title: () => i18n.gamesArkanoidTitle, desc: () => i18n.gamesArkanoidDesc },
   { id: 'arkanoid', title: () => i18n.gamesArkanoidTitle, desc: () => i18n.gamesArkanoidDesc },
   { id: 'pingpong', title: () => i18n.gamesPingPongTitle, desc: () => i18n.gamesPingPongDesc },
   { id: 'pingpong', title: () => i18n.gamesPingPongTitle, desc: () => i18n.gamesPingPongDesc },
   { id: 'asteroids', title: () => i18n.gamesAsteroidsTitle, desc: () => i18n.gamesAsteroidsDesc },
   { id: 'asteroids', title: () => i18n.gamesAsteroidsTitle, desc: () => i18n.gamesAsteroidsDesc },
+  { id: 'rockpaperscissors', title: () => i18n.gamesRockPaperScissorsTitle, desc: () => i18n.gamesRockPaperScissorsDesc },
   { id: 'tiktaktoe', title: () => i18n.gamesTikTakToeTitle, desc: () => i18n.gamesTikTakToeDesc },
   { id: 'tiktaktoe', title: () => i18n.gamesTikTakToeTitle, desc: () => i18n.gamesTikTakToeDesc },
   { id: 'flipflop', title: () => i18n.gamesFlipFlopTitle, desc: () => i18n.gamesFlipFlopDesc },
   { id: 'flipflop', title: () => i18n.gamesFlipFlopTitle, desc: () => i18n.gamesFlipFlopDesc },
   { id: '8ball', title: () => i18n.games8BallTitle, desc: () => i18n.games8BallDesc },
   { id: '8ball', title: () => i18n.games8BallTitle, desc: () => i18n.games8BallDesc },
@@ -53,7 +55,7 @@ const renderHallOfFame = (hall) => {
   );
   );
 };
 };
 
 
-const VALID_GAME_IDS = new Set(['cocoland','ecoinflow','audiopendulum','spaceinvaders','arkanoid','pingpong','asteroids','tiktaktoe','flipflop','8ball','artillery','labyrinth','cocoman','tetris']);
+const VALID_GAME_IDS = new Set(['cocoland','ecoinflow','neoninfiltrator','audiopendulum','spaceinvaders','arkanoid','pingpong','asteroids','rockpaperscissors','tiktaktoe','flipflop','8ball','artillery','labyrinth','cocoman','tetris']);
 
 
 exports.gameShellView = (name) => {
 exports.gameShellView = (name) => {
   if (!VALID_GAME_IDS.has(name)) {
   if (!VALID_GAME_IDS.has(name)) {

+ 15 - 0
src/views/main_views.js

@@ -1006,6 +1006,21 @@ const template = (titlePrefix, ...elements) => {
 
 
 exports.template = template;
 exports.template = template;
 
 
+exports.tribeAccessDeniedView = (tribe) => {
+  const tribeName = tribe && !tribe.isAnonymous ? tribe.title : "";
+  return template(
+    i18n.tribeContentAccessDenied,
+    div({ class: "div-center" },
+      h2(i18n.tribeContentAccessDenied),
+      p(i18n.tribeContentAccessDeniedMsg),
+      tribeName ? p({ class: "tribe-access-name" }, tribeName) : null,
+      div({ class: "visit-btn-centered" },
+        a({ href: "/tribes", class: "filter-btn" }, i18n.tribeViewTribes)
+      )
+    )
+  );
+};
+
 const thread = (messages) => {
 const thread = (messages) => {
   let lookingForTarget = true;
   let lookingForTarget = true;
   let shallowest = Infinity;
   let shallowest = Infinity;

+ 42 - 40
src/views/pads_view.js

@@ -97,7 +97,7 @@ const renderPadCard = (pad, filter) => {
       ),
       ),
       table({ class: "tribe-info-table" },
       table({ class: "tribe-info-table" },
         tr(td(i18n.padStatusLabel || "Status"), td(renderStatus(pad.status, pad.isClosed))),
         tr(td(i18n.padStatusLabel || "Status"), td(renderStatus(pad.status, pad.isClosed))),
-        tr(td(i18n.padDeadlineLabel || "Deadline"), td(pad.deadline ? moment(pad.deadline).format("YYYY-MM-DD HH:mm") : "\u2014"))
+        pad.deadline ? tr(td(i18n.padDeadlineLabel || "Deadline"), td(moment(pad.deadline).format("YYYY-MM-DD HH:mm"))) : null
       ),
       ),
       div({ class: "tribe-card-members" },
       div({ class: "tribe-card-members" },
         span({ class: "tribe-members-count" }, `${i18n.padMembersLabel || "Members"}: ${pad.members.length}`)
         span({ class: "tribe-members-count" }, `${i18n.padMembersLabel || "Members"}: ${pad.members.length}`)
@@ -144,6 +144,17 @@ const renderCreateForm = (padToEdit, params) => {
   )
   )
 }
 }
 
 
+exports.renderPadInvitePage = (code) => {
+  const pageContent = div({ class: "invite-page" },
+    h2(i18n.tribeInviteCodeText, code),
+    form({ method: "GET", action: "/pads" },
+      input({ type: "hidden", name: "filter", value: "all" }),
+      button({ type: "submit", class: "filter-btn" }, i18n.walletBack)
+    )
+  )
+  return template(i18n.padInviteMode || "Invite", section(pageContent))
+}
+
 exports.padsView = async (pads, filter, padToEdit, params) => {
 exports.padsView = async (pads, filter, padToEdit, params) => {
   const q = String((params && params.q) || "").trim()
   const q = String((params && params.q) || "").trim()
   const isForm = filter === "create" || filter === "edit"
   const isForm = filter === "create" || filter === "edit"
@@ -194,10 +205,10 @@ exports.singlePadView = async (pad, entries, params) => {
   const isMember = pad.members.includes(userId)
   const isMember = pad.members.includes(userId)
   const padClosed = pad.isClosed
   const padClosed = pad.isClosed
   const returnTo = `/pads/${encodeURIComponent(pad.rootId)}`
   const returnTo = `/pads/${encodeURIComponent(pad.rootId)}`
-
   const shareUrl = `/pads/${encodeURIComponent(pad.rootId)}`
   const shareUrl = `/pads/${encodeURIComponent(pad.rootId)}`
+  const isRestrictedInviteOnly = !isMember && !isAuthor && pad.status === "INVITE-ONLY"
 
 
-  const tags = Array.isArray(pad.tags) && pad.tags.length > 0
+  const tags = !isRestrictedInviteOnly && Array.isArray(pad.tags) && pad.tags.length > 0
     ? div({ class: "tribe-side-tags" }, ...pad.tags.map(t => a({ href: `/search?query=%23${encodeURIComponent(t)}` }, `#${t}`)))
     ? div({ class: "tribe-side-tags" }, ...pad.tags.map(t => a({ href: `/search?query=%23${encodeURIComponent(t)}` }, `#${t}`)))
     : null
     : null
 
 
@@ -215,11 +226,11 @@ exports.singlePadView = async (pad, entries, params) => {
     ),
     ),
     table({ class: "tribe-info-table" },
     table({ class: "tribe-info-table" },
       tr(td({ class: "tribe-info-label" }, i18n.padCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(pad.createdAt).format("YYYY-MM-DD"))),
       tr(td({ class: "tribe-info-label" }, i18n.padCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(pad.createdAt).format("YYYY-MM-DD"))),
-      tr(td({ class: "tribe-info-value", colspan: "4" }, a({ href: `/author/${encodeURIComponent(pad.author)}`, class: "user-link" }, pad.author))),
+      isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-value", colspan: "4" }, a({ href: `/author/${encodeURIComponent(pad.author)}`, class: "user-link" }, pad.author))),
       tr(td({ class: "tribe-info-label" }, i18n.padStatusLabel || "Status"), td({ class: "tribe-info-value", colspan: "3" }, renderStatus(pad.status, padClosed))),
       tr(td({ class: "tribe-info-label" }, i18n.padStatusLabel || "Status"), td({ class: "tribe-info-value", colspan: "3" }, renderStatus(pad.status, padClosed))),
-      tr(td({ class: "tribe-info-label" }, i18n.padDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, pad.deadline ? moment(pad.deadline).format("YYYY-MM-DD HH:mm") : "\u2014"))
+      isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-label" }, i18n.padDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, pad.deadline ? moment(pad.deadline).format("YYYY-MM-DD HH:mm") : "\u2014"))
     ),
     ),
-    div({ class: "tribe-side-actions" },
+    isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" },
       isAuthor
       isAuthor
         ? form({ method: "POST", action: `/pads/generate-invite/${encodeURIComponent(pad.rootId)}` },
         ? form({ method: "POST", action: `/pads/generate-invite/${encodeURIComponent(pad.rootId)}` },
             button({ type: "submit", class: "tribe-action-btn" }, i18n.padGenerateCode || "Generate Code")
             button({ type: "submit", class: "tribe-action-btn" }, i18n.padGenerateCode || "Generate Code")
@@ -260,18 +271,12 @@ exports.singlePadView = async (pad, entries, params) => {
           )
           )
         )
         )
       : null,
       : null,
-    (!isAuthor && (pad.status === "OPEN" || isMember) && !padClosed)
+    !isRestrictedInviteOnly && (!isAuthor && (pad.status === "OPEN" || isMember) && !padClosed)
       ? form({ method: "POST", action: `/pads/join/${encodeURIComponent(pad.rootId)}` },
       ? form({ method: "POST", action: `/pads/join/${encodeURIComponent(pad.rootId)}` },
           button({ type: "submit", class: "create-button" }, i18n.padStartEditing || "START EDITING!")
           button({ type: "submit", class: "create-button" }, i18n.padStartEditing || "START EDITING!")
         )
         )
       : null,
       : null,
-    tags,
-    params.inviteCode
-      ? div({ class: "pad-invite-section" },
-          p(i18n.padInviteGenerated || "Invite Code Generated"),
-          input({ type: "text", readonly: true, value: params.inviteCode })
-        )
-      : null
+    tags
   )
   )
 
 
   let canonicalEntries = entries
   let canonicalEntries = entries
@@ -291,24 +296,6 @@ exports.singlePadView = async (pad, entries, params) => {
       )
       )
     : p(i18n.padNoEntries || "No entries yet.")
     : p(i18n.padNoEntries || "No entries yet.")
 
 
-  const editorArea = isMember && !padClosed && !params.selectedVersion
-    ? div({ class: "pad-editor-area" },
-        coloredView,
-        form({ method: "POST", action: `/pads/entry/${encodeURIComponent(pad.rootId)}` },
-          textarea({ name: "text", rows: "12", class: "pad-editor-white", placeholder: i18n.padEditorPlaceholder || "Start writing..." }, currentText),
-          button({ type: "submit", class: "create-button" }, i18n.padSubmitEntry || "Submit")
-        )
-      )
-    : div({ class: "pad-editor-area" },
-        params.selectedVersion
-          ? div({ class: "pad-viewer-back" },
-              a({ href: `/pads/${encodeURIComponent(pad.rootId)}`, class: "filter-btn" },
-                "\u2190 " + (i18n.padBackToEditor || "Back to editor"))
-            )
-          : null,
-        coloredView
-      )
-
   const versionList = entries.length > 0
   const versionList = entries.length > 0
     ? div({ class: "pad-version-list" },
     ? div({ class: "pad-version-list" },
         h4(i18n.padVersionHistory || "Version History"),
         h4(i18n.padVersionHistory || "Version History"),
@@ -325,14 +312,29 @@ exports.singlePadView = async (pad, entries, params) => {
       )
       )
     : null
     : null
 
 
-  const padMain = div({ class: "tribe-main" },
-    div({ class: "pad-main-layout" },
-      div({ class: "pad-main-left" },
-        div({ class: "pad-editor-container" }, editorArea)
-      ),
-      versionList ? div({ class: "pad-main-right" }, versionList) : null
-    )
-  )
+  const editorArea = isMember && !padClosed && !params.selectedVersion
+    ? div({ class: "pad-editor-area" },
+        coloredView,
+        form({ method: "POST", action: `/pads/entry/${encodeURIComponent(pad.rootId)}` },
+          textarea({ name: "text", rows: "12", class: "pad-editor-white", placeholder: i18n.padEditorPlaceholder || "Start writing..." }, currentText),
+          button({ type: "submit", class: "create-button" }, i18n.padSubmitEntry || "Submit")
+        ),
+        versionList ? div({ class: "pad-version-section" }, versionList) : null
+      )
+    : div({ class: "pad-editor-area" },
+        params.selectedVersion
+          ? div({ class: "pad-viewer-back" },
+              a({ href: `/pads/${encodeURIComponent(pad.rootId)}`, class: "filter-btn" },
+                "\u2190 " + (i18n.padBackToEditor || "Back to editor"))
+            )
+          : null,
+        coloredView,
+        versionList ? div({ class: "pad-version-section" }, versionList) : null
+      )
+
+  const padMain = isRestrictedInviteOnly
+    ? div({ class: "tribe-main" }, p({ class: "access-denied-msg" }, i18n.padAccessDenied))
+    : div({ class: "tribe-main" }, editorArea)
 
 
   return template(
   return template(
     pad.title || i18n.padsTitle || "Pad",
     pad.title || i18n.padsTitle || "Pad",

+ 16 - 2
src/views/search_view.js

@@ -4,6 +4,8 @@ const moment = require("../server/node_modules/moment");
 const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
 const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
 const { renderUrl } = require('../backend/renderUrl');
 const { renderUrl } = require('../backend/renderUrl');
 const { sanitizeHtml } = require('../backend/sanitizeHtml');
 const { sanitizeHtml } = require('../backend/sanitizeHtml');
+const { config } = require("../server/SSB_server.js");
+const userId = config.keys.id;
 
 
 const decodeMaybe = (s) => {
 const decodeMaybe = (s) => {
   try { return decodeURIComponent(String(s || '')); } catch { return String(s || ''); }
   try { return decodeURIComponent(String(s || '')); } catch { return String(s || ''); }
@@ -477,19 +479,31 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
           content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.searchPriceLabel || 'PRICE') + ':'), span({ class: 'card-value' }, `${content.price} ECO`)) : null,
           content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.searchPriceLabel || 'PRICE') + ':'), span({ class: 'card-value' }, `${content.price} ECO`)) : null,
           content.stock !== undefined ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, content.stock)) : null
           content.stock !== undefined ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, content.stock)) : null
         );
         );
-      case 'chat':
+      case 'chat': {
+        const chatInviteOnly = content.status === 'INVITE-ONLY' && content.author !== userId && !(Array.isArray(content.members) && content.members.includes(userId));
+        if (chatInviteOnly) return div({ class: 'search-chat' },
+          content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatsTitle || 'Chat') + ':'), span({ class: 'card-value' }, content.title)) : null,
+          div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatStatus || 'STATUS') + ':'), span({ class: 'card-value' }, i18n.chatStatusInviteOnly || 'INVITE-ONLY'))
+        );
         return div({ class: 'search-chat' },
         return div({ class: 'search-chat' },
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatsTitle || 'Chat') + ':'), span({ class: 'card-value' }, content.title)) : null,
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatsTitle || 'Chat') + ':'), span({ class: 'card-value' }, content.title)) : null,
           content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatDescription || 'Description') + ':'), span({ class: 'card-value' }, content.description)) : null,
           content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatDescription || 'Description') + ':'), span({ class: 'card-value' }, content.description)) : null,
           content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatCategoryLabel || 'Category') + ':'), span({ class: 'card-value' }, content.category)) : null,
           content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatCategoryLabel || 'Category') + ':'), span({ class: 'card-value' }, content.category)) : null,
           content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatStatus || 'STATUS') + ':'), span({ class: 'card-value' }, content.status)) : null
           content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatStatus || 'STATUS') + ':'), span({ class: 'card-value' }, content.status)) : null
         );
         );
-      case 'pad':
+      }
+      case 'pad': {
+        const padInviteOnly = content.status === 'INVITE-ONLY' && content.author !== userId && !(Array.isArray(content.members) && content.members.includes(userId));
+        if (padInviteOnly) return div({ class: 'search-pad' },
+          content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padTitle || 'Pad') + ':'), span({ class: 'card-value' }, content.title)) : null,
+          div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padStatusLabel || 'Status') + ':'), span({ class: 'card-value' }, i18n.padStatusInviteOnly || 'INVITE-ONLY'))
+        );
         return div({ class: 'search-pad' },
         return div({ class: 'search-pad' },
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padTitle || 'Pad') + ':'), span({ class: 'card-value' }, content.title)) : null,
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padTitle || 'Pad') + ':'), span({ class: 'card-value' }, content.title)) : null,
           content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padStatusLabel || 'Status') + ':'), span({ class: 'card-value' }, content.status)) : null,
           content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padStatusLabel || 'Status') + ':'), span({ class: 'card-value' }, content.status)) : null,
           content.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padDeadlineLabel || 'Deadline') + ':'), span({ class: 'card-value' }, content.deadline)) : null
           content.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padDeadlineLabel || 'Deadline') + ':'), span({ class: 'card-value' }, content.deadline)) : null
         );
         );
+      }
       case 'gameScore':
       case 'gameScore':
         return div({ class: 'search-game' },
         return div({ class: 'search-game' },
           content.game ? div({ class: 'game-row' },
           content.game ? div({ class: 'game-row' },

+ 30 - 17
src/views/task_view.js

@@ -387,28 +387,41 @@ exports.singleTaskView = async (task, filter, comments = []) => {
   const assignees = safeArray(task.assignees);
   const assignees = safeArray(task.assignees);
   const commentCount = typeof task.commentCount === "number" ? task.commentCount : 0;
   const commentCount = typeof task.commentCount === "number" ? task.commentCount : 0;
 
 
+  const isPrivateNoAccess = String(task.isPublic || "").toUpperCase() === "PRIVATE" &&
+    String(task.author) !== String(userId) &&
+    !assignees.includes(userId);
+
+  const filterBar = div(
+    { class: "filters" },
+    form(
+      { method: "GET", action: "/tasks" },
+      button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAll),
+      button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMine),
+      button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAssigned),
+      button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterOpen),
+      button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterInProgress),
+      button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterClosed),
+      button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterLow),
+      button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMedium),
+      button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterHigh),
+      button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterUrgent),
+      button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.taskCreateButton)
+    )
+  );
+
+  if (isPrivateNoAccess) {
+    return template(
+      task.title,
+      section(filterBar, p({ class: "access-denied-msg" }, i18n.contentAccessDenied))
+    );
+  }
+
   const topbar = renderTaskTopbar(task, currentFilter, { single: true });
   const topbar = renderTaskTopbar(task, currentFilter, { single: true });
 
 
   return template(
   return template(
     task.title,
     task.title,
     section(
     section(
-      div(
-        { class: "filters" },
-        form(
-          { method: "GET", action: "/tasks" },
-          button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAll),
-          button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMine),
-          button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAssigned),
-          button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterOpen),
-          button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterInProgress),
-          button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterClosed),
-          button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterLow),
-          button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMedium),
-          button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterHigh),
-          button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterUrgent),
-          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.taskCreateButton)
-        )
-      ),
+      filterBar,
       div(
       div(
         { class: "card card-section task" },
         { class: "card card-section task" },
         topbar ? topbar : null,
         topbar ? topbar : null,