diff --git a/service/pages-browse.js b/service/pages-browse.js
new file mode 100644
index 0000000..b20f434
--- /dev/null
+++ b/service/pages-browse.js
@@ -0,0 +1,303 @@
+/**
+ * Browse pages โ category index and per-listing detail.
+ *
+ * Everything here is rendered server-side from the SQLite listings table. No
+ * client JS, so Googlebot sees the real page, Theseus sees the real page, and
+ * the s3 snapshot (future milestone #12) is just the same HTML written to disk.
+ *
+ * These pages are also where the "0 listings" messaging on the home page and
+ * /browse flips to real counts โ `countsByCategory()` is exported here and
+ * imported by pages.js so the two render surfaces agree on what is live.
+ */
+
+import {
+ listingsByCategory,
+ getListing,
+ countByCategory as countByCategoryDb,
+} from './db.js';
+import { escapeHtml } from './layout.js';
+import { notFound } from './pages.js';
+
+/* ------------------------------- categories ------------------------------- */
+
+/**
+ * The authoritative metadata for the eight categories โ icon, title, and the
+ * blurb that shows on the home grid and at the top of the category page. One
+ * table so a copy change is a one-liner rather than scattered across templates.
+ *
+ * Adding a new category means adding a row here AND adding the slug to the
+ * allow-list in listings.js. The browse grid picks up the new row
+ * automatically.
+ */
+export const CATEGORIES = [
+ { slug: 'communication', icon: '๐ฌ', title: 'Communication',
+ blurb: `Messengers, voice, boards, group chats. Clients that route through peers rather than someone else's server.` },
+ { slug: 'privacy', icon: '๐ก๏ธ', title: 'Privacy & security',
+ blurb: `Tor and mix-net tooling, key and vault managers, disk crypto. The things a conventional store delists first when a government writes.` },
+ { slug: 'wallets', icon: '๐ธ', title: 'Wallets & payments',
+ blurb: `BCH wallets, cross-chain bridges, point-of-sale terminals. The chain publishes the listing and the chain settles the payment.` },
+ { slug: 'games', icon: '๐ฎ', title: 'Games',
+ blurb: `Standalone titles, on-chain leaderboards, in-app purchases that reach the publisher instead of a 30% toll.` },
+ { slug: 'devtools', icon: '๐งฐ', title: 'Developer tools',
+ blurb: `SDKs, libraries, indexers, faucets, protocol utilities. The pieces other apps on this list are built out of.` },
+ { slug: 'media', icon: '๐ง', title: 'Media & publishing',
+ blurb: `Readers, players, one-file blogs, podcast hosts. Clients that find their audience without going through an aggregator.` },
+ { slug: 'utilities', icon: 'โ๏ธ', title: 'Utilities',
+ blurb: `File tools, format converters, backup, calculators. The small sharp things that solve one problem and do not ask for an account first.` },
+ { slug: 'other', icon: 'โจ', title: 'Everything else',
+ blurb: `Anything that does not fit above. New categories are proposed in-band โ any listing can declare one and clients will offer to add it.` },
+];
+
+const CATEGORY_BY_SLUG = Object.fromEntries(CATEGORIES.map((c) => [c.slug, c]));
+
+/**
+ * {slug: count} for every category โ categories with 0 listings are present
+ * with the value 0, so templates can read them unconditionally.
+ */
+export function countsByCategory() {
+ const raw = countByCategoryDb();
+ const out = {};
+ for (const c of CATEGORIES) out[c.slug] = raw[c.slug] || 0;
+ return out;
+}
+
+/**
+ * Shared category grid, used on the home page and on /browse.
+ *
+ * `verbose` controls whether each card carries its blurb. Home page wants it;
+ * the dedicated browse page already has the blurb at the top and does not
+ * need to repeat it on every card.
+ */
+export function renderCategoriesGrid({ verbose = true } = {}) {
+ const counts = countsByCategory();
+ return `
`;
+}
+
+/* ------------------------------- category page ------------------------------- */
+
+export function categoryPage(req, slug) {
+ const cat = CATEGORY_BY_SLUG[slug];
+ if (!cat) return notFound(req);
+
+ const rows = listingsByCategory(slug);
+ const title = cat.title;
+ const here = `/c/${slug}`;
+
+ return {
+ title,
+ description: `${cat.title} apps on Prometheus โ ${cat.blurb}`,
+ here,
+ body: `
+
+ ${cat.icon}
+ ${escapeHtml(title)}
+ ${escapeHtml(cat.blurb)}
+
+
+
+
+
+
+ ${rows.length === 0
+ ? `No listings in this category yet. Be the first.`
+ : `${rows.length} ${rows.length === 1 ? 'listing' : 'listings'}, newest first.`}
+
+
+ ${rows.length === 0 ? emptyCategory(cat) : renderListingList(rows)}
+
+
+ โ All categories
+
+
+
+
`,
+ };
+}
+
+/* ------------------------------- app detail page ------------------------------- */
+
+export function appPage(req, publisher, slug) {
+ const id = `${String(publisher).toLowerCase()}/${String(slug).toLowerCase()}`;
+ const row = getListing(id);
+ if (!row || row.withdrawn_at) return notFound(req);
+
+ const cat = CATEGORY_BY_SLUG[row.category];
+ const catChip = cat
+ ? `${cat.icon} ${escapeHtml(cat.title)}`
+ : '';
+
+ const scanReport = safeParse(row.scan_report);
+ const scanPayload = scanReport?.payload ? safeParse(fromBase64Url(scanReport.payload)) : null;
+ const verdict = scanPayload?.scan?.verdict || 'unknown';
+ const scanBadge = renderScanBadge(verdict, scanPayload);
+
+ const iconHtml = row.icon
+ ? `
`
+ : `${cat?.icon || '๐ฆ'}
`;
+
+ return {
+ title: `${row.name} by ${row.publisher}`,
+ description: row.description.slice(0, 200),
+ here: `/app/${row.publisher}/${row.slug}`,
+ body: `
+
+
+ ${iconHtml}
+
+
${escapeHtml(row.name)}
+
+ by ${escapeHtml(row.publisher)} ยท
+ v${escapeHtml(row.version)} ยท
+ ${catChip}
+
+
+
+
+
+
+
+
+
+
+
+
Download
+
+ ${formatBytes(row.download_size)} ยท
+ ${shortSha(row.download_sha256)}
+ ${scanBadge}
+
+
+
Download
+
+
+ Full hash + source URL
+
+
+
SHA-256 ${row.download_sha256}
+
+
+
+
+
+
+
+
+ Who published this
+ Signed by ${escapeHtml(row.publisher)} โ a BNS name owned by
+ ${escapeHtml(row.signer_address)}. Any installer
+ the chain says belongs to that key is trusted by your client; any client
+ that follows a different trust list will not see it as trusted.
+
+
+
+ Endorsements
+ No endorsements yet. An endorsement is a signed
+ on-chain statement from any BNS name saying "I vouch for this app." Readers
+ whose trust list includes the endorser see the endorsement; those who do
+ not, do not. The endorsement protocol lands with APS1.
+
+
+
+ ${cat ? `โ Back to ${escapeHtml(cat.title)}` : `โ Browse`}
+
+
+
`,
+ };
+}
+
+/* ------------------------------- helpers ------------------------------- */
+
+function renderListingList(rows) {
+ return `
+ ${rows.map(renderListingCard).join('\n')}
+
`;
+}
+
+function renderListingCard(row) {
+ const href = `/app/${encodeURIComponent(row.publisher)}/${encodeURIComponent(row.slug)}`;
+ return `
+ ${escapeHtml(row.name)}
+
+ ${escapeHtml(truncate(row.description, 160))}
+
+
+ by ${escapeHtml(row.publisher)} ยท v${escapeHtml(row.version)} ยท ${formatBytes(row.download_size)}
+
+ `;
+}
+
+function emptyCategory(cat) {
+ return `
+
Nothing here yet. If you ship software that fits "${escapeHtml(cat.title)}",
+ the
submit flow is live โ upload, scan, sign, your listing
+ appears in this view.
+
`;
+}
+
+function renderScanBadge(verdict, payload) {
+ const engine = payload?.scan?.engine || '';
+ const sigs = payload?.scan?.signatures || [];
+ switch (verdict) {
+ case 'clean':
+ return `scan โ clean${engine ? ` (${escapeHtml(engine)})` : ''}`;
+ case 'infected':
+ return `scan โ infected`;
+ default:
+ return `scan ? ${escapeHtml(verdict)}`;
+ }
+}
+
+function formatBytes(n) {
+ const num = Number(n) || 0;
+ if (num < 1024) return `${num} B`;
+ if (num < 1024 * 1024) return `${(num / 1024).toFixed(1)} KiB`;
+ if (num < 1024 * 1024 * 1024) return `${(num / 1024 / 1024).toFixed(1)} MiB`;
+ return `${(num / 1024 / 1024 / 1024).toFixed(2)} GiB`;
+}
+
+function shortSha(sha) {
+ if (!sha) return '';
+ const s = String(sha);
+ if (s.length <= 16) return s;
+ return `${s.slice(0, 8)}โฆ${s.slice(-6)}`;
+}
+
+function truncate(s, max) {
+ const str = String(s || '');
+ if (str.length <= max) return str;
+ return str.slice(0, max - 1) + 'โฆ';
+}
+
+function escapeAttr(s) {
+ // Same as text escape; attributes are also always wrapped in double-quotes.
+ return escapeHtml(s);
+}
+
+function safeParse(s) {
+ if (!s) return null;
+ if (typeof s === 'object') return s;
+ try { return JSON.parse(s); } catch { return null; }
+}
+
+function fromBase64Url(s) {
+ try { return Buffer.from(String(s), 'base64url').toString('utf8'); }
+ catch { return ''; }
+}
diff --git a/service/pages.js b/service/pages.js
index a1e29b4..5ab2386 100644
--- a/service/pages.js
+++ b/service/pages.js
@@ -10,6 +10,35 @@
* as the SEO-friendly mirror.
*/
+import { renderCategoriesGrid, countsByCategory } from './pages-browse.js';
+import { countActive } from './db.js';
+
+/**
+ * The "Browse by category" section used on the home page. Lives here so the
+ * home page's inline template reads cleanly, but delegates the actual grid to
+ * pages-browse.js so a single row of counts drives both surfaces.
+ */
+function renderHomeBrowseSection() {
+ const total = countActive();
+ return `
+ Browse by category
+ On a conventional store, one company decides which shelf an app
+ belongs on. Here the publisher labels their own listing, and your trust list
+ decides whether to show it. The categories below are the initial shape; a new
+ one gets proposed by publishing a listing against it.
+
+ ${renderCategoriesGrid({ verbose: true })}
+
+ ${total === 0 ? `
+ No live listings yet.
+ The on-chain publication protocol (APS1) is in
+ progress; in the meantime the site, the scanner, the publisher-signed
+ listing endpoint and the publishers' walkthrough are up. First real
+ listings appear in this grid as they publish.
+ ` : ''}
+`;
+}
+
/* ------------------------------- home ------------------------------- */
export function home() {
@@ -36,70 +65,7 @@ export function home() {
-
- Browse by category
- On a conventional store, one company decides which shelf an app
- belongs on. Here the publisher labels their own listing, and your trust list
- decides whether to show it. The categories below are the initial shape; a new
- one gets proposed by publishing a listing against it.
-
-
-
๐ฌ
- Communication
- Messengers, voice, boards, group chats. Clients that route through peers
- rather than someone else's server.
- 0 listings
-
-
๐ก๏ธ
- Privacy & security
- Tor and mix-net tooling, key and vault managers, disk crypto. The things
- a conventional store delists first when a government writes.
- 0 listings
-
-
๐ธ
- Wallets & payments
- BCH wallets, cross-chain bridges, point-of-sale terminals. The chain
- publishes the listing and the chain settles the payment.
- 0 listings
-
-
๐ฎ
- Games
- Standalone titles, on-chain leaderboards, in-app purchases that reach
- the publisher instead of a 30% toll.
- 0 listings
-
-
๐งฐ
- Developer tools
- SDKs, libraries, indexers, faucets, protocol utilities. The pieces other
- apps on this list are built out of.
- 0 listings
-
-
๐ง
- Media & publishing
- Readers, players, one-file blogs, podcast hosts. Clients that find their
- audience without going through an aggregator.
- 0 listings
-
-
โ๏ธ
- Utilities
- File tools, format converters, backup, calculators. The small sharp
- things that solve one problem and do not ask for an account first.
- 0 listings
-
-
โจ
- Everything else
- Anything that does not fit above. New categories are proposed in-band โ
- any listing can declare one and clients will offer to add it.
- 0 listings
-
-
-
- No live listings yet.
- The listing protocol (APS1 record on chain) and
- the submission flow are being wired now. The site, the scanner, the
- publishers' walkthrough and this directory are up; a listing you can click
- on and install is next.
-
+${renderHomeBrowseSection()}
How Prometheus works, in a nutshell
@@ -303,6 +269,10 @@ export function publish() {
/* ------------------------------- browse ----------------------------- */
export function browse() {
+ const total = countActive();
+ const totalLabel = total === 0
+ ? `No listings yet โ the eight categories are set and ready to receive publications.`
+ : `${total} ${total === 1 ? 'listing' : 'listings'} across ${Object.values(countsByCategory()).filter((n) => n > 0).length} ${Object.values(countsByCategory()).filter((n) => n > 0).length === 1 ? 'category' : 'categories'}.`;
return {
title: 'Browse',
description:
@@ -318,29 +288,9 @@ export function browse() {
- No live listings yet. The listing protocol is
- being wired; the browse pages below will light up as the APS1 index starts
- carrying records. In the meantime, the eight categories are set and ready
- to receive publications.
+ ${totalLabel}
-
+ ${renderCategoriesGrid({ verbose: false })}
`,
diff --git a/service/server.js b/service/server.js
index 4fd1b32..a8bbc14 100644
--- a/service/server.js
+++ b/service/server.js
@@ -26,6 +26,7 @@ import {
handleListingIndex,
handleListingGet,
} from './listings.js';
+import { categoryPage, appPage } from './pages-browse.js';
const PORT = Number(process.env.PORT) || 3700;
const BIND = process.env.BIND || '127.0.0.1';
@@ -88,6 +89,17 @@ const server = createServer(async (req, res) => {
await serveHealth(res);
} else if (url === '/.well-known/prometheus-service-key.json') {
serveServiceKey(res);
+ } else if (url.startsWith('/c/')) {
+ const slug = url.slice('/c/'.length);
+ servePageFn(() => categoryPage(req, slug), res);
+ } else if (url.startsWith('/app/')) {
+ // /app//
+ const parts = url.slice('/app/'.length).split('/').filter(Boolean);
+ if (parts.length === 2) {
+ servePageFn(() => appPage(req, parts[0], parts[1]), res);
+ } else {
+ servePage(pages.notFound, res, 404, req);
+ }
} else if (PAGE_ROUTES[url]) {
servePage(PAGE_ROUTES[url], res);
} else {
@@ -133,6 +145,25 @@ function normalizeRequestUrl(raw) {
*/
function servePage(fn, res, status = 200, req = null) {
const page = fn(req);
+ renderPage(page, res, status);
+}
+
+/**
+ * Render a page object (not a function). Used by routes that take extra
+ * arguments to produce the page (category slug, app publisher/slug) and
+ * therefore need to call the generator themselves rather than hand a bare
+ * reference to servePage.
+ */
+function servePageFn(produce, res) {
+ const page = produce();
+ // A `notFound()` return has this exact title; routes use it as the convention
+ // for "the thing you asked for does not exist" so we don't need a parallel
+ // status plumbing path.
+ const status = page.title === 'Not here' ? 404 : 200;
+ renderPage(page, res, status);
+}
+
+function renderPage(page, res, status) {
const html = layout(page);
res.writeHead(status, {
'content-type': 'text/html; charset=utf-8',