Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
93 commits
Select commit Hold shift + click to select a range
aed2ea3
Merge remote-tracking branch 'origin/main' into feat/online-db
lukyrys Mar 10, 2026
3e21d95
Merge remote-tracking branch 'origin/main' into feat/online-db
lukyrys Mar 14, 2026
8be0dc8
feat: add HLC implementation with tests
lukyrys Mar 14, 2026
2ff6c72
feat: add catalog SQLite schema with LWW upsert, FTS5, and tests
lukyrys Mar 14, 2026
c2453f7
feat: add Ed25519 catalog signer with tests
lukyrys Mar 14, 2026
81eba83
feat: add 5-step validation chain for catalog operations
lukyrys Mar 15, 2026
c0bc94d
fix: resolve TypeScript strict mode issues in catalog
lukyrys Mar 15, 2026
f9a3dcb
test: add E2E integration tests for catalog lifecycle, search, LWW, s…
lukyrys Mar 15, 2026
f5efb89
test: add stress and edge-case tests, fix remove to delete entry from…
lukyrys Mar 15, 2026
968431a
test: add CBOR signed_op blob round-trip and forwarding tests
lukyrys Mar 15, 2026
88888a2
test: add CRDT convergence tests — multi-peer, LWW, tombstone, delta …
lukyrys Mar 15, 2026
ca42dea
fix: clean up unused imports in test files
lukyrys Mar 15, 2026
c18e285
feat: add CatalogManager with integration tests
lukyrys Mar 15, 2026
d3c2e42
feat: add API handlers and E2E WebSocket tests for catalog
lukyrys Mar 15, 2026
53ffda7
test: add complete E2E test suite — 47 tests covering full catalog li…
lukyrys Mar 15, 2026
6df8e19
feat: register catalog API handlers in APIServer, add CatalogAPI to s…
lukyrys Mar 15, 2026
ee30531
feat: rewrite Products page to API-driven catalog, add frontend catal…
lukyrys Mar 15, 2026
ccfc7ea
test: add Playwright E2E tests for catalog UI with mock backend
lukyrys Mar 15, 2026
28f72f2
feat: add ownerPeerID field to ILISHNetwork and lishnets DB table
lukyrys Mar 15, 2026
59f40b0
feat: add getPrivateKey, registerStreamHandler, dialProtocolByPeerId …
lukyrys Mar 15, 2026
23f34c2
feat: wire CatalogManager into Networks — join/leave triggers catalog…
lukyrys Mar 15, 2026
1d310e6
feat: add GossipSub catalog_op handler, async TopicHandler support
lukyrys Mar 15, 2026
57e8180
feat: add catalog WebSocket events — catalog:updated, catalog:removed…
lukyrys Mar 15, 2026
abeb3bb
feat: add catalog error codes to shared errors
lukyrys Mar 15, 2026
e3e9c03
feat: add catalog event subscription and live updates on Products page
lukyrys Mar 15, 2026
739cd17
feat: add bilateral sync protocol, rate limiter, topic validator, del…
lukyrys Mar 15, 2026
5e8579d
test: add bilateral sync and rate limiter tests
lukyrys Mar 15, 2026
53b40ee
feat: add getSyncStatus API, anti-entropy timer infrastructure
lukyrys Mar 15, 2026
bc1c2d7
feat: enforce catalog size limits and per-publisher quota in validati…
lukyrys Mar 15, 2026
d1507c0
feat: power-events-first batch ordering in sync, ACL state transfer t…
lukyrys Mar 15, 2026
5cf7de9
feat: add ownerPeerID to ILISHNetwork, auto-join catalog on startup
lukyrys Mar 15, 2026
1ee0b4f
fix: graceful degradation — catalog errors never block file sharing
lukyrys Mar 15, 2026
6e18c7e
feat: upgrade GossipSub D from 2 to 6 (spec minimum for mesh resilience)
lukyrys Mar 15, 2026
017ee36
feat: add automatic tombstone GC timer (every 6h, 30-day retention)
lukyrys Mar 15, 2026
5728b41
feat: add manifestHash computation, auth failure logging, schema vers…
lukyrys Mar 15, 2026
a008187
feat: auto-assign ownerPeerID when creating new network
lukyrys Mar 15, 2026
d9d36bb
feat: wire search bar to catalog API, show metadata (size, tags, type…
lukyrys Mar 15, 2026
b85bd16
fix: clear broadcasts in multi-peer test to prevent cross-test contam…
lukyrys Mar 15, 2026
291311c
test: add Playwright tests for metadata display, search bar, navigati…
lukyrys Mar 15, 2026
25434ac
feat: add GossipSub peer scoring (P4 invalid messages, P6 IP colocation)
lukyrys Mar 15, 2026
2e08d55
feat: add ACL management API, rewrite Product detail with catalog met…
lukyrys Mar 15, 2026
c254290
test: add Playwright test for product detail page metadata
lukyrys Mar 15, 2026
6500139
test: add 9 real use case tests — community lifecycle, sync, concurre…
lukyrys Mar 15, 2026
d52ec70
test: add two-node P2P integration test — real libp2p GossipSub catal…
lukyrys Mar 15, 2026
ab98c27
test: add 14 adversarial tests with 4 real libp2p nodes — forgery, co…
lukyrys Mar 15, 2026
a3b3c11
feat: add ACL admin panel component for catalog role management
lukyrys Mar 15, 2026
3ed4f2a
fix: update mock backend with realistic ACL PeerIDs and getSyncStatus…
lukyrys Mar 15, 2026
6157ec1
feat: add catalog:sync event, ownerPeerID validation, fix unused imports
lukyrys Mar 15, 2026
2c2fa8d
test: add Playwright config for real backend E2E testing (not mock)
lukyrys Mar 15, 2026
af813b8
test: add 27-test 10-node P2P suite — roles, collision, forgery, revo…
lukyrys Mar 15, 2026
73d0bbe
test: add 22 deep 10-node tests — rights propagation, delete across n…
lukyrys Mar 15, 2026
f03d528
fix: ESC navigation works on empty catalog — fallback navArea for loa…
lukyrys Mar 16, 2026
eb7d472
merge: integrate main branch (7 commits — search filter, verify all, …
lukyrys Mar 16, 2026
4558e31
chore: update gitignore, fix unused imports after main merge
lukyrys Mar 16, 2026
013bb6d
feat: library page auto-detects active network, removes hardcoded cat…
lukyrys Mar 16, 2026
9ec1cb4
feat: add Publish and Permissions UI panels to Library page
lukyrys Mar 16, 2026
e56f089
feat: rewrite Library UI with proper Button/Row/Input components, key…
lukyrys Mar 16, 2026
fbea4ea
fix: align Library UI with project patterns — Table/TableRow for ACL,…
lukyrys Mar 16, 2026
5dab7a4
test: rewrite catalog E2E tests — keyboard navigation, publish/permis…
lukyrys Mar 16, 2026
72bf6f2
fix: reject negative totalSize/chunkSize/fileCount in catalog validator
lukyrys Mar 16, 2026
d23a751
fix: extract panels to child components — keyboard nav to Publish/Per…
lukyrys Mar 16, 2026
d308568
fix: move toolbar into scrollable area, redesign Product detail with …
lukyrys Mar 16, 2026
bcac352
feat: restore category selection (All/Movies/Software/Video), add des…
lukyrys Mar 16, 2026
5044134
fix: add cs translations for categories, wire Download button to tran…
lukyrys Mar 16, 2026
5c4c00f
feat: add catalog.startDownload API, wire Download button, restore ca…
lukyrys Mar 16, 2026
afefe6c
feat: implement catalog.startDownload with real P2P download via Down…
lukyrys Mar 16, 2026
f70a253
test: add full A-Z E2E integration test for catalog workflow
lukyrys Mar 16, 2026
4ad4537
feat: wire catalog download to downloads store, add transfer event li…
lukyrys Mar 16, 2026
dd48f2e
fix: catalog download flow — fetch manifest from peer before download…
lukyrys Mar 16, 2026
47746e4
fix: register catalog GossipSub handler on startup (was only in setEn…
lukyrys Mar 16, 2026
9463ee7
feat: implement bilateral catalog sync protocol for historical entry …
lukyrys Mar 16, 2026
b68fe6f
fix: add debug logging to catalog sync, handle CBOR type conversion
lukyrys Mar 16, 2026
3c6a7b0
fix: clear vector clocks before bilateral sync to prevent REPLAY_DETE…
lukyrys Mar 16, 2026
4fd7d47
fix: single-file LISH directory path, download() waits for actual com…
lukyrys Mar 16, 2026
096b4f1
feat: add real-time download progress events (chunks, peers, speed) t…
lukyrys Mar 16, 2026
0c58836
fix: preserve downloading status when lishs:add event overwrites entry
lukyrys Mar 16, 2026
5cc4ebe
fix: emit lishs:add when manifest imported, expose navigateTo for tes…
lukyrys Mar 16, 2026
573edc0
feat: add direct peer probe fallback when GossipSub want/have fails
lukyrys Mar 16, 2026
2c4c27f
fix: track active downloads via timestamp, prevent lishs:add from ove…
lukyrys Mar 16, 2026
2062893
feat: add download speed calculation and peer count to progress events
lukyrys Mar 16, 2026
122d74a
fix: broadcast download events to ALL clients, not just the requestin…
lukyrys Mar 16, 2026
0438152
fix: use separate stream for manifest probe and chunk download (preve…
lukyrys Mar 16, 2026
4040598
fix: adapt download toolbar to current state (hide irrelevant buttons…
lukyrys Mar 16, 2026
6786c63
feat: real pause/resume download with backend API + UI wiring
lukyrys Mar 16, 2026
81d6156
fix: show upload toggle during active download
lukyrys Mar 16, 2026
0805fc2
feat: add 'searching' download status for peer discovery phase
lukyrys Mar 16, 2026
c4a27b0
fix: prevent progress events from overriding paused download state
lukyrys Mar 16, 2026
96ae515
merge: upstream main (open-directory, enable/disable download/upload)
lukyrys Mar 16, 2026
1f7a872
feat: wire up enable/disable all download/upload buttons to backend API
lukyrys Mar 16, 2026
2a35c49
fix: remove unused searching download status
lukyrys Mar 16, 2026
1f75d49
chore: remove test data from tracking, add data-*/patterns to gitignore
lukyrys Mar 16, 2026
a02b42a
merge: feat/downloading into feat/online-db
lukyrys Mar 23, 2026
c8377f5
merge: feat/downloading into feat/online-db
lukyrys Mar 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,17 @@ vite.config.js.timestamp-*
**/cert.pem
**/key.pem
**/*.orig
data-*/
.node-*/

# Editor/IDE local configs
.[a-z]*/
!.git/
!.github/
/[A-Z]*.md
!/README.md

# Test artifacts
**/test-results/
**/.playwright-real-data/
**/playwright-report/
4 changes: 4 additions & 0 deletions .peer-pids
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
50372
231896
215452
207536
74 changes: 44 additions & 30 deletions backend/bun.lock

Large diffs are not rendered by default.

27 changes: 26 additions & 1 deletion backend/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { initFsHandlers } from './fs.ts';
import { initLISHsHandlers } from './lishs.ts';
import { initTransferHandlers } from './transfer.ts';
import { initEventsHandlers } from './events.ts';
import { initCatalogHandlers } from './catalog.ts';
import { type CatalogManager } from '../catalog/catalog-manager.ts';
import { initSystemHandlers } from './system.ts';
interface ClientData {
subscribedEvents: Set<string>;
Expand Down Expand Up @@ -41,7 +43,7 @@ export class APIServer {
private readonly dataServer: DataServer;
private readonly networks: Networks;

constructor(dataDir: string, dataServer: DataServer, networks: Networks, settings: Settings, options: APIServerOptions) {
constructor(dataDir: string, dataServer: DataServer, networks: Networks, settings: Settings, options: APIServerOptions, catalogManager?: CatalogManager | undefined) {
this.dataDir = dataDir;
this.dataServer = dataServer;
this.networks = networks;
Expand All @@ -60,6 +62,13 @@ export class APIServer {
const _fs = initFsHandlers();
const _lishs = initLISHsHandlers(this.dataServer, emitTo, broadcastFn);
const _transfer = initTransferHandlers(this.networks, this.dataServer, this.dataDir, emitTo, broadcastFn);
const _catalog = catalogManager ? initCatalogHandlers(catalogManager, {
networks: this.networks,
dataServer: this.dataServer,
dataDir: this.dataDir,
emit: emitTo,
broadcast: broadcastFn,
}) : null;
const hasSubscribers = (event: string): boolean => {
for (const client of this.clients) {
if (client.data.subscribedEvents.has(event) || client.data.subscribedEvents.has('*')) return true;
Expand Down Expand Up @@ -144,6 +153,22 @@ export class APIServer {
'fs.exists': _fs.exists,
'fs.writeText': _fs.writeText,
'fs.writeCompressed': _fs.writeCompressed,
// Catalog (optional — requires CatalogManager)
...(_catalog ? {
'catalog.list': _catalog.list,
'catalog.get': _catalog.get,
'catalog.search': _catalog.search,
'catalog.publish': _catalog.publish,
'catalog.update': _catalog.update,
'catalog.remove': _catalog.remove,
'catalog.getAccess': _catalog.getAccess,
'catalog.grantRole': _catalog.grantRole,
'catalog.revokeRole': _catalog.revokeRole,
'catalog.getSyncStatus': _catalog.getSyncStatus,
'catalog.startDownload': _catalog.startDownload,
'catalog.pauseDownload': _catalog.pauseDownload,
'catalog.resumeDownload': _catalog.resumeDownload,
} : {}),
// System
'system.ram': _system.ram,
'system.storage': _system.storage,
Expand Down
190 changes: 190 additions & 0 deletions backend/src/api/catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { type CatalogManager } from '../catalog/catalog-manager.ts';
import { type Networks } from '../lishnet/lishnets.ts';
import { type DataServer } from '../lish/data-server.ts';
import { Downloader } from '../protocol/downloader.ts';
import { type IStoredLISH, type HashAlgorithm, CodedError, ErrorCodes } from '@shared';
import { Utils } from '../utils.ts';
import { join } from 'path';
import type { CatalogEntryRow, CatalogACLRow } from '../db/catalog.ts';

const assert = Utils.assertParams;

export interface StartDownloadResult {
status: 'downloading' | 'not_available';
message: string;
downloadDir?: string;
}

export interface CatalogHandlers {
list: (p: { networkID: string; limit?: number }) => CatalogEntryRow[];
get: (p: { networkID: string; lishID: string }) => CatalogEntryRow | null;
search: (p: { networkID: string; query: string; limit?: number }) => CatalogEntryRow[];
publish: (p: {
networkID: string; lishID: string; name?: string; description?: string;
chunkSize: number; checksumAlgo: string; totalSize: number; fileCount: number;
manifestHash: string; contentType?: string; tags?: string[];
}) => Promise<void>;
update: (p: { networkID: string; lishID: string; name?: string; description?: string; contentType?: string; tags?: string[] }) => Promise<void>;
remove: (p: { networkID: string; lishID: string }) => Promise<void>;
getAccess: (p: { networkID: string }) => CatalogACLRow | null;
grantRole: (p: { networkID: string; delegatee: string; role: 'admin' | 'moderator' }) => Promise<void>;
revokeRole: (p: { networkID: string; delegatee: string; role: 'admin' | 'moderator' }) => Promise<void>;
getSyncStatus: (p: { networkID: string }) => { entryCount: number; tombstoneCount: number; lastSyncAt: string | null };
startDownload: (p: { networkID: string; lishID: string }, client: any) => Promise<StartDownloadResult>;
pauseDownload: (p: { lishID: string }) => { success: boolean };
resumeDownload: (p: { lishID: string }) => { success: boolean };
}

type EmitFn = (client: any, event: string, data: any) => void;

type BroadcastFn = (event: string, data: any) => void;

export interface CatalogHandlerDeps {
networks: Networks;
dataServer: DataServer;
dataDir: string;
emit: EmitFn;
broadcast: BroadcastFn;
}

// Track active downloaders for pause/resume
const activeDownloaders = new Map<string, Downloader>();

export function initCatalogHandlers(catalogManager: CatalogManager, deps?: CatalogHandlerDeps): CatalogHandlers {
return {
list(p) {
assert(p, ['networkID']);
return catalogManager.list(p.networkID, p.limit);
},
get(p) {
assert(p, ['networkID', 'lishID']);
return catalogManager.get(p.networkID, p.lishID);
},
search(p) {
assert(p, ['networkID', 'query']);
return catalogManager.search(p.networkID, p.query, p.limit);
},
async publish(p) {
assert(p, ['networkID', 'lishID', 'chunkSize', 'checksumAlgo', 'totalSize', 'fileCount', 'manifestHash']);
await catalogManager.publish(p.networkID, p);
},
async update(p) {
assert(p, ['networkID', 'lishID']);
const fields: { name?: string; description?: string; contentType?: string; tags?: string[] } = {};
if (p.name !== undefined) fields.name = p.name;
if (p.description !== undefined) fields.description = p.description;
if (p.contentType !== undefined) fields.contentType = p.contentType;
if (p.tags !== undefined) fields.tags = p.tags;
await catalogManager.update(p.networkID, p.lishID, fields);
},
async remove(p) {
assert(p, ['networkID', 'lishID']);
await catalogManager.remove(p.networkID, p.lishID);
},
getAccess(p) {
assert(p, ['networkID']);
return catalogManager.getAccess(p.networkID);
},
async grantRole(p) {
assert(p, ['networkID', 'delegatee', 'role']);
await catalogManager.grantRole(p.networkID, p.delegatee, p.role);
},
async revokeRole(p) {
assert(p, ['networkID', 'delegatee', 'role']);
await catalogManager.revokeRole(p.networkID, p.delegatee, p.role);
},
getSyncStatus(p) {
assert(p, ['networkID']);
return catalogManager.getSyncStatus(p.networkID);
},
async startDownload(p, client): Promise<StartDownloadResult> {
assert(p, ['networkID', 'lishID']);
const entry = catalogManager.get(p.networkID, p.lishID);
if (!entry) {
return { status: 'not_available', message: 'Entry not found in catalog' };
}
if (!deps) {
return { status: 'not_available', message: 'Download infrastructure not available' };
}

// Build a stub LISH manifest from catalog entry metadata.
// The downloader will broadcast "want" on GossipSub and peers with the actual
// chunks will respond with "have" — the manifest only needs id, name, chunkSize, checksumAlgo.
const stubManifest: IStoredLISH = {
id: entry.lish_id,
name: entry.name ?? entry.lish_id,
description: entry.description ?? undefined,
created: entry.published_at ?? new Date().toISOString(),
chunkSize: entry.chunk_size,
checksumAlgo: (entry.checksum_algo as HashAlgorithm) ?? 'sha256',
};

try {
const network = deps.networks.getRunningNetwork();
const downloadDir = join(deps.dataDir, 'downloads', Date.now().toString());
const downloader = new Downloader(downloadDir, network, deps.dataServer, p.networkID);
await downloader.initFromManifest(stubManifest);

// Notify ALL clients when manifest is imported (LISH appears in downloads)
downloader.setManifestImportedCallback(lishID => {
const detail = deps.dataServer.getDetail(lishID);
if (detail) deps.broadcast('lishs:add', detail);
});

// Broadcast progress to ALL connected clients
downloader.setProgressCallback(info => {
deps.broadcast('transfer.download:progress', {
lishID: entry.lish_id,
downloadedChunks: info.downloadedChunks,
totalChunks: info.totalChunks,
peers: info.peers,
bytesPerSecond: info.bytesPerSecond,
});
});

// Track downloader for pause/resume
activeDownloaders.set(entry.lish_id, downloader);

// Start async download — broadcast completion/error to ALL clients
downloader
.download()
.then(() => {
activeDownloaders.delete(entry.lish_id);
deps.broadcast('transfer.download:complete', { downloadDir, lishID: entry.lish_id, name: entry.name });
})
.catch(err => {
activeDownloaders.delete(entry.lish_id);
if (err instanceof CodedError) deps.broadcast('transfer.download:error', { error: err.code, errorDetail: err.detail, lishID: entry.lish_id });
else deps.broadcast('transfer.download:error', { error: ErrorCodes.DOWNLOAD_ERROR, errorDetail: err.message, lishID: entry.lish_id });
});

return {
status: 'downloading',
message: `Download started for "${entry.name}". Looking for peers with the file...`,
downloadDir,
};
} catch (err: any) {
return {
status: 'not_available',
message: `Cannot start download: ${err.message}`,
};
}
},
pauseDownload(p) {
assert(p, ['lishID']);
const dl = activeDownloaders.get(p.lishID);
if (!dl) return { success: false };
dl.pause();
deps?.broadcast('transfer.download:paused', { lishID: p.lishID });
return { success: true };
},
resumeDownload(p) {
assert(p, ['lishID']);
const dl = activeDownloaders.get(p.lishID);
if (!dl) return { success: false };
dl.resume();
deps?.broadcast('transfer.download:resumed', { lishID: p.lishID });
return { success: true };
},
};
}
21 changes: 20 additions & 1 deletion backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { DataServer } from './lish/data-server.ts';
import { openDatabase } from './db/database.ts';
import { APIServer } from './api/api.ts';
import { Settings } from './settings.ts';
import { CatalogManager } from './catalog/catalog-manager.ts';
import { setWorkerUrl } from './lish/lish.ts';

// Parse command line arguments
Expand Down Expand Up @@ -64,6 +65,24 @@ const dataServer = new DataServer(db);
const networks = new Networks(db, dataDir, dataServer, settings, enablePink);
networks.init();

const catalogManager = new CatalogManager({
db,
getPrivateKey: () => networks.getRunningNetwork().getPrivateKey() as any,
getLocalPeerID: () => {
try { return networks.getRunningNetwork().getNodeInfo()?.peerID ?? 'local'; } catch { return 'local'; }
},
broadcast: (networkID, op) => {
try {
const net = networks.getRunningNetwork();
net.broadcast(`lish/${networkID}`, { type: 'catalog_op', ...op });
} catch { /* network not running — skip broadcast */ }
},
emitEvent: (event, data) => {
try { apiServer.broadcastEvent(event, data); } catch { /* server not started */ }
},
});
networks.setCatalogManager(catalogManager);

// Apply speed limits from settings
import { Downloader } from './protocol/downloader.ts';
import { setMaxUploadSpeed, setUploadBroadcast, initUploadState } from './protocol/lish-protocol.ts';
Expand All @@ -81,7 +100,7 @@ const apiServer = new APIServer(dataDir, dataServer, networks, settings, {
secure: apiSecure,
keyFile: apiKeyFile,
certFile: apiCertFile,
});
}, catalogManager);

// Wire upload progress broadcast (after apiServer is created)
setUploadBroadcast((event, data) => apiServer.broadcastEvent(event, data));
Expand Down
Loading