Upgrade mobile UX to Oasis 0.9.0

Rebase onto upstream 0.9.0. Mobile topbar (logo->home, avatar->profile),
hexagon category nav (hive) and bottom bar; remove the six top-bar
buttons (inbox/pm/publish/search/graphos/peers) that cluttered the header.
Theme OasisMobileUX carries topbar/hexagon/bottombar styles.
This commit is contained in:
s1to 2026-08-01 13:41:09 +02:00
parent de36b38851
commit 8d21216362
69 changed files with 8974 additions and 820 deletions

View file

@ -82,7 +82,7 @@ const HIDDEN_ENVELOPE_TYPES = new Set([
'courts-key'
]);
module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel }) => {
module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
@ -143,6 +143,72 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel }) => {
return t.length > max ? t.slice(0, max - 1) + '…' : t;
};
// Builds self-contained chatThread items (title + recent messages embedded) for
// general (non-tribe) OPEN chats that have activity. Emitted as one item per chat
// with ts = latest message, so it survives the recent/all/mine filters intact.
const CHAT_THREAD_LIMIT = 6;
const buildChatThreads = async (ssbClient, idToAction, rootOf) => {
const replacedChatIds = new Set();
for (const a of idToAction.values()) {
if (a.type !== 'chat') continue;
const rep = a.content && a.content.replaces;
if (rep) replacedChatIds.add(rep);
}
const stateByRoot = new Map();
for (const a of idToAction.values()) {
if (a.type !== 'chat') continue;
const root = rootOf(a.id);
const isTip = !replacedChatIds.has(a.id);
const prev = stateByRoot.get(root);
// Resolve the chain tip by replaces (a non-replaced action wins), breaking ts
// ties correctly — the mock and real SSB can stamp equal timestamps.
if (prev && !((isTip && !prev.isTip) || (isTip === prev.isTip && (a.ts || 0) >= prev.ts))) continue;
const cc = a.content || {};
stateByRoot.set(root, { ts: a.ts || 0, isTip, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || '') });
}
const msgsByRoot = new Map();
for (const a of idToAction.values()) {
if (a.type !== 'chatMessage') continue;
const c = a.content || {};
if (c.tribeId || c.encryptedText) continue;
const root = c.chatId;
if (!root || typeof c.text !== 'string' || !c.text) continue;
if (!msgsByRoot.has(root)) msgsByRoot.set(root, []);
msgsByRoot.get(root).push(a);
}
for (const root of msgsByRoot.keys()) {
if (stateByRoot.has(root)) continue;
const val = await getMsg(ssbClient, root);
const cc = val && val.content;
if (cc && typeof cc === 'object' && cc.type === 'chat') {
stateByRoot.set(root, { ts: 0, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || '') });
} else {
stateByRoot.set(root, null);
}
}
const items = [];
for (const [root, msgs] of msgsByRoot.entries()) {
const info = stateByRoot.get(root);
if (!info || info.tribeId || info.status !== 'OPEN') continue;
const asc = msgs.slice().sort((x, y) => (x.ts || 0) - (y.ts || 0));
const last = asc[asc.length - 1];
items.push({
id: `chatthread:${root}`,
type: 'chatThread',
author: last.author,
ts: last.ts || 0,
content: {
type: 'chatThread',
chatRoot: root,
title: info.title || '',
description: info.description || '',
replies: asc.slice(-CHAT_THREAD_LIMIT).map(m => ({ id: m.id, author: m.author, ts: m.ts || 0, text: (m.content && m.content.text) || '' }))
}
});
}
return items;
};
return {
invalidateCache() {
_feedCache = null;
@ -256,7 +322,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel }) => {
const target = String(c.target || '');
const author = v.author;
if (!target || !author) continue;
const key = `${target}${author}`;
const key = `${target}\u0000${author}`;
const prev = aiHelpfulSeen.get(key);
const curTs = v.timestamp || 0;
if (!prev || curTs > prev.ts) aiHelpfulSeen.set(key, { target, ts: curTs, helpful: c.helpful !== false });
@ -491,7 +557,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel }) => {
if (tombstoned.has(a.id)) continue;
if (a.type === 'tribe' && parentOf.has(a.id)) continue;
const c = a.content || {};
if (c.root && tombstoned.has(c.root)) continue;
if (c.root && tombstoned.has(c.root) && !c.replaces) continue;
if (a.type === 'vote' && tombstoned.has(c.vote?.link)) continue;
if (a.type === 'spread' && (c.spreadTargetId || c.vote?.link) && tombstoned.has(c.spreadTargetId || c.vote?.link)) continue;
if (c.key && tombstoned.has(c.key)) continue;
@ -598,8 +664,33 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel }) => {
deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x });
if (industryModel && deduped.some(a => a.type === 'industryBuild')) {
const builds = await industryModel.listAllBuilds().catch(() => []);
const buildStatusById = new Map();
for (const b of (builds || [])) buildStatusById.set(b.id || b.key, String(b.status || 'PROPOSED').toUpperCase());
deduped = deduped.filter(a => {
if (a.type !== 'industryBuild') return true;
const st = buildStatusById.get(a.id);
if (st === undefined || st === 'PROPOSED' || st === 'REJECTED') return false;
a.content = { ...a.content, buildStatus: st };
return true;
});
}
if (industryModel && deduped.some(a => a.type === 'industryBlueprint')) {
const bps = await industryModel.listAllBlueprints().catch(() => []);
const bpById = new Map((bps || []).map(b => [b.id || b.key, b]));
for (const a of deduped) {
if (a.type !== 'industryBlueprint') continue;
const b = bpById.get(a.id);
if (b && b.estTotal != null) a.content = { ...a.content, estTotal: b.estTotal };
}
}
const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']);
const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log', 'gameScore']);
const chatThreadItems = await buildChatThreads(ssbClient, idToAction, rootOf, deduped);
deduped = deduped.concat(chatThreadItems);
const isAllowedTribeActivity = (a) => {
if (tribeInternalTypes.has(a.type)) return false;
const c = a.content || {};
@ -628,6 +719,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel }) => {
if (a.type === 'shop' && String(c.visibility || '').toUpperCase() === 'CLOSED' && a.author !== userId) return false;
if (a.type === 'curriculum' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId) return false;
if (a.type === 'shopProduct' && c.shopVisibility && String(c.shopVisibility).toUpperCase() === 'CLOSED' && a.author !== userId) return false;
if (a.type === 'transfer' && String(c.status || '').toUpperCase() === 'UNCONFIRMED' && a.author !== userId && c.from !== userId && c.to !== userId) return false;
return true;
};
const isVisible = (a) => {
@ -664,9 +756,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel }) => {
});
else if (filter === 'task')
out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment');
else if (filter === 'industry')
out = deduped.filter(a => ['industry', 'industryBuild', 'industryBlueprint', 'industryAllocation'].includes(a.type) && isVisible(a));
else if (filter === 'pad') out = deduped.filter(a => a.type === 'pad' && (a.content || {}).status === 'OPEN');
else if (filter === 'chat') out = deduped.filter(a => a.type === 'chat' && (a.content || {}).status === 'OPEN');
else if (filter === 'chat') out = deduped.filter(a => (a.type === 'chat' || a.type === 'chatThread') && isAllowedTribeActivity(a) && isVisible(a));
else if (filter === 'calendar') out = deduped.filter(a => a.type === 'calendar' && (a.content || {}).status === 'OPEN');
else if (filter === 'transfer') out = deduped.filter(a => a.type === 'transfer' && isVisible(a));
else out = deduped.filter(a => a.type === filter);
out.sort((a, b) => (b.ts || 0) - (a.ts || 0));

View file

@ -19,7 +19,7 @@ function writeAgendaConfig(cfg) {
fs.writeFileSync(agendaConfigPath, JSON.stringify(cfg, null, 2));
}
module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel }) => {
module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel, industryModel }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
@ -184,7 +184,11 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
? projectsModel.listProjects('all').then(normalize).catch(() => [])
: fetchItems('project');
const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll] = await Promise.all([
const industryViaModel = industryModel && typeof industryModel.listMyBuilds === 'function'
? industryModel.listMyBuilds().then(normalize).catch(() => [])
: [];
const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll, industryAll] = await Promise.all([
tasksViaModel,
eventsViaModel,
fetchItems('transfer'),
@ -193,7 +197,8 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
fetchItems('report'),
jobsViaModel,
projectsViaModel,
calendarsViaModel
calendarsViaModel,
industryViaModel
]);
const tasks = tasksAll.filter(c => Array.isArray(c.assignees) && c.assignees.includes(userId)).map(t => ({ ...t, type: 'task' }));
@ -206,6 +211,7 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
const reports = reportsAll.filter(c => c.author === userId || (Array.isArray(c.confirmations) && c.confirmations.includes(userId))).map(r => ({ ...r, type: 'report' }));
const jobs = jobsAll.filter(c => c.author === userId || (Array.isArray(c.subscribers) && c.subscribers.includes(userId))).map(j => ({ ...j, type: 'job', title: j.title }));
const projects = projectsAll.map(p => ({ ...p, type: 'project' }));
const industryBuilds = industryAll.map(b => ({ ...b, type: 'industry', title: b.title }));
const myCalendars = calendarsAll
.filter(c => c.author === userId || (Array.isArray(c.participants) && c.participants.includes(userId)));
const calendars = myCalendars.map(c => ({ ...c, type: 'calendar' }));
@ -241,6 +247,7 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
...reports,
...jobs,
...projects,
...industryBuilds,
...calendars,
...calendarDates
];
@ -260,6 +267,7 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
else if (filter === 'closed') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'CLOSED');
else if (filter === 'jobs') filtered = filtered.filter(i => i.type === 'job');
else if (filter === 'projects') filtered = filtered.filter(i => i.type === 'project');
else if (filter === 'industry') filtered = filtered.filter(i => i.type === 'industry');
else if (filter === 'calendars') filtered = filtered.filter(i => i.type === 'calendar' || i.type === 'calendarDate');
else if (filter === 'today') {
const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0);
@ -313,6 +321,7 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
reports: mainItems.filter(i => i.type === 'report').length,
jobs: mainItems.filter(i => i.type === 'job').length,
projects: mainItems.filter(i => i.type === 'project').length,
industry: mainItems.filter(i => i.type === 'industry').length,
calendars: mainItems.filter(i => i.type === 'calendar' || i.type === 'calendarDate').length,
today: mainItems.filter(i => { const d = itemTs(i); return d >= startOfDay.getTime() && d <= endOfDay.getTime(); }).length,
upcoming: mainItems.filter(i => itemTs(i) > now).length,

View file

@ -589,6 +589,14 @@ function scoreFromActions(actions) {
else if (t === "chat") score += 1 * decay;
else if (t === "gamescore") score += 2 * decay;
else if (t === "pixelia") score += 2 * decay;
else if (rawType === "industry") score += 8 * decay;
else if (rawType === "industryblueprint") score += 6 * decay;
else if (rawType === "industrybuild") score += 8 * decay;
else if (rawType === "industrycontribution") score += 4 * decay;
else if (rawType === "industryallocation") score += 10 * decay;
else if (rawType === "industryvote") score += 2 * decay;
else if (rawType === "industrymember") score += 2 * decay;
else if (rawType === "industryopinion") score += 1 * decay;
}
return Math.max(0, Math.round(score));
}
@ -754,6 +762,35 @@ async function getUserArchTax(userId) {
return archTaxFromDays(ageDays);
}
let _industryModel = null;
function industryModel() {
if (_industryModel) return _industryModel;
if (!services || !services.cooler) return null;
try { _industryModel = require("./industry_model")({ cooler: services.cooler }); } catch (_) { _industryModel = null; }
return _industryModel;
}
async function getIndustryBalance(userId) {
const uid = resolveUserId(userId);
const industry = industryModel();
if (!industry) return { received: 0, sent: 0, net: 0, networkTotal: 0 };
const builds = await industry.listAllBuilds().catch(() => []);
let production = 0;
let earned = 0;
for (const b of (builds || [])) {
const est = parseFloat(b && b.estTotal);
if (Number.isFinite(est) && est > 0) production += est;
const mine = parseFloat(b && b.points && b.points[uid]);
if (Number.isFinite(mine) && mine > 0) earned += mine;
}
return {
received: Number(earned.toFixed(6)),
sent: 0,
net: Number(earned.toFixed(6)),
networkTotal: Number(production.toFixed(6))
};
}
async function getUserEngagementScore(userId) {
const ssb = await openSsb();
const uid = resolveUserId(userId);
@ -1062,6 +1099,7 @@ async function getLastPublishedTimestamp(userId) {
allocations = await getUbiAllocationsFromSSB();
}
const userBalance = await safeGetBalance("user");
const industryBal = await getIndustryBalance(uid).catch(() => ({ received: 0, sent: 0, net: 0, networkTotal: 0 }));
const epochs = await epochsRepo.list();
let computed = null;
try { computed = await computeEpoch({ epochId, userId: uid, rules: DEFAULT_RULES }); } catch {}
@ -1080,6 +1118,10 @@ async function getLastPublishedTimestamp(userId) {
const hasValidWallet = !!(userAddress && isValidEcoinAddress(userAddress) && userWalletCfg.url);
const summary = {
userBalance,
industryBalance: industryBal.net,
industryNetworkTotal: industryBal.networkTotal,
industryReceived: industryBal.received,
industrySent: industryBal.sent,
epochId,
pool: poolForEpoch,
weightsSum: computed?.epoch?.weightsSum || 0,

View file

@ -305,17 +305,11 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
try {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const live = new Set()
const tomb = buildValidatedTombstoneSet(messages)
for (const m of messages) {
const c = m.value && m.value.content
if (!c) continue
if (c.type === "calendar") live.add(m.key)
}
const all = ownCrypto.getAllRootIds()
let removed = 0
for (const rid of all) {
if (!live.has(rid) || tomb.has(rid)) {
if (tomb.has(rid)) {
try { ownCrypto.dropKey(rid); removed += 1 } catch (_) {}
}
}

View file

@ -779,17 +779,11 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
try {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const live = new Set()
for (const m of messages) {
const c = m.value && m.value.content
if (!c) continue
if (c.type === "chat") live.add(m.key)
}
const tomb = buildValidatedTombstoneSet(messages)
const all = ownCrypto.getAllRootIds()
let removed = 0
for (const rid of all) {
if (!live.has(rid) || tomb.has(rid)) {
if (tomb.has(rid)) {
try { ownCrypto.dropKey(rid); removed += 1 } catch (_) {}
}
}

343
src/models/dev_model.js Normal file
View file

@ -0,0 +1,343 @@
const fs = require('fs');
const path = require('path');
const { getConfig } = require('../configs/config-manager.js');
const ROOT = path.resolve(__dirname, '..', '..');
const ALLOWED_ROOTS = ['src', 'docs', 'scripts', 'test'];
const ALLOWED_ROOT_FILES = ['README.md', 'LICENSE', 'install.sh', 'oasis.sh'];
const DENIED_SEGMENTS = new Set(['node_modules', '.git', '.ssb', 'packages', 'configs', 'embeddings', 'tiles', 'cache', 'results']);
const DENIED_NAMES = new Set(['secret', '.env', '.npmrc', 'package-lock.json']);
const DENIED_EXTENSIONS = new Set(['.gguf', '.jks', '.keystore', '.enc', '.pem', '.key', '.p12', '.crt']);
const VIEWABLE_EXTENSIONS = new Set(['.js', '.mjs', '.json', '.css', '.md', '.sh', '.html', '.txt', '.yml', '.yaml']);
const STATS_TTL = 60 * 1000;
let statsCache = null;
let statsCacheAt = 0;
const MAX_VIEW_BYTES = 2 * 1024 * 1024;
const PAGE_LINES = 500;
const SEARCH_MAX = 200;
const SEARCH_SNIPPET = 200;
const extOf = (name) => {
const i = String(name || '').lastIndexOf('.');
return i > 0 ? String(name).slice(i).toLowerCase() : '';
};
const isDeniedEntry = (name) => {
const n = String(name || '');
if (!n || n === '.' || n === '..') return true;
if (n.toLowerCase().startsWith('.git')) return true;
if (DENIED_SEGMENTS.has(n)) return true;
if (DENIED_NAMES.has(n)) return true;
if (DENIED_EXTENSIONS.has(extOf(n))) return true;
return false;
};
const PLAIN_TEXT_NAME = /^[A-Z][A-Z0-9_-]*$/;
const isViewable = (name) => (VIEWABLE_EXTENSIONS.has(extOf(name)) || PLAIN_TEXT_NAME.test(String(name || ''))) && !isDeniedEntry(name);
function realRoot() {
try { return fs.realpathSync(ROOT); } catch (_) { return ROOT; }
}
function splitPath(rel) {
const raw = String(rel || '');
for (let i = 0; i < raw.length; i++) { if (raw.charCodeAt(i) < 32) throw new Error('Invalid path'); }
return raw.replace(/^[/\\]+/, '').split(/[/\\]+/).filter(s => s && s !== '.');
}
function resolveSafe(rel) {
const segments = splitPath(rel);
if (segments.some(s => s === '..')) throw new Error('Invalid path');
if (segments.some(isDeniedEntry)) throw new Error('Path not available');
if (segments.length) {
const head = segments[0];
const isRootFile = segments.length === 1 && ALLOWED_ROOT_FILES.includes(head);
if (!ALLOWED_ROOTS.includes(head) && !isRootFile) throw new Error('Path not available');
}
const target = path.resolve(ROOT, segments.join(path.sep));
let real;
try { real = fs.realpathSync(target); } catch (_) { throw new Error('Not found'); }
const base = realRoot();
if (real !== base && !real.startsWith(base + path.sep)) throw new Error('Path not available');
return { abs: real, rel: segments.join('/'), segments };
}
function parentsOf(segments) {
const out = [];
let acc = '';
for (const s of segments) {
acc = acc ? `${acc}/${s}` : s;
out.push({ name: s, path: acc });
}
return out;
}
function rootListing() {
const dirs = [];
const files = [];
for (const name of ALLOWED_ROOTS) {
const abs = path.join(ROOT, name);
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) dirs.push({ name, path: name });
}
for (const name of ALLOWED_ROOT_FILES) {
const abs = path.join(ROOT, name);
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
files.push({ name, path: name, size: fs.statSync(abs).size, ext: extOf(name), viewable: isViewable(name) });
}
return { path: '', parents: [], dirs, files, hidden: 0 };
}
function walkFiles(relDir, onFile) {
let entries;
try { entries = fs.readdirSync(path.resolve(ROOT, relDir), { withFileTypes: true }); } catch (_) { return true; }
for (const e of entries) {
if (isDeniedEntry(e.name)) continue;
const rel = relDir ? `${relDir}/${e.name}` : e.name;
if (e.isSymbolicLink()) continue;
if (e.isDirectory()) { if (walkFiles(rel, onFile) === false) return false; continue; }
if (!e.isFile()) continue;
if (onFile(rel, e.name) === false) return false;
}
return true;
}
const MODULE_ALIASES = {
ai: { model: 'src/AI/ai_service.mjs', view: 'src/views/AI_view.js' },
aiNav: { model: 'src/AI/routes_index.js', view: 'src/views/AI_view.js', paths: ['/ai/ask'] },
docs: { paths: ['/documents'] },
latest: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest'] },
popular: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/popular'] },
topics: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/topics'] },
summaries: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/summaries'] },
threads: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/threads'] },
multiverse: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/extended'] },
invites: { model: 'src/models/main_models.js', view: 'src/views/invites_view.js' },
graphos: { model: 'src/models/main_models.js', view: 'src/views/graphos_view.js' }
};
const GROUPS = {
translations: ['src/client/assets/translations'],
styles: ['src/client/assets/styles', 'src/client/assets/themes'],
tests: ['test']
};
module.exports = {
ROOT,
PAGE_LINES,
GROUPS,
listGroup(key) {
const roots = GROUPS[String(key || '')];
if (!roots) throw new Error('Not found');
const dirs = [];
const files = [];
for (const rel of roots) {
let listing;
try { listing = module.exports.listTree(rel); } catch (_) { continue; }
dirs.push(...listing.dirs);
files.push(...listing.files);
}
dirs.sort((a, b) => a.name.localeCompare(b.name));
files.sort((a, b) => a.name.localeCompare(b.name));
return { group: String(key), path: '', parents: [], dirs, files, hidden: 0 };
},
listTree(relPath) {
const segments = splitPath(relPath);
if (!segments.length) return rootListing();
const target = resolveSafe(relPath);
const stat = fs.statSync(target.abs);
if (!stat.isDirectory()) throw new Error('Not a directory');
const entries = fs.readdirSync(target.abs, { withFileTypes: true });
const dirs = [];
const files = [];
let hidden = 0;
for (const e of entries) {
if (isDeniedEntry(e.name) || e.isSymbolicLink()) { hidden++; continue; }
const rel = `${target.rel}/${e.name}`;
if (e.isDirectory()) { dirs.push({ name: e.name, path: rel }); continue; }
if (!e.isFile()) { hidden++; continue; }
let size = 0;
try { size = fs.statSync(path.join(target.abs, e.name)).size; } catch (_) { size = 0; }
files.push({ name: e.name, path: rel, size, ext: extOf(e.name), viewable: isViewable(e.name) });
}
dirs.sort((a, b) => a.name.localeCompare(b.name));
files.sort((a, b) => a.name.localeCompare(b.name));
return { path: target.rel, parents: parentsOf(target.segments), dirs, files, hidden };
},
readFile(relPath, opts = {}) {
const target = resolveSafe(relPath);
const stat = fs.statSync(target.abs);
if (!stat.isFile()) throw new Error('Not a file');
if (!isViewable(path.basename(target.abs))) throw new Error('This file type cannot be displayed');
if (stat.size > MAX_VIEW_BYTES) throw new Error('This file is too large to be displayed');
const all = fs.readFileSync(target.abs, 'utf8').split('\n');
const totalLines = all.length;
const requested = parseInt(opts.from, 10);
const from = Math.min(Math.max(Number.isFinite(requested) ? requested : 1, 1), Math.max(1, totalLines));
const to = Math.min(from + PAGE_LINES - 1, totalLines);
const lines = [];
for (let n = from; n <= to; n++) lines.push({ n, text: all[n - 1] });
return {
path: target.rel,
parents: parentsOf(target.segments),
name: path.basename(target.abs),
size: stat.size,
totalLines,
from,
to,
pageLines: PAGE_LINES,
hasPrev: from > 1,
hasNext: to < totalLines,
lines
};
},
rawFile(relPath) {
const target = resolveSafe(relPath);
const stat = fs.statSync(target.abs);
if (!stat.isFile()) throw new Error('Not a file');
if (!isViewable(path.basename(target.abs))) throw new Error('This file type cannot be displayed');
if (stat.size > MAX_VIEW_BYTES) throw new Error('This file is too large to be displayed');
return { name: path.basename(target.abs), content: fs.readFileSync(target.abs, 'utf8') };
},
searchCode(query, opts = {}) {
const q = String(query || '').trim();
if (q.length < 2) return { query: q, results: [], truncated: false, scanned: 0 };
const wanted = String(opts.ext || '').trim().toLowerCase();
const max = Math.min(Math.max(parseInt(opts.max, 10) || SEARCH_MAX, 1), SEARCH_MAX);
const needle = q.toLowerCase();
const results = [];
let scanned = 0;
let truncated = false;
for (const root of ALLOWED_ROOTS) {
if (truncated) break;
walkFiles(root, (rel, name) => {
if (!isViewable(name)) return true;
if (wanted && extOf(name) !== wanted) return true;
let stat;
try { stat = fs.statSync(path.resolve(ROOT, rel)); } catch (_) { return true; }
if (stat.size > MAX_VIEW_BYTES) return true;
scanned++;
let content;
try { content = fs.readFileSync(path.resolve(ROOT, rel), 'utf8'); } catch (_) { return true; }
if (content.toLowerCase().indexOf(needle) < 0) return true;
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().indexOf(needle) < 0) continue;
results.push({ path: rel, line: i + 1, text: lines[i].trim().slice(0, SEARCH_SNIPPET) });
if (results.length >= max) { truncated = true; return false; }
}
return true;
});
}
return { query: q, results, truncated, scanned };
},
moduleMap() {
const cfg = getConfig();
const mods = Object.keys((cfg && cfg.modules) || {}).map(k => k.replace(/Mod$/, '')).sort();
let backend = '';
try { backend = fs.readFileSync(path.join(ROOT, 'src', 'backend', 'backend.js'), 'utf8'); } catch (_) { backend = ''; }
const modelFiles = fs.existsSync(path.join(ROOT, 'src', 'models')) ? fs.readdirSync(path.join(ROOT, 'src', 'models')) : [];
const viewFiles = fs.existsSync(path.join(ROOT, 'src', 'views')) ? fs.readdirSync(path.join(ROOT, 'src', 'views')) : [];
const stem = (s) => String(s || '').toLowerCase().replace(/s$/, '');
const pick = (files, name, suffixes) => {
for (const suf of suffixes) {
const exact = files.find(f => f.toLowerCase() === `${name.toLowerCase()}${suf}`);
if (exact) return exact;
}
const singular = name.replace(/s$/, '');
for (const suf of suffixes) {
const short = files.find(f => f.toLowerCase() === `${singular.toLowerCase()}${suf}`);
if (short) return short;
}
const target = stem(name);
return files.find(f => {
const suf = suffixes.find(x => f.toLowerCase().endsWith(x));
if (!suf) return false;
const base = stem(f.slice(0, f.length - suf.length));
return base.startsWith(target) || target.startsWith(base);
}) || null;
};
const testsIndex = new Map();
const modsDir = path.join(ROOT, 'test', 'mods');
const indexTests = (dir, prefix) => {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
for (const e of entries) {
if (!e.isDirectory()) continue;
const rel = `${prefix}/${e.name}`;
if (!testsIndex.has(e.name)) testsIndex.set(e.name, rel);
indexTests(path.join(dir, e.name), rel);
}
};
indexTests(modsDir, 'test/mods');
return mods.map(name => {
const alias = MODULE_ALIASES[name] || {};
const routePaths = Array.isArray(alias.paths) && alias.paths.length ? alias.paths : [`/${name}`];
let routes = 0;
for (const routePath of routePaths) {
const escaped = routePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`\\.(get|post)\\(\\s*["'\`]${escaped}(?=["'/?])`, 'g');
routes += backend ? (backend.match(re) || []).length : 0;
}
const model = alias.model !== undefined ? alias.model : (pick(modelFiles, name, ['_model.js', '_models.js']) ? `src/models/${pick(modelFiles, name, ['_model.js', '_models.js'])}` : null);
const view = alias.view !== undefined ? alias.view : (pick(viewFiles, name, ['_view.js', '_views.js']) ? `src/views/${pick(viewFiles, name, ['_view.js', '_views.js'])}` : null);
return {
name,
enabled: (cfg.modules || {})[`${name}Mod`] !== 'off',
model,
view,
routes,
tests: testsIndex.get(name) || testsIndex.get(name.replace(/s$/, '')) || (() => {
const target = stem(name);
for (const [dir, rel] of testsIndex) {
const base = stem(dir);
if (base.startsWith(target) || target.startsWith(base)) return rel;
}
return null;
})()
};
});
},
projectStats() {
const now = Date.now();
if (statsCache && now - statsCacheAt < STATS_TTL) return statsCache;
let files = 0;
let lines = 0;
let bytes = 0;
for (const root of ALLOWED_ROOTS) {
walkFiles(root, (rel, name) => {
if (!isViewable(name)) return true;
try {
const abs = path.resolve(ROOT, rel);
const stat = fs.statSync(abs);
if (stat.size > MAX_VIEW_BYTES) return true;
files++;
bytes += stat.size;
lines += fs.readFileSync(abs, 'utf8').split('\n').length;
} catch (_) {}
return true;
});
}
const cfg = getConfig();
const modules = Object.keys((cfg && cfg.modules) || {}).length;
const translations = path.join(ROOT, 'src', 'client', 'assets', 'translations');
const languages = fs.existsSync(translations) ? fs.readdirSync(translations).filter(f => /^oasis_[a-z]{2}\.js$/.test(f)).length : 0;
const testsDir = path.join(ROOT, 'test', 'mods');
const testMods = fs.existsSync(testsDir) ? fs.readdirSync(testsDir).length : 0;
let version = '';
try { version = String(JSON.parse(fs.readFileSync(path.join(ROOT, 'src', 'server', 'package.json'), 'utf8')).version || ''); } catch (_) { version = ''; }
statsCache = { files, lines, bytes, modules, languages, testMods, version, node: process.version };
statsCacheAt = now;
return statsCache;
}
};

View file

@ -0,0 +1,200 @@
const pull = require('../server/node_modules/pull-stream');
const fs = require('fs');
const crypto = require('crypto');
const { Readable } = require('stream');
const fsc = require('../backend/fileshare_crypto');
const MAX_CHUNK_SIZE = 5 * 1024 * 1024;
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const addBlob = (client, buffer) =>
new Promise((resolve, reject) => {
pull(pull.values([buffer]), client.blobs.add((err, ref) => err ? reject(err) : resolve(ref)));
});
const getBlob = (client, ref) =>
new Promise((resolve, reject) => {
pull(client.blobs.get(ref), pull.collect((err, chunks) => err ? reject(err) : resolve(Buffer.concat(chunks))));
});
const hasBlob = (client, ref) =>
new Promise((resolve) => {
if (!client.blobs || typeof client.blobs.has !== 'function') return resolve(false);
client.blobs.has(ref, (err, has) => resolve(!err && !!has));
});
const wantBlob = (client, ref, timeoutMs) =>
new Promise((resolve) => {
if (typeof client.blobs.want !== 'function') return resolve(true);
let done = false;
const finish = (ok) => { if (done) return; done = true; if (timer) clearTimeout(timer); resolve(ok); };
const timer = timeoutMs ? setTimeout(() => finish(false), timeoutMs) : null;
client.blobs.want(ref, (err) => finish(!err));
});
const rmBlob = (client, ref) =>
new Promise((resolve) => {
if (typeof client.blobs.rm !== 'function') return resolve(false);
client.blobs.rm(ref, (err) => resolve(!err));
});
const createShareFromBuffer = async ({ buffer, filename, mime, chunkSize } = {}) => {
if (!Buffer.isBuffer(buffer)) throw new Error('buffer required');
const client = await openSsb();
const key = fsc.generateFileKey();
const size = fsc.DEFAULT_CHUNK_SIZE;
const useChunk = Math.min(Number(chunkSize) || fsc.DEFAULT_CHUNK_SIZE, MAX_CHUNK_SIZE);
const chunkHashes = [];
for (const part of fsc.splitBuffer(buffer, useChunk)) {
const payload = fsc.encryptChunk(part, key);
chunkHashes.push(await addBlob(client, payload));
}
const manifest = fsc.buildManifest({
filename, mime, size: buffer.length, chunkSize: useChunk,
chunkHashes, plainSha256: fsc.sha256Hex(buffer)
});
const manifestBlobId = await addBlob(client, fsc.encryptManifest(manifest, key));
return {
type: 'fileShare',
v: 1,
key: fsc.keyToHex(key),
manifestBlobId,
filename: manifest.filename,
mime: manifest.mime,
size: manifest.size,
chunkCount: chunkHashes.length
};
};
const createShareFromFile = async ({ filepath, filename, mime, chunkSize } = {}) => {
if (!filepath) throw new Error('filepath required');
const client = await openSsb();
const key = fsc.generateFileKey();
const useChunk = Math.min(Number(chunkSize) || fsc.DEFAULT_CHUNK_SIZE, MAX_CHUNK_SIZE);
const chunkHashes = [];
const hashAll = crypto.createHash('sha256');
let total = 0;
let carry = Buffer.alloc(0);
const flush = async (part) => {
hashAll.update(part);
total += part.length;
chunkHashes.push(await addBlob(client, fsc.encryptChunk(part, key)));
};
for await (const data of fs.createReadStream(filepath, { highWaterMark: useChunk })) {
carry = carry.length ? Buffer.concat([carry, data]) : data;
while (carry.length >= useChunk) {
await flush(carry.subarray(0, useChunk));
carry = carry.subarray(useChunk);
}
}
if (carry.length || !chunkHashes.length) await flush(carry);
const manifest = fsc.buildManifest({
filename, mime, size: total, chunkSize: useChunk,
chunkHashes, plainSha256: hashAll.digest('hex')
});
const manifestBlobId = await addBlob(client, fsc.encryptManifest(manifest, key));
return {
type: 'fileShare', v: 1,
key: fsc.keyToHex(key), manifestBlobId,
filename: manifest.filename, mime: manifest.mime, size: manifest.size,
chunkCount: chunkHashes.length
};
};
const openManifest = async (pointer) => {
const client = await openSsb();
const key = fsc.keyFromHex(pointer.key);
await wantBlob(client, pointer.manifestBlobId);
const payload = await getBlob(client, pointer.manifestBlobId);
return fsc.decryptManifest(payload, key);
};
const CHUNK_WANT_TIMEOUT = 30000;
const PREFLIGHT_TIMEOUT = 12000;
const readShareStream = (pointer) => {
const key = fsc.keyFromHex(pointer.key);
async function* gen() {
const client = await openSsb();
if (!(await wantBlob(client, pointer.manifestBlobId, CHUNK_WANT_TIMEOUT))) throw new Error('fileshare blob unavailable');
const manifest = fsc.decryptManifest(await getBlob(client, pointer.manifestBlobId), key);
for (const ref of manifest.chunks) {
if (!(await wantBlob(client, ref, CHUNK_WANT_TIMEOUT))) throw new Error('fileshare blob unavailable');
yield fsc.decryptChunk(await getBlob(client, ref), key);
}
}
return Readable.from(gen());
};
const ensureAvailable = async (pointer, timeoutMs = PREFLIGHT_TIMEOUT) => {
try {
const client = await openSsb();
const key = fsc.keyFromHex(pointer.key);
if (!(await wantBlob(client, pointer.manifestBlobId, timeoutMs))) return false;
const manifest = fsc.decryptManifest(await getBlob(client, pointer.manifestBlobId), key);
if (manifest.chunks.length && !(await wantBlob(client, manifest.chunks[0], timeoutMs))) return false;
return true;
} catch (_) {
return false;
}
};
const reassembleToBuffer = async (pointer) => {
const parts = [];
for await (const chunk of readShareStream(pointer)) parts.push(chunk);
return Buffer.concat(parts);
};
const isAvailable = async (pointer) => {
const client = await openSsb();
if (!(await hasBlob(client, pointer.manifestBlobId))) return false;
try {
const key = fsc.keyFromHex(pointer.key);
const manifest = fsc.decryptManifest(await getBlob(client, pointer.manifestBlobId), key);
for (const ref of manifest.chunks) if (!(await hasBlob(client, ref))) return false;
return true;
} catch (_) {
return false;
}
};
const removeLocalBlobs = async (pointer) => {
const client = await openSsb();
let removed = 0;
try {
const key = fsc.keyFromHex(pointer.key);
if (await hasBlob(client, pointer.manifestBlobId)) {
const manifest = fsc.decryptManifest(await getBlob(client, pointer.manifestBlobId), key);
for (const ref of manifest.chunks) if (await rmBlob(client, ref)) removed++;
}
} catch (_) {}
if (await rmBlob(client, pointer.manifestBlobId)) removed++;
return removed;
};
const pruneExpired = async (sentFileShares, ttlMs, now) => {
const cutoff = (Number.isFinite(now) ? now : Date.now()) - (Number(ttlMs) || 0);
let pruned = 0;
for (const item of (Array.isArray(sentFileShares) ? sentFileShares : [])) {
if (!item || !item.pointer) continue;
const sentAt = new Date(item.sentAt || 0).getTime();
if (!(sentAt <= cutoff)) continue;
const n = await removeLocalBlobs(item.pointer);
if (n > 0) pruned++;
}
return pruned;
};
return { createShareFromBuffer, createShareFromFile, openManifest, readShareStream, reassembleToBuffer, isAvailable, ensureAvailable, removeLocalBlobs, pruneExpired };
};

View file

@ -0,0 +1,993 @@
const pull = require("../server/node_modules/pull-stream")
const { getConfig } = require("../configs/config-manager.js")
const { buildValidatedTombstoneSet } = require('./tombstone_validator')
const logLimit = (getConfig().ssbLogStream && getConfig().ssbLogStream.limit) || 1000
module.exports = ({ cooler }) => {
let ssb
const openSsb = async () => {
if (!ssb) ssb = await cooler.open()
return ssb
}
const TYPE = "industry"
const SECTORS = ["software", "hardware", "agriculture", "textile", "energy", "food", "construction", "media", "services", "other"]
const POLICIES = ["open", "vote", "invite"]
const clampPercent = (n) => {
const x = parseInt(n, 10)
if (!Number.isFinite(x)) return 0
return Math.max(0, Math.min(100, x))
}
const clampMajority = (n) => {
const x = parseFloat(n)
if (!Number.isFinite(x)) return 0.5
return Math.max(0.5, Math.min(1, x))
}
const clampQuorum = (n) => {
const x = parseInt(n, 10)
if (!Number.isFinite(x) || x < 1) return 1
return Math.min(100000, x)
}
const safeSector = (s) => {
const v = String(s || "").toLowerCase().trim()
return SECTORS.includes(v) ? v : "other"
}
const safePolicy = (p) => {
const v = String(p || "").toLowerCase().trim()
return POLICIES.includes(v) ? v : "vote"
}
const nonNegNum = (v) => {
const x = parseFloat(v)
return Number.isFinite(x) && x >= 0 ? x : 0
}
async function getAllMsgs(ssbClient) {
return new Promise((r, j) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((e, m) => (e ? j(e) : r(m)))
)
})
}
function chainMaps(nodeMap) {
const next = new Map(), prev = new Map(), strictNext = new Map()
for (const [key, n] of nodeMap) {
const t = n.c.replaces
if (!t) continue
next.set(t, key); prev.set(key, t)
const orig = nodeMap.get(t)
if (orig && orig.author === n.author) strictNext.set(t, key)
}
return { next, prev, strictNext }
}
function buildIndex(messages) {
const tomb = buildValidatedTombstoneSet(messages)
const nodes = new Map()
const blueprints = new Map()
const builds = new Map()
const collab = { member: [], invite: [], apply: [], vote: [], opinion: [], buildStatus: [], buildDates: [], contribution: [], allocation: [] }
for (const m of messages) {
const c = m && m.value && m.value.content
if (!c) continue
if (c.type === "tombstone") continue
const ts = (m.value && m.value.timestamp) || 0
const author = m.value && m.value.author
if (c.type === "industryMember") { collab.member.push({ target: c.target, author, action: c.action === "leave" ? "leave" : "join", ts }); continue }
if (c.type === "industryInvite") { collab.invite.push({ target: c.target, author, invitee: c.invitee, ts }); continue }
if (c.type === "industryApply") { collab.apply.push({ target: c.target, author, ts }); continue }
if (c.type === "industryVote") { collab.vote.push({ target: c.target, author, subject: String(c.subject || ""), ref: String(c.ref || ""), choice: c.choice === "no" ? "no" : "yes", ts }); continue }
if (c.type === "industryOpinion") { collab.opinion.push({ target: c.target, author, category: c.category, ts }); continue }
if (c.type === "industryBlueprint") { blueprints.set(m.key, { key: m.key, ts, c, author }); continue }
if (c.type === "industryBuild") { builds.set(m.key, { key: m.key, ts, c, author }); continue }
if (c.type === "industryBuildStatus") { collab.buildStatus.push({ target: c.target, author, status: String(c.status || "").toUpperCase(), ts }); continue }
if (c.type === "industryBuildDates" || c.type === "industryBuildUpdate") { collab.buildDates.push({ target: c.target, author, title: String(c.title || ""), notes: String(c.notes || ""), startDate: String(c.startDate || ""), endDate: String(c.endDate || ""), ts }); continue }
if (c.type === "industryContribution") { collab.contribution.push({ target: c.target, author, kind: c.kind, hours: nonNegNum(c.hours), item: String(c.item || ""), value: nonNegNum(c.value), eco: nonNegNum(c.eco), transferId: c.transferId || null, note: String(c.note || ""), ts }); continue }
if (c.type === "industryAllocation") { collab.allocation.push({ target: c.target, author, shares: (c.shares && typeof c.shares === "object") ? c.shares : {}, amounts: (c.amounts && typeof c.amounts === "object") ? c.amounts : {}, pot: nonNegNum(c.pot), outputValue: nonNegNum(c.outputValue), disposition: c.disposition || null, ts }); continue }
if (c.type !== TYPE) continue
nodes.set(m.key, { key: m.key, ts, c, author })
}
const fc = chainMaps(nodes)
const bp = chainMaps(blueprints)
return { tomb, nodes, blueprints, builds, collab, naiveNext: fc.next, naivePrev: fc.prev, strictNext: fc.strictNext, bpNext: bp.next, bpPrev: bp.prev, bpStrictNext: bp.strictNext }
}
const rootOfIdx = (idx, key) => { let x = key, g = 0; while (idx.naivePrev.has(x) && idx.nodes.has(idx.naivePrev.get(x)) && g++ < 100000) x = idx.naivePrev.get(x); return x }
const rootOfBlueprint = (idx, key) => { let x = key, g = 0; while (idx.bpPrev.has(x) && idx.blueprints.has(idx.bpPrev.get(x)) && g++ < 100000) x = idx.bpPrev.get(x); return x }
function passesThreshold(yesCount, memberCount, quorum, majority) {
const size = Math.max(1, memberCount)
const need = Math.max(Math.min(clampQuorum(quorum), size), Math.ceil(size * clampMajority(majority)))
return yesCount >= need
}
function actionVoteState(idx, groupRoot, members, subject, ref) {
const pref = new Map()
for (const v of idx.collab.vote.filter(x => x.target === groupRoot && x.subject === subject && x.ref === ref).sort((a, b) => a.ts - b.ts)) {
if (!members.has(v.author)) continue
pref.set(v.author, v.choice)
}
const voters = [...pref.entries()].filter(([, c]) => c === "yes").map(([m]) => m)
return { yes: voters.length, voters }
}
function voteNeedOf(g) {
const size = Math.max(1, new Set(g.facility.members).size)
return Math.max(Math.min(clampQuorum(g.facility.quorum), size), Math.ceil(size * clampMajority(g.facility.majority)))
}
function actionApproved(idx, g, subject, ref) {
const members = new Set(g.facility.members)
if (members.size <= 1) return true
const st = actionVoteState(idx, g.root, members, subject, ref)
return passesThreshold(st.yes, members.size, g.facility.quorum, g.facility.majority)
}
function blueprintGovernance(idx, g, bpRoot) {
const members = new Set(g.facility.members)
const up = actionVoteState(idx, g.root, members, "bpUpdate", bpRoot)
const del = actionVoteState(idx, g.root, members, "bpDelete", bpRoot)
return {
root: bpRoot,
facilityMembers: members.size,
voteNeed: voteNeedOf(g),
updateYes: up.yes,
updateVoters: up.voters,
deleteYes: del.yes,
deleteVoters: del.voters,
updateApproved: actionApproved(idx, g, "bpUpdate", bpRoot),
deleteApproved: actionApproved(idx, g, "bpDelete", bpRoot)
}
}
function buildGovernance(idx, g, buildId) {
const members = new Set(g.facility.members)
const up = actionVoteState(idx, g.root, members, "buildUpdate", buildId)
const del = actionVoteState(idx, g.root, members, "buildDelete", buildId)
return {
facilityMembers: members.size,
voteNeed: voteNeedOf(g),
updateYes: up.yes,
updateVoters: up.voters,
deleteYes: del.yes,
deleteVoters: del.voters,
updateApproved: actionApproved(idx, g, "buildUpdate", buildId),
deleteApproved: actionApproved(idx, g, "buildDelete", buildId)
}
}
function resolveGroup(idx, root) {
const { nodes, collab, naiveNext, strictNext } = idx
const followStrict = (key) => { let x = key, g = 0; while (strictNext.has(x) && g++ < 100000) x = strictNext.get(x); return x }
const rootNode = nodes.get(root)
if (!rootNode) return null
const steward = rootNode.author
let tip = followStrict(root)
const tn = nodes.get(tip)
if (!tn || tn.author !== steward) tip = root
const best = nodes.get(tip)
if (!best) return null
const groupKeys = []
{ let x = root, g = 0; groupKeys.push(x); while (naiveNext.has(x) && g++ < 100000) { x = naiveNext.get(x); groupKeys.push(x) } }
const facility = { ...best.c, id: tip }
const policy = safePolicy(facility.membershipPolicy)
const quorum = clampQuorum(facility.quorum)
const majority = clampMajority(facility.majority)
const KIND_ORDER = { invite: 0, apply: 1, member: 2, vote: 3 }
const events = []
for (const e of collab.member.filter(x => x.target === root)) events.push({ kind: "member", ...e })
for (const e of collab.invite.filter(x => x.target === root)) events.push({ kind: "invite", ...e })
for (const e of collab.apply.filter(x => x.target === root)) events.push({ kind: "apply", ...e })
for (const e of collab.vote.filter(x => x.target === root)) events.push({ kind: "vote", ...e })
events.sort((a, b) => (a.ts - b.ts) || (KIND_ORDER[a.kind] - KIND_ORDER[b.kind]))
const members = new Set([steward])
const invites = new Set()
const applicants = new Set()
const admitVotes = new Map()
const dissolveVoters = new Set()
let dissolved = false
const admitKey = (uid) => "admit:" + uid
for (const ev of events) {
if (ev.kind === "invite") {
if (members.has(ev.author) && ev.invitee) invites.add(ev.invitee)
continue
}
if (ev.kind === "apply") {
if (!members.has(ev.author)) applicants.add(ev.author)
continue
}
if (ev.kind === "member") {
if (ev.action === "leave") { if (ev.author !== steward) members.delete(ev.author); continue }
if (members.has(ev.author)) continue
if (policy === "open") { members.add(ev.author); applicants.delete(ev.author) }
else if (policy === "invite" && invites.has(ev.author)) { members.add(ev.author); applicants.delete(ev.author) }
continue
}
if (ev.kind === "vote") {
if (!members.has(ev.author)) continue
if (ev.subject === "admit" && ev.ref) {
const k = admitKey(ev.ref)
if (!admitVotes.has(k)) admitVotes.set(k, new Map())
admitVotes.get(k).set(ev.author, ev.choice)
const yes = [...admitVotes.get(k).values()].filter(v => v === "yes").length
if (!members.has(ev.ref) && passesThreshold(yes, members.size, quorum, majority)) {
members.add(ev.ref); applicants.delete(ev.ref)
}
} else if (ev.subject === "dissolve") {
if (ev.choice === "yes") dissolveVoters.add(ev.author); else dissolveVoters.delete(ev.author)
if (passesThreshold(dissolveVoters.size, members.size, quorum, majority)) dissolved = true
}
continue
}
}
const pendingApplicants = [...applicants].map(uid => {
const k = admitKey(uid)
const vm = admitVotes.get(k) || new Map()
let yes = 0, no = 0
for (const v of vm.values()) { if (v === "yes") yes++; else no++ }
return { id: uid, yes, no, voters: [...vm.keys()] }
})
const opinions = {}
const opinionSet = new Set()
for (const k of groupKeys) {
const n = nodes.get(k); if (!n || n.author !== steward) continue
const c = n.c
if (c.opinions && typeof c.opinions === "object") { for (const kk of Object.keys(c.opinions)) opinions[kk] = Math.max(opinions[kk] || 0, Number(c.opinions[kk]) || 0) }
for (const v of (Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : [])) opinionSet.add(v)
}
for (const op of collab.opinion.filter(x => x.target === root)) { if (!opinionSet.has(op.author)) { opinionSet.add(op.author); opinions[op.category] = (opinions[op.category] || 0) + 1 } }
const pausePref = new Map()
for (const ev of events) {
if (ev.kind !== "vote" || ev.subject !== "pause") continue
if (!members.has(ev.author)) continue
pausePref.set(ev.author, ev.choice)
}
const pauseVoters = [...pausePref.entries()].filter(([, c]) => c === "yes").map(([m]) => m)
const paused = passesThreshold(pauseVoters.length, members.size, quorum, majority)
facility.steward = steward
facility.root = root
facility.membershipPolicy = policy
facility.quorum = quorum
facility.majority = majority
facility.members = [...members]
facility.memberCount = members.size
facility.pendingApplicants = pendingApplicants
facility.invites = [...invites]
facility.opinions = opinions
facility.opinions_inhabitants = [...opinionSet]
facility.pauseVoters = pauseVoters
facility.pauseYes = pauseVoters.length
facility.status = dissolved ? "DISSOLVED" : (paused ? "PAUSED" : "ACTIVE")
return { tip, root, best, facility, tombstoned: idx.tomb.has(tip) }
}
const BUILD_STATUSES = ["PROPOSED", "REJECTED", "APPROVED", "STOCKING", "IN_PRODUCTION", "COMPLETED", "FAILED"]
const BUILD_TRANSITIONS = { APPROVED: ["STOCKING", "FAILED"], STOCKING: ["IN_PRODUCTION", "FAILED"], IN_PRODUCTION: ["COMPLETED", "FAILED"], FAILED: ["APPROVED", "STOCKING", "IN_PRODUCTION"], COMPLETED: ["IN_PRODUCTION"] }
const CONTRIBUTABLE = new Set(["APPROVED", "STOCKING", "IN_PRODUCTION"])
const normalizeMaterials = (raw, text) => {
if (Array.isArray(raw)) return raw.map(m => ({ item: String((m && m.item) || "").trim(), qty: nonNegNum(m && m.qty), price: nonNegNum(m && m.price) })).filter(m => m.item)
const t = String(text || "")
if (!t.trim()) return []
return t.split("\n").map(line => { const parts = line.split(/[:,]/).map(x => String(x || "").trim()); return { item: parts[0] || "", qty: nonNegNum(parts[1]), price: nonNegNum(parts[2]) } }).filter(m => m.item)
}
const safeMaterials = (v) => (Array.isArray(v) ? v : [])
const estimateBlueprint = (bp, laborRate) => {
const materialsCost = safeMaterials(bp.materials).reduce((s, m) => s + nonNegNum(m.qty) * nonNegNum(m.price), 0)
const laborCost = nonNegNum(bp.laborHours) * nonNegNum(laborRate)
return {
estMaterialsCost: Number(materialsCost.toFixed(6)),
estLaborCost: Number(laborCost.toFixed(6)),
estTotal: Number((materialsCost + laborCost).toFixed(6))
}
}
const estimateForBlueprintRef = (idx, bpRef, laborRate) => {
if (!bpRef) return null
const b = resolveBlueprint(idx, rootOfBlueprint(idx, bpRef))
if (!b || b.tombstoned) return null
return { blueprintName: b.blueprint.name, blueprintImage: b.blueprint.image || null, blueprintKind: b.blueprint.outKind || null, blueprintLicense: b.blueprint.license || "copyleft", ...estimateBlueprint(b.blueprint, laborRate) }
}
const normalizeSkills = (raw) => {
if (Array.isArray(raw)) return raw.map(x => String(x || "").trim()).filter(Boolean)
return String(raw || "").split(",").map(x => x.trim()).filter(Boolean)
}
function computeShares(contributions, laborRate) {
const rate = nonNegNum(laborRate)
const points = new Map()
for (const c of contributions) {
let p = 0
if (c.kind === "labor") p = (c.hours || 0) * rate
else if (c.kind === "material") p = (c.value || 0)
else if (c.kind === "eco") p = (c.eco || 0)
if (p <= 0) continue
points.set(c.author, (points.get(c.author) || 0) + p)
}
let total = 0
for (const v of points.values()) total += v
const shares = new Map()
if (total > 0) for (const [k, v] of points) shares.set(k, v / total)
return { points, total, shares }
}
function resolveBlueprint(idx, root) {
const { blueprints, bpStrictNext } = idx
const followStrict = (key) => { let x = key, g = 0; while (bpStrictNext.has(x) && g++ < 100000) x = bpStrictNext.get(x); return x }
const rootNode = blueprints.get(root)
if (!rootNode) return null
const author = rootNode.author
let tip = followStrict(root)
const tn = blueprints.get(tip)
if (!tn || tn.author !== author) tip = root
const best = blueprints.get(tip)
if (!best) return null
return { tip, root, blueprint: { ...best.c, id: tip, author }, tombstoned: idx.tomb.has(tip) }
}
function resolveBuild(idx, root, facility) {
const node = idx.builds.get(root)
if (!node) return null
const build = { ...node.c, id: root, proposer: node.author }
const steward = facility.steward
const members = new Set(facility.members)
const approveVotes = new Map()
for (const v of idx.collab.vote.filter(x => x.subject === "build" && x.ref === root).sort((a, b) => a.ts - b.ts)) {
if (!members.has(v.author)) continue
approveVotes.set(v.author, v.choice)
}
const approveYes = [...approveVotes.values()].filter(x => x === "yes").length
const approveNo = [...approveVotes.values()].filter(x => x === "no").length
let status = "PROPOSED"
if (passesThreshold(approveYes, members.size, facility.quorum, facility.majority)) status = "APPROVED"
else if (passesThreshold(approveNo, members.size, facility.quorum, facility.majority)) status = "REJECTED"
for (const ev of idx.collab.buildStatus.filter(x => x.target === root).sort((a, b) => a.ts - b.ts)) {
const allowed = BUILD_TRANSITIONS[status] || []
if (!allowed.includes(ev.status)) continue
const isSteward = ev.author === steward
const isProposer = ev.author === build.proposer
const stewardOnly = ev.status === "COMPLETED" || ev.status === "FAILED"
if (stewardOnly && !isSteward) continue
if (!isSteward && !isProposer) continue
status = ev.status
}
for (const ev of idx.collab.buildDates.filter(x => x.target === root).sort((a, b) => a.ts - b.ts)) {
if (ev.author !== steward && ev.author !== build.proposer) continue
if (ev.title) build.title = ev.title
if (ev.notes) build.notes = ev.notes
if (ev.startDate) build.startDate = ev.startDate
if (ev.endDate) build.endDate = ev.endDate
}
build.status = status
const contributions = idx.collab.contribution.filter(x => x.target === root && members.has(x.author)).sort((a, b) => a.ts - b.ts)
build.contributions = contributions.map(c => ({ author: c.author, kind: c.kind, hours: c.hours, item: c.item, value: c.value, eco: c.eco, note: c.note, at: new Date(c.ts || 0).toISOString() }))
const cs = computeShares(contributions, facility.laborRate)
build.points = Object.fromEntries(cs.points)
build.totalPoints = cs.total
build.shares = Object.fromEntries(cs.shares)
build.treasury = contributions.reduce((s, c) => s + (c.eco || 0), 0)
const allocs = idx.collab.allocation.filter(x => x.target === root && x.author === steward).sort((a, b) => a.ts - b.ts)
const latest = allocs[allocs.length - 1] || null
build.distributed = !!latest
build.allocation = latest ? { shares: latest.shares, amounts: latest.amounts, pot: latest.pot, outputValue: latest.outputValue, disposition: latest.disposition, at: new Date(latest.ts || 0).toISOString() } : null
build.tombstoned = idx.tomb.has(root)
return build
}
async function getById(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Facility not found")
return g.facility
}
async function resolveTipId(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Facility not found")
return g.tip
}
const CONTENT_COLLAB_FIELDS = ["id", "root", "steward", "members", "memberCount", "pendingApplicants", "invites", "opinions", "opinions_inhabitants", "pauseVoters", "pauseYes"]
function stripCollab(content) {
const out = { ...content }
for (const f of CONTENT_COLLAB_FIELDS) delete out[f]
out.opinions = {}
out.opinions_inhabitants = []
return out
}
async function publishReplace(ssbClient, currentId, content) {
const tomb = { type: "tombstone", target: currentId, deletedAt: new Date().toISOString(), author: ssbClient.id }
const updated = { ...stripCollab(content), type: TYPE, replaces: currentId, updatedAt: new Date().toISOString() }
await new Promise((res, rej) => ssbClient.publish(tomb, (e) => (e ? rej(e) : res())))
return new Promise((res, rej) => ssbClient.publish(updated, (e, m) => (e ? rej(e) : res(m))))
}
const publish = (ssbClient, content) => new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
return {
type: TYPE,
SECTORS,
POLICIES,
async createFacility(data) {
const ssbClient = await openSsb()
const content = {
type: TYPE,
name: String(data.name || "").trim(),
sector: safeSector(data.sector),
description: String(data.description || "").trim(),
image: data.image || null,
mapUrl: String(data.mapUrl || "").trim(),
membershipPolicy: safePolicy(data.membershipPolicy),
quorum: clampQuorum(data.quorum),
majority: clampMajority(data.majority),
laborRate: nonNegNum(data.laborRate),
license: String(data.license || "copyleft").trim() || "copyleft",
tags: normalizeSkills(data.tags),
status: "ACTIVE",
author: ssbClient.id,
createdAt: new Date().toISOString(),
updatedAt: null,
opinions: {},
opinions_inhabitants: []
}
if (!content.name) throw new Error("Facility name is required")
return publish(ssbClient, content)
},
async updateFacility(id, patch) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (g.facility.steward !== ssbClient.id) throw new Error("Unauthorized")
const current = g.facility
const nextStatus = patch.status === undefined ? current.status : String(patch.status || "").toUpperCase()
if (nextStatus === "DISSOLVED" && current.status !== "DISSOLVED") throw new Error("Dissolution requires a governance vote")
const updated = {
...current,
...patch,
name: patch.name === undefined ? current.name : String(patch.name || "").trim(),
sector: patch.sector === undefined ? current.sector : safeSector(patch.sector),
description: patch.description === undefined ? current.description : String(patch.description || "").trim(),
image: patch.image === undefined ? current.image : (patch.image || null),
mapUrl: patch.mapUrl === undefined ? current.mapUrl : String(patch.mapUrl || "").trim(),
membershipPolicy: patch.membershipPolicy === undefined ? current.membershipPolicy : safePolicy(patch.membershipPolicy),
quorum: patch.quorum === undefined ? current.quorum : clampQuorum(patch.quorum),
majority: patch.majority === undefined ? current.majority : clampMajority(patch.majority),
laborRate: patch.laborRate === undefined ? current.laborRate : nonNegNum(patch.laborRate),
tags: patch.tags === undefined ? current.tags : normalizeSkills(patch.tags),
status: nextStatus
}
return publishReplace(ssbClient, current.id, updated)
},
async deleteFacility(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (g.facility.steward !== ssbClient.id) throw new Error("Unauthorized")
if (g.facility.memberCount > 1) throw new Error("A facility with members cannot be deleted; it must be dissolved by vote")
const tomb = { type: "tombstone", target: g.tip, deletedAt: new Date().toISOString(), author: ssbClient.id }
return publish(ssbClient, tomb)
},
async joinFacility(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (g.facility.status === "DISSOLVED") throw new Error("Facility is dissolved")
const uid = ssbClient.id
if (g.facility.members.includes(uid)) throw new Error("Already a member")
const policy = g.facility.membershipPolicy
if (policy === "vote") return publish(ssbClient, { type: "industryApply", target: g.root, createdAt: new Date().toISOString() })
if (policy === "invite" && !g.facility.invites.includes(uid)) throw new Error("You need an invitation to join")
return publish(ssbClient, { type: "industryMember", target: g.root, action: "join", createdAt: new Date().toISOString() })
},
async leaveFacility(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Facility not found")
const uid = ssbClient.id
if (uid === g.facility.steward) throw new Error("The steward cannot leave; dissolve the facility instead")
if (!g.facility.members.includes(uid)) throw new Error("Not a member")
return publish(ssbClient, { type: "industryMember", target: g.root, action: "leave", createdAt: new Date().toISOString() })
},
async inviteToFacility(id, invitee) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members can invite")
if (!/^@[A-Za-z0-9+/]+={0,2}\.ed25519$/.test(String(invitee || ""))) throw new Error("Invalid invitee id")
return publish(ssbClient, { type: "industryInvite", target: g.root, invitee: String(invitee), createdAt: new Date().toISOString() })
},
async voteGovernance(id, subject, ref, choice) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members can vote")
const subj = String(subject || "")
const SUBJECTS = ["admit", "dissolve", "pause", "bpUpdate", "bpDelete", "buildUpdate", "buildDelete"]
if (!SUBJECTS.includes(subj)) throw new Error("Invalid governance subject")
if (subj !== "admit" && subj !== "dissolve" && subj !== "pause" && !String(ref || "").trim()) throw new Error("Governance vote requires a target")
return publish(ssbClient, { type: "industryVote", target: g.root, subject: subj, ref: String(ref || ""), choice: choice === "no" ? "no" : "yes", createdAt: new Date().toISOString() })
},
async createOpinion(id, category) {
const categories = require('../backend/opinion_categories')
if (!categories.includes(category)) throw new Error('Invalid opinion category')
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error('Facility not found')
if ((g.facility.opinions_inhabitants || []).includes(ssbClient.id)) throw new Error('Already opined')
return publish(ssbClient, { type: 'industryOpinion', target: g.root, category, createdAt: new Date().toISOString() })
},
async getFacilityById(id) {
return getById(id)
},
async listFacilities(filter = "ALL") {
const ssbClient = await openSsb()
const uid = ssbClient.id
const idx = buildIndex(await getAllMsgs(ssbClient))
const roots = new Set()
for (const key of idx.nodes.keys()) roots.add(rootOfIdx(idx, key))
let list = []
for (const root of roots) {
const g = resolveGroup(idx, root)
if (!g || g.tombstoned) continue
list.push(g.facility)
}
const f = String(filter || "ALL").toUpperCase()
if (f === "MINE") list = list.filter(x => x.steward === uid)
else if (f === "MEMBER") list = list.filter(x => Array.isArray(x.members) && x.members.includes(uid))
else if (f === "ACTIVE") list = list.filter(x => x.status === "ACTIVE")
else if (f === "PAUSED") list = list.filter(x => x.status === "PAUSED")
else if (f === "DISSOLVED") list = list.filter(x => x.status === "DISSOLVED")
else if (SECTORS.includes(f.toLowerCase())) list = list.filter(x => x.sector === f.toLowerCase())
list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
return list
},
async facilitiesOf(feedId) {
const ssbClient = await openSsb()
const uid = feedId || ssbClient.id
const idx = buildIndex(await getAllMsgs(ssbClient))
const roots = new Set()
for (const key of idx.nodes.keys()) roots.add(rootOfIdx(idx, key))
const list = []
for (const root of roots) {
const g = resolveGroup(idx, root)
if (!g || g.tombstoned) continue
if (Array.isArray(g.facility.members) && g.facility.members.includes(uid)) list.push(g.facility)
}
list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
return list
},
async listMyBuilds() {
const ssbClient = await openSsb()
const uid = ssbClient.id
const idx = buildIndex(await getAllMsgs(ssbClient))
const facRoots = new Set()
for (const key of idx.nodes.keys()) facRoots.add(rootOfIdx(idx, key))
const myFacilities = new Map()
for (const root of facRoots) {
const g = resolveGroup(idx, root)
if (!g || g.tombstoned) continue
if (Array.isArray(g.facility.members) && g.facility.members.includes(uid)) myFacilities.set(g.root, g.facility)
}
const out = []
for (const [key, node] of idx.builds) {
if (idx.tomb.has(key)) continue
const fac = myFacilities.get(node.c.facility)
if (!fac) continue
const build = resolveBuild(idx, key, fac)
build.facilityId = fac.root
build.facilityName = fac.name
out.push(build)
}
out.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
return out
},
async createBlueprint(facilityId, data) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, facilityId))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members can add blueprints")
const content = {
type: "industryBlueprint",
facility: g.root,
name: String(data.name || "").trim(),
description: String(data.description || "").trim(),
image: data.image || null,
sector: safeSector(data.sector || g.facility.sector),
materials: normalizeMaterials(data.materials, data.materialsText),
laborHours: nonNegNum(data.laborHours),
skills: normalizeSkills(data.skills),
outItem: String(data.outItem || "").trim(),
outQty: nonNegNum(data.outQty),
outUnit: String(data.outUnit || "unit").trim() || "unit",
outKind: String(data.outKind || "physical").toLowerCase() === "digital" ? "digital" : "physical",
license: String(data.license || "copyleft").trim() || "copyleft",
createdAt: new Date().toISOString()
}
if (!content.name) throw new Error("Blueprint name is required")
return publish(ssbClient, content)
},
async updateBlueprint(id, patch) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const b = resolveBlueprint(idx, rootOfBlueprint(idx, id))
if (!b || b.tombstoned) throw new Error("Blueprint not found")
if (b.blueprint.author !== ssbClient.id) throw new Error("Unauthorized")
const gb = resolveGroup(idx, rootOfIdx(idx, b.blueprint.facility))
if (!gb || gb.tombstoned) throw new Error("Facility not found")
if (!gb.facility.members.includes(ssbClient.id)) throw new Error("Only members of the facility can do this")
if (!actionApproved(idx, gb, "bpUpdate", b.root)) throw new Error("Updating this blueprint requires a governance vote")
const cur = b.blueprint
const updated = {
type: "industryBlueprint",
facility: cur.facility,
name: patch.name === undefined ? cur.name : String(patch.name || "").trim(),
description: patch.description === undefined ? (cur.description || "") : String(patch.description || "").trim(),
image: patch.image === undefined ? (cur.image || null) : (patch.image || null),
sector: patch.sector === undefined ? cur.sector : safeSector(patch.sector),
materials: patch.materials === undefined && patch.materialsText === undefined ? cur.materials : normalizeMaterials(patch.materials, patch.materialsText),
laborHours: patch.laborHours === undefined ? cur.laborHours : nonNegNum(patch.laborHours),
skills: patch.skills === undefined ? cur.skills : normalizeSkills(patch.skills),
outItem: patch.outItem === undefined ? cur.outItem : String(patch.outItem || "").trim(),
outQty: patch.outQty === undefined ? cur.outQty : nonNegNum(patch.outQty),
outUnit: patch.outUnit === undefined ? cur.outUnit : String(patch.outUnit || "unit").trim() || "unit",
outKind: patch.outKind === undefined ? cur.outKind : (String(patch.outKind).toLowerCase() === "digital" ? "digital" : "physical"),
license: patch.license === undefined ? cur.license : String(patch.license || "copyleft").trim() || "copyleft",
replaces: b.tip,
createdAt: cur.createdAt,
updatedAt: new Date().toISOString()
}
const tomb = { type: "tombstone", target: b.tip, deletedAt: new Date().toISOString(), author: ssbClient.id }
await publish(ssbClient, tomb)
return publish(ssbClient, updated)
},
async deleteBlueprint(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const b = resolveBlueprint(idx, rootOfBlueprint(idx, id))
if (!b || b.tombstoned) throw new Error("Blueprint not found")
const g = resolveGroup(idx, rootOfIdx(idx, b.blueprint.facility))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members of the facility can do this")
if (b.blueprint.author !== ssbClient.id) throw new Error("Only the author can delete a blueprint")
if (!actionApproved(idx, g, "bpDelete", b.root)) throw new Error("Deleting this blueprint requires a governance vote")
const chain = []
{ let x = b.root, guard = 0; chain.push(x); while (idx.bpNext.has(x) && guard++ < 100000) { x = idx.bpNext.get(x); chain.push(x) } }
const chainSet = new Set(chain)
let usedBy = 0
for (const [key, node] of idx.builds) {
if (idx.tomb.has(key)) continue
if (chainSet.has(node.c.blueprint)) usedBy++
}
if (usedBy > 0) throw new Error(`This blueprint is used by ${usedBy} build${usedBy > 1 ? "s" : ""}; delete them first`)
let last = null
for (const key of chain) {
const node = idx.blueprints.get(key)
if (!node || node.author !== ssbClient.id) continue
if (idx.tomb.has(key)) continue
last = await publish(ssbClient, { type: "tombstone", target: key, deletedAt: new Date().toISOString(), author: ssbClient.id })
}
return last
},
async getBlueprint(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const b = resolveBlueprint(idx, rootOfBlueprint(idx, id))
if (!b || b.tombstoned) throw new Error("Blueprint not found")
const g = resolveGroup(idx, rootOfIdx(idx, b.blueprint.facility))
if (!g || g.tombstoned) throw new Error("Facility not found")
const builds = []
for (const [key, node] of idx.builds) {
if (idx.tomb.has(key)) continue
if (node.c.blueprint !== b.tip && node.c.blueprint !== b.root) continue
const rb = resolveBuild(idx, key, g.facility)
if (rb) builds.push({ id: key, title: rb.title, status: rb.status, startDate: rb.startDate, endDate: rb.endDate })
}
builds.sort((x, y) => new Date(y.startDate || 0) - new Date(x.startDate || 0))
return {
...b.blueprint,
facilityId: g.root,
facilityName: g.facility.name,
members: g.facility.members,
builds,
...estimateBlueprint(b.blueprint, g.facility.laborRate),
...blueprintGovernance(idx, g, b.root)
}
},
async listBlueprints(facilityId) {
const ssbClient = await openSsb()
const msgs = await getAllMsgs(ssbClient)
const idx = buildIndex(msgs)
const root = rootOfIdx(idx, facilityId)
const g = resolveGroup(idx, root)
const laborRate = g && !g.tombstoned ? g.facility.laborRate : 0
const roots = new Set()
for (const key of idx.blueprints.keys()) roots.add(rootOfBlueprint(idx, key))
const list = []
for (const r of roots) {
const b = resolveBlueprint(idx, r)
if (!b || b.tombstoned) continue
if (b.blueprint.facility !== root) continue
list.push({ ...b.blueprint, ...estimateBlueprint(b.blueprint, laborRate), ...(g && !g.tombstoned ? blueprintGovernance(idx, g, b.root) : {}) })
}
list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
return list
},
async createBuild(facilityId, data) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, facilityId))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (g.facility.status !== "ACTIVE") throw new Error("Facility is not active")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members can propose builds")
let blueprintId = String(data.blueprintId || "").trim() || null
if (!blueprintId) throw new Error("A build needs a blueprint")
{
const b = resolveBlueprint(idx, rootOfBlueprint(idx, blueprintId))
if (!b || b.tombstoned || b.blueprint.facility !== g.root) throw new Error("Blueprint not found in this facility")
blueprintId = b.tip
}
const content = { type: "industryBuild", facility: g.root, blueprint: blueprintId, title: String(data.title || "").trim(), notes: String(data.notes || "").trim(), image: data.image || null, startDate: String(data.startDate || "").trim() || null, endDate: String(data.endDate || "").trim() || null, createdAt: new Date().toISOString() }
if (!content.title) throw new Error("Build title is required")
const today = new Date().toISOString().slice(0, 10)
if (!content.startDate) throw new Error("Build start date is required")
if (!content.endDate) throw new Error("Build end date is required")
if (content.startDate < today) throw new Error("Start date cannot be in the past")
if (content.startDate && content.endDate && new Date(content.endDate) < new Date(content.startDate)) throw new Error("End date must be after start date")
return publish(ssbClient, content)
},
async voteBuild(buildId, choice) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const bnode = idx.builds.get(buildId)
if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found")
const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members can vote")
return publish(ssbClient, { type: "industryVote", target: g.root, subject: "build", ref: buildId, choice: choice === "no" ? "no" : "yes", createdAt: new Date().toISOString() })
},
async setBuildStatus(buildId, status) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const bnode = idx.builds.get(buildId)
if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found")
const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility))
if (!g || g.tombstoned) throw new Error("Facility not found")
const build = resolveBuild(idx, buildId, g.facility)
const next = String(status || "").toUpperCase()
if (!BUILD_STATUSES.includes(next)) throw new Error("Invalid status")
const allowed = BUILD_TRANSITIONS[build.status] || []
if (!allowed.includes(next)) throw new Error(`Cannot move build from ${build.status} to ${next}`)
if (g.facility.status !== "ACTIVE") throw new Error("Facility is not active")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members of the facility can do this")
const isSteward = g.facility.steward === ssbClient.id
const isProposer = build.proposer === ssbClient.id
const isReopen = build.status === "COMPLETED" || build.status === "FAILED"
if (build.status === "COMPLETED" && build.distributed) throw new Error("A distributed build cannot be reopened")
if ((next === "COMPLETED" || next === "FAILED" || isReopen) && !isSteward) throw new Error("Only the steward can complete, fail or reopen a build")
if (!isSteward && !isProposer) throw new Error("Only the steward or the proposer can advance the build")
return publish(ssbClient, { type: "industryBuildStatus", target: buildId, status: next, createdAt: new Date().toISOString() })
},
async contribute(buildId, data) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const bnode = idx.builds.get(buildId)
if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found")
const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members can contribute")
if (g.facility.status !== "ACTIVE") throw new Error("Facility is not active")
const build = resolveBuild(idx, buildId, g.facility)
if (!CONTRIBUTABLE.has(build.status)) throw new Error("This build is not accepting contributions")
const kind = ["labor", "material", "eco"].includes(String(data.kind)) ? String(data.kind) : "labor"
const content = { type: "industryContribution", target: buildId, kind, hours: nonNegNum(data.hours), item: String(data.item || "").trim(), value: nonNegNum(data.value), eco: nonNegNum(data.eco), transferId: data.transferId || null, note: String(data.note || "").trim(), createdAt: new Date().toISOString() }
if (kind === "labor") {
if (content.hours <= 0) throw new Error("Labor contributions require hours above zero")
content.item = ""; content.value = 0; content.eco = 0
} else if (kind === "material") {
if (!content.item) throw new Error("Material contributions require an item")
if (content.value <= 0) throw new Error("Material contributions require a value above zero")
content.hours = 0; content.eco = 0
} else {
if (content.eco <= 0) throw new Error("ECO contributions require an amount above zero")
content.hours = 0; content.item = ""; content.value = 0
}
return publish(ssbClient, content)
},
async getBuild(buildId) {
const ssbClient = await openSsb()
const msgs = await getAllMsgs(ssbClient)
const idx = buildIndex(msgs)
const bnode = idx.builds.get(buildId)
if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found")
const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility))
if (!g) throw new Error("Facility not found")
const build = resolveBuild(idx, buildId, g.facility)
build.facilityId = g.root
build.facilityName = g.facility.name
build.steward = g.facility.steward
build.members = g.facility.members
const est = estimateForBlueprintRef(idx, build.blueprint, g.facility.laborRate)
if (est) Object.assign(build, est)
Object.assign(build, buildGovernance(idx, g, buildId))
return build
},
async updateBuild(buildId, data) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const bnode = idx.builds.get(buildId)
if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found")
const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members of the facility can do this")
const build = resolveBuild(idx, buildId, g.facility)
const isSteward = g.facility.steward === ssbClient.id
if (build.proposer !== ssbClient.id && !isSteward) throw new Error("Only the steward or the proposer can update a build")
if (build.distributed) throw new Error("A distributed build cannot be updated")
if (!actionApproved(idx, g, "buildUpdate", buildId)) throw new Error("Updating this build requires a governance vote")
const startDate = String(data.startDate || "").trim()
const endDate = String(data.endDate || "").trim()
if (!startDate) throw new Error("Build start date is required")
if (!endDate) throw new Error("Build end date is required")
if (new Date(endDate) < new Date(startDate)) throw new Error("End date must be after start date")
const today = new Date().toISOString().slice(0, 10)
if (endDate < today) throw new Error("End date cannot be in the past")
const title = data.title === undefined ? String(build.title || "") : String(data.title || "").trim()
if (!title) throw new Error("Build title is required")
const notes = data.notes === undefined ? String(build.notes || "") : String(data.notes || "").trim()
return publish(ssbClient, { type: "industryBuildUpdate", target: buildId, title, notes, startDate, endDate, createdAt: new Date().toISOString() })
},
async deleteBuild(buildId) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const bnode = idx.builds.get(buildId)
if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found")
const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members of the facility can do this")
if (bnode.author !== ssbClient.id) throw new Error("Only the proposer can delete a build")
if (!actionApproved(idx, g, "buildDelete", buildId)) throw new Error("Deleting this build requires a governance vote")
return publish(ssbClient, { type: "tombstone", target: buildId, deletedAt: new Date().toISOString(), author: ssbClient.id })
},
async listBuilds(facilityId, filter = "ALL") {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, facilityId))
if (!g || g.tombstoned) throw new Error("Facility not found")
let list = []
for (const [key, node] of idx.builds) {
if (idx.tomb.has(key)) continue
if (node.c.facility !== g.root) continue
const b = resolveBuild(idx, key, g.facility)
if (!b) continue
const est = estimateForBlueprintRef(idx, b.blueprint, g.facility.laborRate)
list.push({ ...b, ...(est || {}), ...buildGovernance(idx, g, key) })
}
const f = String(filter || "ALL").toUpperCase()
if (BUILD_STATUSES.includes(f)) list = list.filter(x => x.status === f)
list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
return list
},
async listAllBlueprints() {
const ssbClient = await openSsb()
const msgs = await getAllMsgs(ssbClient)
const idx = buildIndex(msgs)
const roots = new Set()
for (const key of idx.blueprints.keys()) roots.add(rootOfBlueprint(idx, key))
const facCache = new Map()
const list = []
for (const root of roots) {
const b = resolveBlueprint(idx, root)
if (!b || b.tombstoned) continue
const facRoot = rootOfIdx(idx, b.blueprint.facility)
let g = facCache.get(facRoot)
if (g === undefined) { g = resolveGroup(idx, facRoot); facCache.set(facRoot, g) }
if (!g || g.tombstoned) continue
list.push({ ...b.blueprint, facilityId: g.root, facilityName: g.facility.name, members: g.facility.members, ...estimateBlueprint(b.blueprint, g.facility.laborRate), ...blueprintGovernance(idx, g, b.root) })
}
list.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0))
return list
},
async listAllBuilds() {
const ssbClient = await openSsb()
const msgs = await getAllMsgs(ssbClient)
const idx = buildIndex(msgs)
const facCache = new Map()
const list = []
for (const [key, node] of idx.builds) {
if (idx.tomb.has(key)) continue
const facRoot = rootOfIdx(idx, node.c.facility)
let g = facCache.get(facRoot)
if (g === undefined) { g = resolveGroup(idx, facRoot); facCache.set(facRoot, g) }
if (!g || g.tombstoned) continue
const b = resolveBuild(idx, key, g.facility)
if (!b) continue
const est = estimateForBlueprintRef(idx, b.blueprint, g.facility.laborRate)
list.push({ ...b, facilityId: g.root, facilityName: g.facility.name, members: g.facility.members, ...(est || {}), ...buildGovernance(idx, g, key) })
}
list.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0))
return list
},
computeDistributionPlan(build, outputValue) {
const pot = nonNegNum(build.treasury) + nonNegNum(outputValue)
const distributable = pot
const allocations = []
const amounts = {}
for (const [uid, share] of Object.entries(build.shares || {})) {
const amount = distributable * share
amounts[uid] = amount
if (amount > 0) allocations.push({ to: uid, amount, share })
}
return { pot, distributable, allocations, amounts }
},
async distributeBuild(buildId, data) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const bnode = idx.builds.get(buildId)
if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found")
const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (g.facility.steward !== ssbClient.id) throw new Error("Only the steward can distribute")
const build = resolveBuild(idx, buildId, g.facility)
if (build.status !== "COMPLETED") throw new Error("Only a completed build can be distributed")
if (build.distributed) throw new Error("This build has already been distributed")
if (build.totalPoints <= 0) throw new Error("There are no contributions to distribute")
const plan = this.computeDistributionPlan(build, data && data.outputValue)
const content = { type: "industryAllocation", target: buildId, shares: build.shares, amounts: plan.amounts, pot: plan.pot, outputValue: nonNegNum(data && data.outputValue), disposition: (data && data.disposition) || null, createdAt: new Date().toISOString() }
await publish(ssbClient, content)
return { plan, facility: g.facility, build }
}
}
}

View file

@ -407,7 +407,7 @@ module.exports = ({ cooler }) => {
const isOwner = viewer === target;
const arr = (v) => Array.isArray(v) ? v : [];
const up = (v) => String(v || '').toUpperCase();
const COUNTED = new Set(['post','event','task','forum','tribe','market','job','project','shop','image','video','audio','document','bookmark','transfer','map']);
const COUNTED = new Set(['post','event','task','forum','tribe','market','job','project','industry','shop','image','video','audio','document','bookmark','transfer','map']);
const accessible = (type, c) => {
if (c.encryptedPayload) return false;
switch (type) {

View file

@ -177,6 +177,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
status: c.status || "OPEN",
tags: Array.isArray(c.tags) ? c.tags : normalizeTags(c.tags),
subscribers: Array.isArray(visibleSubs) ? visibleSubs : [],
industry: c.industry || "",
mapUrl: c.mapUrl || "",
visibility: String(c.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC",
clearnetPublic: !!c.clearnetPublic
@ -235,6 +236,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
status: "OPEN",
industry: String(jobData.industry || "").trim(),
mapUrl: String(jobData.mapUrl || "").trim(),
visibility: String(jobData.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC",
clearnetPublic: jobData.clearnetPublic === true || jobData.clearnetPublic === 'true' || jobData.clearnetPublic === 'on'

View file

@ -58,11 +58,9 @@ module.exports = {
const encrypted = encryptBuffer(original, pw);
const roundtrip = decryptBuffer(encrypted, pw);
if (!roundtrip.equals(original)) {
throw new Error('Backup verification failed; nothing was written.');
throw new Error('Backup verification failed; nothing was exported.');
}
const encryptedFilePath = path.join(homeDir, 'oasis.enc');
fs.writeFileSync(encryptedFilePath, encrypted, { mode: 0o600 });
return encryptedFilePath;
return { filename: 'oasis.enc', data: encrypted };
} catch (error) {
throw new Error("Error exporting data: " + error.message);
}

View file

@ -23,7 +23,7 @@ const ACTION_TYPES = new Set([
'post', 'about', 'contact', 'feed', 'bookmark', 'image', 'audio', 'video',
'document', 'torrent', 'event', 'task', 'taskAssignment',
'votes', 'vote', 'report', 'tribe', 'chat', 'chatMessage', 'pad', 'padEntry',
'forum', 'market', 'job', 'project', 'pixelia', 'map', 'mapMarker',
'forum', 'market', 'job', 'project', 'industry', 'industryBuild', 'pixelia', 'map', 'mapMarker',
'shop', 'shopProduct', 'curriculum', 'gameScore',
'calendar', 'calendarDate', 'calendarNote',
'transfer', 'bankClaim', 'ubiClaim',
@ -61,6 +61,8 @@ const ACTION_PHRASES = {
forum: 'posted in the forum',
job: 'posted a job opportunity',
project: 'advanced a project',
industry: 'founded a network-owned facility',
industryBuild: 'proposed a production build',
pixelia: 'placed a pixel in pixelia',
map: 'contributed to a map',
mapMarker: 'placed a marker on a map',
@ -291,7 +293,10 @@ module.exports = ({ cooler }) => {
const c = v?.content;
if (!c) continue;
if (v.author !== userId) continue;
if (c.type === 'tombstone') continue;
if (c.type === 'tombstone') {
if (typeof c.target === 'string' && c.target) tombstoned.add(c.target);
continue;
}
if (c.type !== 'log') continue;
if (c.replaces) replaced.set(c.replaces, dec.key || keyIn);
items.push({

View file

@ -412,17 +412,11 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
try {
const ssbClient = await openSsb();
const messages = await new Promise((resolve, reject) => pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((e, m) => e ? reject(e) : resolve(m))));
const live = new Set();
const tomb = buildValidatedTombstoneSet(messages);
for (const m of messages) {
const c = m.value && m.value.content;
if (!c) continue;
if (c.type === "map") live.add(m.key);
}
const all = ownCrypto.getAllRootIds();
let removed = 0;
for (const rid of all) {
if (!live.has(rid) || tomb.has(rid)) {
if (tomb.has(rid)) {
try { ownCrypto.dropKey(rid); removed += 1; } catch (_) {}
}
}

View file

@ -180,6 +180,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
shopProductId: shopOpts.shopProductId || "",
shopId: shopOpts.shopId || "",
shopTitle: shopOpts.shopTitle || "",
industry: shopOpts.industry || "",
visibility: String(visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
}
@ -344,7 +345,8 @@ module.exports = ({ cooler, tribeCrypto }) => {
mapUrl: c.mapUrl || "",
shopProductId: c.shopProductId || "",
shopId: c.shopId || "",
shopTitle: c.shopTitle || ""
shopTitle: c.shopTitle || "",
industry: c.industry || ""
})
}
@ -445,7 +447,8 @@ module.exports = ({ cooler, tribeCrypto }) => {
mapUrl: c.mapUrl || "",
shopProductId: c.shopProductId || "",
shopId: c.shopId || "",
shopTitle: c.shopTitle || ""
shopTitle: c.shopTitle || "",
industry: c.industry || ""
}
},

View file

@ -0,0 +1,181 @@
const fs = require('fs');
const path = require('path');
const pull = require('../server/node_modules/pull-stream');
const STEPS = ['language', 'profile', 'federation', 'larp', 'greeting', 'backup'];
const FLAG = 'oasis-first-contact';
const PENDING = 'welcome=pending';
const DISMISSED = 'welcome=dismissed';
const DONE = 'welcome=done';
const ssbPathOf = (given) => {
if (given) return given;
try { return require('../server/ssb_config').path; } catch (_) { return null; }
};
const flagPath = (dir) => path.join(dir, FLAG);
function readFlag(dir) {
if (!dir) return { exists: false, id: '', markers: new Set() };
let raw = '';
try { raw = fs.readFileSync(flagPath(dir), 'utf8'); } catch (_) { return { exists: false, id: '', markers: new Set() }; }
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
const isMarker = (l) => /^(welcome|step)=/.test(l);
const markers = new Set(lines.filter(isMarker));
return { exists: true, id: lines.find(l => !isMarker(l)) || '', markers };
}
function appendMarker(dir, marker, createIfMissing) {
if (!dir) return false;
const flag = readFlag(dir);
if (flag.markers.has(marker)) return true;
if (!flag.exists && !createIfMissing) return false;
try {
fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(flagPath(dir), `${marker}\n`);
return true;
} catch (_) {
return false;
}
}
const isPending = (dir) => {
if (!dir) return false;
const flag = readFlag(dir);
if (!flag.exists) return true;
if (flag.markers.has(DISMISSED) || flag.markers.has(DONE)) return false;
return flag.markers.has(PENDING);
};
const bannerVisible = (ssbPath) => isPending(ssbPathOf(ssbPath));
module.exports = ({ cooler, ssbPath } = {}) => {
const dir = () => ssbPathOf(ssbPath);
const ownMessages = async () => {
const ssb = await cooler.open();
const mine = ssb.id;
const all = await new Promise((resolve, reject) =>
pull(ssb.createLogStream({ reverse: true, limit: 2000 }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs || [])))
);
return { mine, messages: all.filter(m => m && m.value && m.value.author === mine) };
};
const hasProfile = (mine, messages) => messages.some(m => {
const c = m.value.content;
if (!c || typeof c !== 'object' || c.type !== 'about' || c.about !== mine) return false;
return String(c.name || '').trim().length > 0 || String(c.image || '').trim().length > 0;
});
const hasGreeted = (messages) => messages.some(m => {
const c = m.value.content;
return c && typeof c === 'object' && c.type === 'feed' && String(c.text || '').trim().length > 0;
});
const hasJoinedLarp = (messages) => {
for (const m of messages) {
const c = m.value.content;
if (!c || typeof c !== 'object') continue;
if (c.type === 'larpJoinHouse' && c.house) return true;
if (c.type === 'larpLeaveLarp') return false;
}
return false;
};
const hasFederated = (mine, messages) => {
try {
const gossip = JSON.parse(fs.readFileSync(path.join(dir(), 'gossip.json'), 'utf8'));
if (Array.isArray(gossip) && gossip.length > 0) return true;
} catch (_) {}
return messages.some(m => {
const c = m.value.content;
return c && typeof c === 'object' && c.type === 'contact' && c.following === true && c.contact && c.contact !== mine;
});
};
return {
STEPS,
flagPath() {
return flagPath(dir());
},
firstContactSeen(feedId) {
const flag = readFlag(dir());
return flag.exists && (!feedId || flag.id === String(feedId));
},
begin(feedId) {
const target = dir();
if (!target) return false;
const flag = readFlag(target);
const closed = flag.markers.has(DISMISSED) || flag.markers.has(DONE);
const markers = closed ? [...flag.markers] : [...new Set([PENDING, ...flag.markers])];
try {
fs.mkdirSync(target, { recursive: true });
fs.writeFileSync(flagPath(target), `${String(feedId || '')}\n${new Date().toISOString()}\n${markers.join('\n')}\n`);
return true;
} catch (_) {
return false;
}
},
markStep(step) {
if (!STEPS.includes(step)) return false;
if (step !== 'language' && step !== 'backup') return true;
if (!isPending(dir())) return true;
return appendMarker(dir(), `step=${step}`);
},
dismiss() {
return appendMarker(dir(), DISMISSED, true);
},
isVisible() {
return isPending(dir());
},
async status(available) {
const target = dir();
const flag = readFlag(target);
const usable = STEPS.filter(s => !available || available[s] !== false);
let mine = '';
let messages = [];
try { ({ mine, messages } = await ownMessages()); } catch (_) { mine = ''; messages = []; }
const steps = {
language: flag.markers.has('step=language'),
profile: hasProfile(mine, messages),
federation: hasFederated(mine, messages),
larp: hasJoinedLarp(messages),
backup: flag.markers.has('step=backup'),
greeting: hasGreeted(messages)
};
const profile = { id: mine, name: '', description: '', image: '' };
for (const m of messages) {
const c = m.value.content;
if (!c || typeof c !== 'object' || c.type !== 'about' || c.about !== mine) continue;
if (!profile.name && String(c.name || '').trim()) profile.name = String(c.name).trim();
if (!profile.description && String(c.description || '').trim()) profile.description = String(c.description).trim();
if (!profile.image && String(c.image || '').trim()) profile.image = String(c.image).trim();
if (profile.name && profile.description && profile.image) break;
}
const done = usable.filter(s => steps[s]).length;
const complete = done === usable.length;
if (complete && isPending(target)) appendMarker(target, DONE);
return {
id: flag.id,
pending: isPending(target),
dismissed: flag.markers.has(DISMISSED),
steps,
profile,
usable,
done,
total: usable.length,
complete
};
}
};
};
module.exports.bannerVisible = bannerVisible;
module.exports.STEPS = STEPS;

View file

@ -22,12 +22,13 @@ module.exports = ({ cooler }) => {
const validTypes = [
'bookmark', 'votes', 'transfer',
'feed', 'image', 'audio', 'video', 'document', 'torrent'
'feed', 'image', 'audio', 'video', 'document', 'torrent',
'industry', 'project', 'report', 'task', 'event', 'shopProduct'
];
const getPreview = c => {
if (c.type === 'bookmark' && c.bookmark) return `🔖 ${c.bookmark}`;
return c.text || c.description || c.title || '';
return c.text || c.description || c.title || c.name || '';
};
const OPINION_MSG_TYPE = {
@ -39,7 +40,13 @@ module.exports = ({ cooler }) => {
audio: 'audioOpinion',
video: 'videoOpinion',
document: 'documentOpinion',
torrent: 'torrentOpinion'
torrent: 'torrentOpinion',
industry: 'industryOpinion',
project: 'projectOpinion',
report: 'reportOpinion',
task: 'taskOpinion',
event: 'eventOpinion',
shopProduct: 'shopOpinion'
};
const createVote = async (contentId, category) => {
@ -72,6 +79,7 @@ module.exports = ({ cooler }) => {
const tombstoned = buildValidatedTombstoneSet(messages);
const replaces = new Map();
const byId = new Map();
const authorOf = new Map();
const opinionMsgs = [];
for (const msg of messages) {
@ -82,6 +90,7 @@ module.exports = ({ cooler }) => {
if (tombstoned.has(c.target)) byId.delete(c.target);
continue;
}
if (key) authorOf.set(key, msg.value.author);
if (typeof c.type === 'string' && c.type.endsWith('Opinion') && c.target) {
opinionMsgs.push({ target: c.target, author: msg.value.author, category: c.category });
continue;
@ -104,10 +113,10 @@ module.exports = ({ cooler }) => {
}
for (const [oldId, newId] of Array.from(replaces.entries())) {
const oldM = byId.get(oldId);
const oldAuthor = authorOf.get(oldId);
const newM = byId.get(newId);
if (!oldM) { replaces.delete(oldId); continue; }
if (!newM || String(newM.value.author) !== String(oldM.value.author)) {
if (oldAuthor === undefined) { replaces.delete(oldId); continue; }
if (!newM || String(newM.value.author) !== String(oldAuthor)) {
replaces.delete(oldId);
byId.delete(newId);
}
@ -226,6 +235,11 @@ module.exports = ({ cooler }) => {
}
filtered = Array.from(bySig.values());
filtered = filtered.filter(m => {
const ops = m.value?.content?.opinions || {};
return Object.values(ops).reduce((acc, x) => acc + (Number(x) || 0), 0) > 0;
});
if (filter === 'MINE') {
filtered = filtered.filter(m => m.value.author === userId);
} else if (filter === 'RECENT') {

View file

@ -615,17 +615,11 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
try {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const live = new Set()
const tomb = buildValidatedTombstoneSet(messages)
for (const m of messages) {
const c = m.value && m.value.content
if (!c) continue
if (c.type === "pad") live.add(m.key)
}
const all = ownCrypto.getAllRootIds()
let removed = 0
for (const rid of all) {
if (!live.has(rid) || tomb.has(rid)) {
if (tomb.has(rid)) {
try { ownCrypto.dropKey(rid); removed += 1 } catch (_) {}
}
}

View file

@ -474,7 +474,12 @@ if (c.type === type) {
const pol = await summarizePoliciesForTerm({ ...term });
const eff = pol.proposed > 0 ? Math.round((pol.approved / pol.proposed) * 100) : 0;
const cands = await listByType('parliamentCandidature');
const presented = cands.length;
const cycleStartMs = term.startAt ? new Date(term.startAt).getTime() : 0;
const cycleEndMs = term.endAt ? new Date(term.endAt).getTime() : Number.MAX_SAFE_INTEGER;
const presented = cands.filter(c => {
const t = c.createdAt ? new Date(c.createdAt).getTime() : 0;
return t >= cycleStartMs && t <= cycleEndMs;
}).length;
return {
method,
powerType: term.powerType,
@ -1210,6 +1215,50 @@ if (c.type === type) {
return await computeGovernmentCard({ ...term, id: term.id || term.startAt });
}
async function getLatestTerm() {
return await getLatestTermAny();
}
async function getLatestGovernmentCard() {
const term = await getLatestTermAny();
if (!term) return null;
const card = await computeGovernmentCard({ ...term, id: term.id || term.startAt });
const now = moment();
let start = moment(term.startAt);
let end = term.endAt ? moment(term.endAt) : start.clone().add(TERM_DAYS, 'days');
let rolled = false;
while (start.isValid() && end.isValid() && end.isSameOrBefore(now)) {
start = end.clone();
end = end.clone().add(TERM_DAYS, 'days');
rolled = true;
}
if (rolled) {
return {
...card,
id: term.id || term.startAt,
since: start.toISOString(),
end: end.toISOString(),
method: 'ANARCHY',
powerType: 'none',
powerId: null,
powerTitle: 'ANARCHY',
winnerVotes: 0,
totalVotes: 0,
votesReceived: 0,
presented: 0,
proposed: 0,
approved: 0,
declined: 0,
discarded: 0,
revocated: 0,
efficiency: 0,
expired: false,
rolled: true
};
}
return { ...card, id: term.id || term.startAt, since: term.startAt, end: term.endAt, expired: false, rolled: false };
}
async function listLaws() {
const items = await listByType('parliamentLaw');
return items.sort((a, b) => new Date(b.enactedAt) - new Date(a.enactedAt));
@ -1464,6 +1513,8 @@ if (c.type === type) {
listCandidatures,
listTerms,
getCurrentTerm,
getLatestTerm,
getLatestGovernmentCard,
listLeaders,
sweepProposals,
getActorMeta,

View file

@ -28,7 +28,7 @@ module.exports = ({ cooler }) => {
return {
type: 'post',
async sendMessage(recipients = [], subject = '', text = '', crypter = false) {
async sendMessage(recipients = [], subject = '', text = '', crypter = false, ref = '') {
const ssbClient = await openSsb();
const recps = uniqueRecps([userId, ...recipients]);
const content = {
@ -39,6 +39,39 @@ module.exports = ({ cooler }) => {
text,
sentAt: new Date().toISOString(),
private: true,
...(ref ? { ref: String(ref) } : {}),
...(crypter ? { crypter: true } : {})
};
const publishAsync = util.promisify(ssbClient.private.publish);
return publishAsync(content, recps);
},
async sentRefs() {
const out = new Set();
let messages = [];
try { messages = await this.listAllPrivate(); } catch (_) { return out; }
for (const m of (messages || [])) {
const c = m && m.value && m.value.content;
if (!c || !c.ref) continue;
if (m.value.author !== userId) continue;
out.add(`${String(c.subject || '')}|${String(c.ref)}`);
}
return out;
},
async sendFileShare(recipients = [], subject = '', fileShare = null, crypter = false) {
const ssbClient = await openSsb();
const recps = uniqueRecps([userId, ...recipients]);
if (!fileShare || typeof fileShare !== 'object') throw new Error('Invalid file share');
const content = {
type: 'post',
from: userId,
to: recps,
subject,
text: '',
sentAt: new Date().toISOString(),
private: true,
fileShare,
...(crypter ? { crypter: true } : {})
};
const publishAsync = util.promisify(ssbClient.private.publish);

View file

@ -40,7 +40,7 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
'post', 'about', 'curriculum', 'tribe', 'transfer', 'feed',
'votes', 'report', 'task', 'event', 'bookmark', 'document',
'image', 'audio', 'video', 'torrent', 'market', 'bankWallet', 'bankClaim',
'project', 'job', 'forum', 'vote', 'contact', 'pub', 'map', 'shop', 'shopProduct', 'chat', 'pad'
'project', 'job', 'industry', 'industryBlueprint', 'forum', 'vote', 'contact', 'pub', 'map', 'shop', 'shopProduct', 'chat', 'pad'
];
const getRelevantFields = (type, content) => {
@ -87,6 +87,10 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
return [content?.title, content?.status, content?.progress, content?.goal, content?.pledged, content?.deadline, (content?.followers || []).length, (content?.backers || []).length, (content?.milestones || []).length, content?.bounty, content?.bountyAmount, content?.bounty_currency, content?.activity?.kind, content?.activityActor];
case 'job':
return [content?.title, content?.job_type, ...(content?.tasks || []), content?.location, content?.vacants, content?.salary, content?.status, (content?.subscribers || []).length];
case 'industry':
return [content?.name, content?.sector, content?.description, content?.membershipPolicy, content?.status, content?.license, ...(content?.tags || [])];
case 'industryBlueprint':
return [content?.name, content?.description, content?.sector, content?.outItem, content?.outKind, content?.license, ...(content?.skills || []), ...((content?.materials || []).map(m => m && m.item))];
case 'forum':
return [content?.root, content?.category, content?.title, content?.text, content?.key];
case 'vote':
@ -239,6 +243,14 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
].join('|');
}
if (t === 'industry') {
return ['industry', author, norm(c.name), norm(c.sector)].join('|');
}
if (t === 'industryBlueprint') {
return ['industryBlueprint', author, norm(c.facility), norm(c.name)].join('|');
}
if (t === 'forum') {
return `forum:${c.key || c.root || `${author}|${norm(c.title)}` || msg.key}`;
}

View file

@ -58,7 +58,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
};
const types = [
'bookmark','event','task','votes','report','feed','project',
'bookmark','event','task','votes','report','feed','project','industry','industryBlueprint',
'image','torrent','audio','video','document','transfer','post','tribe',
'market','forum','job','aiExchange','map','shop','shopProduct','chat','chatMessage',
'pad','padEntry','gameScore','calendar','calendarDate','calendarNote','log',
@ -281,6 +281,14 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
else if (t === 'chat') score += 1;
else if (t === 'gamescore') score += 2;
else if (t === 'pixelia') score += 2;
else if (rawType === 'industry') score += 8;
else if (rawType === 'industryblueprint') score += 6;
else if (rawType === 'industrybuild') score += 8;
else if (rawType === 'industrycontribution') score += 4;
else if (rawType === 'industryallocation') score += 10;
else if (rawType === 'industryvote') score += 2;
else if (rawType === 'industrymember') score += 2;
else if (rawType === 'industryopinion') score += 1;
}
return Math.max(0, Math.round(score));
};

View file

@ -78,6 +78,10 @@ module.exports = ({ cooler, padsModel, tribesModel }) => {
return ['job', author, norm(c.title), norm(c.location), norm(c.salary), norm(c.job_type)].join('|');
}
if (t === 'industry') {
return ['industry', author, norm(c.name), norm(c.sector)].join('|');
}
if (t === 'forum') {
return `forum:${c.key || c.root || `${author}|${norm(c.title)}` || msg.key}`;
}

View file

@ -20,7 +20,8 @@ module.exports = ({ cooler }) => {
const types = [
'bookmark', 'votes', 'feed',
'image', 'audio', 'video', 'document', 'torrent', 'transfer'
'image', 'audio', 'video', 'document', 'torrent', 'transfer',
'industry', 'project', 'report', 'task', 'event', 'shopProduct'
];
const categories = opinionCategories;
@ -39,6 +40,8 @@ module.exports = ({ cooler }) => {
const tombstoned = buildValidatedTombstoneSet(messages);
const replaces = new Map();
const itemsById = new Map();
const authorOf = new Map();
const opinionMsgs = [];
for (const m of messages) {
const k = m.key;
@ -51,6 +54,13 @@ module.exports = ({ cooler }) => {
continue;
}
if (k) authorOf.set(k, m.value?.author);
if (typeof c.type === 'string' && c.type.endsWith('Opinion') && c.target) {
opinionMsgs.push({ target: c.target, author: m.value?.author, category: c.category });
continue;
}
if (c.opinions && !tombstoned.has(k) && !['task', 'event', 'report'].includes(c.type)) {
if (c.replaces) replaces.set(c.replaces, k);
itemsById.set(k, m);
@ -62,17 +72,47 @@ module.exports = ({ cooler }) => {
}
}
for (const [replacedId, newId] of replaces.entries()) {
const oldMsg = itemsById.get(replacedId);
for (const [replacedId, newId] of Array.from(replaces.entries())) {
const oldAuthor = authorOf.get(replacedId);
const newMsg = itemsById.get(newId);
if (!oldMsg) continue;
if (!newMsg || String(newMsg.value?.author) !== String(oldMsg.value?.author)) {
if (oldAuthor === undefined) { replaces.delete(replacedId); continue; }
if (!newMsg || String(newMsg.value?.author) !== String(oldAuthor)) {
replaces.delete(replacedId);
itemsById.delete(newId);
continue;
}
itemsById.delete(replacedId);
}
const tipToChain = new Map();
for (const [oldId, newId] of replaces.entries()) {
let tip = newId; let g = 0;
while (replaces.has(tip) && g++ < 100000) tip = replaces.get(tip);
if (!tipToChain.has(tip)) tipToChain.set(tip, new Set([tip]));
tipToChain.get(tip).add(oldId); tipToChain.get(tip).add(newId);
}
const idToTip = new Map();
for (const [tip, chain] of tipToChain.entries()) for (const id of chain) idToTip.set(id, tip);
const opinionByTip = new Map();
for (const op of opinionMsgs) {
const tip = idToTip.get(op.target) || op.target;
if (!opinionByTip.has(tip)) opinionByTip.set(tip, { opinions: {}, voters: new Set() });
const agg = opinionByTip.get(tip);
if (agg.voters.has(op.author)) continue;
agg.voters.add(op.author);
if (op.category) agg.opinions[op.category] = (agg.opinions[op.category] || 0) + 1;
}
for (const [k, m] of Array.from(itemsById.entries())) {
const agg = opinionByTip.get(k);
if (!agg) continue;
const c = m.value?.content || {};
const mergedVoters = new Set([...(Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : [])]);
const mergedOpinions = { ...(c.opinions || {}) };
for (const [cat, n] of Object.entries(agg.opinions)) mergedOpinions[cat] = (mergedOpinions[cat] || 0) + n;
for (const v of agg.voters) mergedVoters.add(v);
itemsById.set(k, { ...m, value: { ...m.value, content: { ...c, opinions: mergedOpinions, opinions_inhabitants: [...mergedVoters] } } });
}
let rawItems = Array.from(itemsById.values()).filter(m => types.includes(m.value?.content?.type));
const blobTypes = ['document', 'image', 'audio', 'video'];
@ -148,9 +188,7 @@ module.exports = ({ cooler }) => {
items = items.filter(m => m.value.content.type === filter);
}
if (filter !== 'ALL' && !types.includes(filter)) {
items = items.filter(m => (m.value.content.opinions_inhabitants || []).length > 0);
}
items = items.filter(m => (m.value.content.opinions_inhabitants || []).length > 0);
if (filter === 'TOP') {
items.sort((a, b) => {

View file

@ -929,7 +929,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
const all = tribeCrypto.getAllRootIds();
let removed = 0;
for (const rid of all) {
if (!idx.tribes.has(rid) || idx.effectivelyTombstoned.has(rid)) {
if (idx.effectivelyTombstoned.has(rid)) {
try { tribeCrypto.dropKey(rid); removed++; } catch (_) {}
}
}