From 2c5df40d77d5d5680be6e6f9981555cc38c4b30c Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 5 Oct 2026 22:22:47 +0200 Subject: [PATCH] =?UTF-8?q?prometheus:=20scaffold=20the=20standalone=20ser?= =?UTF-8?q?vice=20=E2=80=94=20pages,=20routing,=20static=20assets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New top-level Prometheus/ directory holds the Node service that will own prometheus.x once the on-chain record flips from the silentmode.st redirect to ip+tls. Nothing dynamic yet — this milestone delivers the site skeleton and proves the family's visual conventions port cleanly to a dynamic server. Shape: Prometheus/service/server.js node:http listener + map-based router Prometheus/service/layout.js document shell, topnav, footer Prometheus/service/pages.js home / browse / submit / publish / boundaries Prometheus/public/style.css ported silentmode.st tokens + category grid Prometheus/public/favicon.svg flame mark Routes: /, /browse, /submit, /publish, /boundaries, /static/*. Dynamic routes (/api/upload, /api/manifest, /api/publish, /api/pay, /admin/*) land here in later milestones per the architecture doc. Launch config .claude/launch.json gets a prometheus-service entry on port 3700 so the preview harness can run it. The pre-pivot WordPress-based scaffold at site-prometheus-x/ is left in place for the next session to delete deliberately — the architecture doc marks it as abandoned for prometheus.x but keeps the Sirius Press infra fixes it carried in. --- .gitignore | 11 + README.md | 38 ++++ package.json | 14 ++ public/favicon.svg | 5 + public/style.css | 174 +++++++++++++++ service/layout.js | 104 +++++++++ service/pages.js | 527 +++++++++++++++++++++++++++++++++++++++++++++ service/server.js | 158 ++++++++++++++ 8 files changed, 1031 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 public/favicon.svg create mode 100644 public/style.css create mode 100644 service/layout.js create mode 100644 service/pages.js create mode 100644 service/server.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72aaa11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +*.log +.env +.env.* +!.env.example +db.sqlite +db.sqlite-journal +db.sqlite-wal +db.sqlite-shm +build/ +uploads/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd8fc55 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Prometheus + +The service behind `prometheus.x` — a standalone website that accepts app +listings, scans the binaries, generates APS1 records for publishers to sign, +and serves the browse pages. + +The architecture decisions and the reasoning behind them live at +[`_coordination/sessions/prometheus-x-architecture.md`](../_coordination/sessions/prometheus-x-architecture.md). +Read that before touching the code; it answers the questions this directory +otherwise invites ("why isn't this on Sia?", "why isn't the admin on another +subdomain?", "why Node?"). + +``` +service/ Node HTTP server, templates, scanner, storage, payments +public/ Static assets served under /static/ +content/ Prose content in a form the templates render into pages +``` + +## Run it locally + +```bash +cd Prometheus +npm install # none yet; placeholder for when deps show up +node service/server.js +``` + +Opens on `http://127.0.0.1:3700` by default. Override with `PORT=xxxx`. + +## Layout + +The server is one `node:http` listener behind a trivial map-based router. +Pages are plain functions that return `{title, description, body}` and +`layout()` wraps them in the common document shell. Static files under +`public/` are served directly. + +There's no framework and no templating engine on purpose — a site this +small does not earn a dependency surface, and the response-rendering path +is short enough to read at a glance. diff --git a/package.json b/package.json new file mode 100644 index 0000000..3d88f3e --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "prometheus", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "The service behind prometheus.x", + "license": "MPL-2.0", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node service/server.js" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..43732c2 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..9e53bb5 --- /dev/null +++ b/public/style.css @@ -0,0 +1,174 @@ +/* Prometheus — design tokens and component styles. + Mirrors site/shared.css so a colour change propagates across the Silent Mode + family. The Prometheus-only additions (`.categories`, `.cat`) are at the end + of the file so the shared block stays diffable against the main site. */ + +:root{ + --bg:#0b0e14; + --panel:#141a24; + --panel2:#18202c; + --line:rgba(255,255,255,.09); + --ink:#e7eaf1; + --mut:#8b98a9; + --dim:#7a8496; + --acid:#d6ff3d; + --chain:#d6ff3d; + --sia:#b39ddb; + --srv:#4fd1a5; + --tor:#a98bd8; + --ok:#4fd1a5; + --taken:#f6768a; + --warn:#ffc75f; +} + +*{box-sizing:border-box} +html{scroll-behavior:smooth} +body{ + margin:0; + background:radial-gradient(1100px 560px at 50% -12%,#16202e,var(--bg)); + color:var(--ink); + font:16px/1.65 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; + min-height:100vh; +} +a{color:var(--acid)} + +/* ============ top nav ============ */ +.topnav{ + position:sticky;top:0;z-index:20; + display:flex;gap:2px;align-items:center;flex-wrap:wrap; + padding:10px 1.2rem; + background:rgba(11,14,20,.82); + backdrop-filter:blur(10px); + border-bottom:1px solid var(--line); +} +.topnav .brand{ + font-weight:600;color:var(--ink);text-decoration:none; + margin-right:14px;font-size:15px;letter-spacing:.2px; +} +.topnav .brand .g{color:var(--acid)} +.topnav a{ + color:var(--mut);text-decoration:none; + padding:9px 12px;border-radius:8px;font-size:14px; +} +.topnav a:hover{color:var(--ink);background:var(--panel)} +.topnav a.here{color:var(--acid);background:rgba(214,255,61,.08)} + +/* ============ layout ============ */ +.wrap{max-width:980px;margin:0 auto;padding:0 1.2rem} +.wrap.narrow{max-width:820px} + +header.hero{text-align:center;padding:4.5rem 1.2rem 2.4rem} +header.hero.compact{padding:3rem 1.2rem 1.4rem} +header.hero .mark{font-size:58px;line-height:1} +h1{font-size:clamp(2rem,5vw,3rem);margin:.5rem 0 .3rem;letter-spacing:.4px} +h1 .g{color:var(--acid)} +.tag{color:var(--mut);font-size:1.1rem;max-width:660px;margin:.4rem auto 0} + +section{padding:2.8rem 0;border-top:1px solid var(--line)} +section.plain{border-top:none} +h2{font-size:1.55rem;margin:0 0 .4rem} +h3{font-size:1.05rem;margin:0 0 .3rem} +.lede{color:var(--mut);margin:0 0 1.6rem;max-width:680px} + +.cta{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin-top:1.7rem} + +/* ============ controls ============ */ +.btn{ + display:inline-block;text-decoration:none; + padding:11px 20px;border-radius:999px; + background:var(--panel2);color:var(--ink);font-size:14.5px; + border:1px solid var(--line);cursor:pointer;font-family:inherit; +} +.btn.ghost{background:transparent;border-color:var(--line);color:var(--ink)} +.btn.acid{background:var(--acid);color:#0b0e14;font-weight:600;border:none} +.btn:hover{filter:brightness(1.12)} +.btn:disabled{opacity:.45;cursor:not-allowed;filter:none} + +/* ============ cards & grids ============ */ +.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px} +.card{ + background:var(--panel);border:1px solid var(--line);border-radius:14px; + padding:18px 20px; +} +.card p{margin:0;color:var(--mut);font-size:14px} +.card h3{display:flex;align-items:center;gap:8px} + +/* status pills */ +.pill{ + display:inline-block;font-size:10.5px;letter-spacing:.04em; + text-transform:uppercase;padding:2px 9px;border-radius:999px;margin-bottom:.6rem; +} +.p-live{background:rgba(79,209,165,.14);color:var(--srv)} +.p-alpha{background:rgba(214,255,61,.13);color:var(--acid)} +.p-designed{background:rgba(179,157,219,.14);color:var(--sia)} +.p-upcoming{background:rgba(139,152,169,.14);color:var(--mut)} + +/* ============ steps (numbered explainer lists) ============ */ +ol.steps{counter-reset:s;list-style:none;padding:0;margin:0;display:grid;gap:10px} +ol.steps li{ + counter-increment:s;display:grid;grid-template-columns:26px 1fr;gap:4px 14px; + align-items:start; + background:var(--panel2);border:1px solid var(--line);border-radius:12px; + padding:15px 18px; +} +ol.steps li::before{ + content:counter(s);grid-column:1;grid-row:1;align-self:start; + display:grid;place-items:center; + width:26px;height:26px;border-radius:50%; + background:rgba(214,255,61,.14);color:var(--acid); + font-size:13px;font-weight:700; +} +ol.steps li > *{grid-column:2;min-width:0} +ol.steps b{color:var(--ink);font-size:14.5px} +ol.steps span{color:var(--mut);font-size:14px;line-height:1.62} + +/* ============ code chips + preformatted ============ */ +code.inline,.mono{ + font-family:ui-monospace,monospace;font-size:13px; + background:#0e131b;border:1px solid var(--line);border-radius:6px;padding:1px 6px; +} +pre{ + background:#0e131b;border:1px solid var(--line);border-radius:10px; + padding:13px 16px;overflow-x:auto; + font-family:ui-monospace,monospace;font-size:13px;color:#c9d4e3;margin:.8rem 0; +} + +/* ============ notes ============ */ +.note{ + background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--acid); + border-radius:10px;padding:14px 18px;color:var(--mut);font-size:14px;margin-top:1.4rem; +} +.note b{color:var(--ink)} +.note.warn{border-left-color:var(--warn)} +.note.danger{border-left-color:var(--taken)} + +/* ============ footer ============ */ +footer{ + border-top:1px solid var(--line);padding:2.5rem 0 3.5rem; + color:var(--dim);font-size:13px;text-align:center;margin-top:2.5rem; +} +footer .fine{max-width:660px;margin:.8rem auto 0;line-height:1.7} + +/* =================================================================== + Prometheus-only additions below. Keep the above identical to + site/shared.css so a shared-token change diffs cleanly. + =================================================================== */ + +/* Category grid. Visually distinct from .grid so the two don't blur + together — Prometheus is a shop, the top of a page is a foyer. */ +.categories{ + display:grid; + grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); + gap:14px;margin-top:1rem; +} +.cat{ + background:var(--panel);border:1px solid var(--line);border-radius:14px; + padding:20px 22px;text-decoration:none;color:inherit; + transition:transform .18s ease, border-color .18s ease, background .18s ease; + display:flex;flex-direction:column;gap:.6rem; +} +.cat:hover{transform:translateY(-3px);border-color:rgba(214,255,61,.35);background:var(--panel2)} +.cat .ico{font-size:26px;line-height:1;filter:grayscale(.2)} +.cat h3{margin:.1rem 0 0;font-size:1.02rem} +.cat .count{color:var(--dim);font-size:12px;letter-spacing:.02em;margin-top:auto;padding-top:.4rem} +.cat.empty .count::before{content:"— ";color:var(--dim)} diff --git a/service/layout.js b/service/layout.js new file mode 100644 index 0000000..ba43f99 --- /dev/null +++ b/service/layout.js @@ -0,0 +1,104 @@ +/** + * Shared document shell for every rendered page. + * + * A page function returns `{title, description, body}`; `layout()` wraps it in + * the `` document with the right meta, topnav, and footer. The brand mark + * is inlined as SVG so pages do not fetch an image to render a 20-pixel logo. + */ + +const BRAND_MARK = ``; + +const NAV = [ + { href: '/browse', label: 'Browse' }, + { href: '/submit', label: 'Submit' }, + { href: '/publish', label: 'How publishing works' }, + { href: '/boundaries', label: 'Boundaries' }, +]; + +/** + * Compose a full HTML document from a page. + * + * @param {{title: string, description: string, body: string, here?: string}} page + * @returns {string} the HTML document, ready to send. + */ +export function layout(page) { + const { title, description, body, here = '' } = page; + + // The canonical URL is always on prometheus.x — never the IP+port the server + // actually listens on, and never the SEO mirror at apps.silentmode.st, even + // when the mirror is what served this request. Readers who followed a link + // from search land on the mirror; the canonical sends search engines back to + // the real address and keeps the scorecard on prometheus.x. + const canonical = `https://prometheus.x${here}`; + + const navHtml = NAV.map(n => { + const classAttr = n.href === here ? ' class="here"' : ''; + return `${n.label}`; + }).join('\n '); + + return ` + + + + +${escapeHtml(title)} — Prometheus + + + + + + + + + + + + + + + +${body} + + + + +`; +} + +/** + * Minimal HTML escape for text that goes into attributes or text nodes. + * Not for anything that must survive as markup; page bodies ship as-is. + */ +export function escapeHtml(s) { + return String(s) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/service/pages.js b/service/pages.js new file mode 100644 index 0000000..c443841 --- /dev/null +++ b/service/pages.js @@ -0,0 +1,527 @@ +/** + * The pages Prometheus.X renders. + * + * Each export is `(req?) => {title, description, body}`. The server is the one + * that wraps them in the layout — these just return the inner content. + * + * The copy was lifted from site/prometheus/index.html (the ICANN teaser) and + * split by concern so each page is one topic a reader opened on purpose. The + * single-scroll version lives at silentmode.st/prometheus and will stay there + * as the SEO-friendly mirror. + */ + +/* ------------------------------- home ------------------------------- */ + +export function home() { + return { + title: 'Apps and programs on the decentralized web', + description: + 'Prometheus is a home for apps, programs, and dApps published on the Bitcoin Cash chain. Listings live on chain, binaries live on Sia, endorsements are signed. Anyone can publish; nobody can gatekeep.', + here: '/', + body: ` +
+
🔥
+

Prometheus + designed · not yet built +

+

A home for apps, programs, dApps, and libraries — listed on the + Bitcoin Cash chain, hosted on Sia, endorsed by signature. Anyone can publish. + Nobody can gatekeep. Named for the one who gave fire to humans and did not ask + the gods first.

+ +
+ +
+ +
+

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.

+ + + +
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.
+
+ +
+

How Prometheus works, in a nutshell

+
    +
  1. A listing is a signed on-chain record. + Publishing an app writes a small APS1 + record to Bitcoin Cash — same protocol shape as a BNS name registration, + different prefix. The payload is a hash pointing at a full manifest stored + on Sia. Nobody approves the record; the transaction is the publish.
  2. +
  3. The binary lives on Sia. + App files are uploaded to the Sia storage network — encrypted on + the publisher's machine, sharded across roughly 30 independent hosts, + held by nobody in particular. The listing carries the SHA-256 the file + must match.
  4. +
  5. Scan reports are signed and attached. + Prometheus runs an automated malware scan on every binary it hosts + and publishes the signed report alongside the listing. The chain still + goes up; the report tells your client whether to warn.
  6. +
  7. Install verifies against the chain. + Clients pull the file, check the SHA-256 against the on-chain + record, check the signing certificate against the fingerprint, and only + then install. The chain is the truth, not the mirror.
  8. +
+

The full publishers' walkthrough, + step by step →

+
+ +
`, + }; +} + +/* ------------------------------- publish ---------------------------- */ + +export function publish() { + return { + title: "Publishers' walkthrough", + description: + 'The end-to-end flow of publishing an app on Prometheus: name, hosting, manifest, listing, verification, updates, withdrawal, and endorsements.', + here: '/publish', + body: ` +
+

Publishers' walkthrough

+

Pick a name, host the binary, write the manifest, sign the listing, + verify it resolves, ship an update when it's time. Here's the whole shape.

+
+ +
+ +
+

Publishing an app on Prometheus is a sequence of signed records + pointing at files stored where you keep them. The whole thing — identity, + hosting, the listing itself — is yours; nothing here grants anyone else + permission to pull your app down, raise your price, or take a cut of what + people pay you for it.

+ +

Steps are tagged available today or + designed so it is clear what you can do + now and what is waiting on the APS1 record format landing.

+
+ +
+

What you need before you start

+
+
+ available today +

A BNS name

+

Any name registered through Sirius works. The + name is what signs the listing, so it is also what readers trust the + listing by. Fractions of a cent to register, no monthly bill.

+
+
+ available today +

Somewhere to put the binary

+

Prometheus can host for you — upload here and we put it on Sia, signed + and scanned. If you'd rather self-host on your own URL, the listing just + records the URL and the hash and will fetch from wherever you say.

+
+
+ available today +

A signing key

+

The private key for the BNS name. If the name is in a wallet you already + use for Bitcoin Cash, that key already exists; publishing re-uses it.

+
+
+ available today +

A pinch of BCH

+

One listing is one transaction. The transaction fee is cents, denominated + in sats. During the free wave, that is the only cost. Later, a flat + listing fee covers the Sia storage.

+
+
+
+ +
+

The walkthrough

+
    +
  1. 1. Register a name — or use one you already own. + The listing is signed by the name's key. A reader who follows your + name will see your listing with the publisher chip set to that name; a + reader who does not will see it marked unsigned by anyone they trust. + One name can carry any number of listings, so a studio with three games + publishes under one identity.
  2. + +
  3. 2. Upload the binary (or point us at your URL). + Prometheus runs a malware scan on upload. If it passes, the file + goes into a Sia bucket Silent Mode controls, the signed scan report is + prepared, and the manifest fields are prefilled. If you'd rather host + the file yourself, give us the URL and the expected SHA-256; the manifest + records both, the scan still runs against whatever the URL serves, and + the client refuses to install a byte-mismatched download.
  4. + +
  5. 3. Fill in the manifest. + Name, description, category, version, icon, download URL, + sha256, and the signing-cert fingerprint when + the binary itself is signed (it should be, if the platform supports it). + Icons are referenced by URL, not embedded. A 2 KB manifest with a 500 KB + icon beside it is the right shape; a 500 KB manifest is not.
  6. + +
  7. 4. Sign the APS1 record with your BNS key. + One transaction. The on-chain record carries the manifest hash, + the category label, and the signature from your name's key. From the + moment the transaction confirms, every client that follows the chain can + see your listing — no approval queue, no editorial review, no publisher + dashboard waiting for a human on the other end.
  8. + +
  9. 5. Verify it resolves. + Open one of the Ariadne clients and look for your listing in the + category you labelled it under. The client should pull the manifest from + Sia, show the fields you wrote, surface the scan report, and offer the + download. Clicking install should fetch the binary, check the SHA-256, + check the signing certificate, and only then run.
  10. + +
  11. 6. Ship updates with another signed record. + A new version is another APS1 transaction referencing the same app + id, signed by the same key, pointing at a new manifest hash. Clients + already following the app see the update offered the next time they + check. Nobody can push a hostile update unless they have your key.
  12. + +
  13. 7. Withdraw a listing when it is time. + A withdrawal is a signed record that marks the app id as retired. + Clients hide the listing from browse and refuse to install it, though + anyone with the manifest hash and the binary can still side-load it — + the chain is a signal, not a lock.
  14. +
+
+ +
+

A note on endorsements

+

A listing is one signed record. An endorsement is another — a short + on-chain statement from any name saying "I vouch for this app." Readers whose + trust list includes the endorser's name will see your listing marked as + endorsed by them; readers who do not follow that name will not. Endorsements + are not reviews, and the client does not aggregate them into a star rating. + They are statements: who said what, when, signed.

+
You can endorse too. Any name can publish an endorsement + of any listing, and most publishers end up endorsing a handful of apps they + use themselves. The trust graph that emerges is the directory's real curation + layer; the categories are just filing.
+
+ +
+

What this costs

+
+
+

Per listing

+

One Bitcoin Cash transaction. Fractions of a cent at current rates. Paid + once, lives on chain.

+
+
+

Per update

+

Another transaction. Same shape, same cost. There is no "update bundle" + discount because there is no central party to collect one.

+
+
+

Per month of hosting

+

Free during the opening wave while Prometheus underwrites the storage. + Afterwards, a flat per-listing fee (planned around $1 at current rates) + covers Sia storage for the typical binary. Your own HTTPS host costs + what it costs you.

+
+
+

Per download

+

Zero to the directory. The reader pulls the binary from wherever the + manifest points; Prometheus never meters the traffic.

+
+
+
+ +
+
Where this stands today. Names, Sia hosting and + BCH signing exist and are in use. The APS1 record + format and the client-side browse/install flow are being wired. This page + describes the end state; the first live listings will go up as soon as the + submit path lands on this site and the Ariadne clients learn to read it.
+
+ +
`, + }; +} + +/* ------------------------------- browse ----------------------------- */ + +export function browse() { + return { + title: 'Browse', + description: + 'Browse apps and programs published on Prometheus by category. Signed listings on the Bitcoin Cash chain; scan reports attached; your trust list filters.', + here: '/browse', + body: ` +
+

Browse

+

Signed listings on chain, scan reports attached, your trust list + decides what to render.

+
+ +
+ +
+
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.
+ + +
+ +
`, + }; +} + +/* ------------------------------- submit ----------------------------- */ + +export function submit() { + return { + title: 'Submit an app', + description: + 'Upload an app to Prometheus. We scan it, generate the manifest and the APS1 record, you sign with your BNS key and the listing goes on chain.', + here: '/submit', + body: ` +
+

Submit an app

+

Upload, scan, sign, publish. Four screens; your key stays with you.

+
+ +
+ +
+
The submit UI is being wired right now. Upload + + scan is the next milestone (see + the publishers' walkthrough for the full flow). This + page becomes the first screen of the submit wizard once the upload endpoint + lands.
+ +
    +
  1. 1. Upload the binary. + Drag-and-drop or file picker. The server scans with ClamAV on the + way in and returns a signed scan report. Scan verdict and SHA-256 appear + on the next screen.
  2. +
  3. 2. Fill the manifest. + Name, description, category, version, icon URL. Scan verdict is + attached automatically. Preview on the right as you type.
  4. +
  5. 3. Sign with your BNS key. + The service shows the APS1 payload. Sign in Theseus (if your wallet + is there), paste a BIP-137 signature from any BCH wallet, or import your + name's mnemonic once in the page (wiped on close). The signed TX is yours + regardless of whether we broadcast it.
  6. +
  7. 4. Pay the listing fee (eventually). + During the free wave: skip. Later: a BCH invoice for the per-listing + fee at the day's rate. Service broadcasts the APS1 TX on confirmation; if + the broadcast fails you still have the signed TX and can relay it yourself.
  8. +
+
+ +
`, + }; +} + +/* ------------------------------- boundaries ------------------------- */ + +export function boundaries() { + return { + title: 'Boundaries', + description: + 'What Prometheus does and does not do. Not a curator. Not a moderator. Storage costs money. Readers decide; the chain carries signed records.', + here: '/boundaries', + body: ` +
+

Boundaries

+

What this directory is not, and why.

+
+ +
+ +
+

A permissionless app directory earns the right to call itself + permissionless by being honest about what that costs. Here is the shape of + the trade.

+
+ +
+

What Prometheus is not

+
+
+

Not a curator

+

Prometheus does not decide what is on it. Malware can be published — as + it can on any store. The scan report tells your client to warn; the + trust lists you subscribe to filter the view. If nobody you trust + endorses it, your client says so.

+
+
+

Not a moderator

+

Content that is illegal in your jurisdiction is your responsibility to + avoid, same as any other permissionless network. Silent Mode operates + infrastructure; it does not decide what runs on it.

+
+
+

Not a payment processor

+

Payments in an app go publisher-to-reader. The listing may carry a BCH + receive address or a wallet-connect URL; Prometheus never routes a + transaction. There is no refund desk because there is no middleman to + issue a refund.

+
+
+

Not an identity provider

+

The only identity on Prometheus is the BNS name a listing is signed by. + Readers decide which names count via their own trust lists. There is no + Prometheus account, no "verified publisher" badge, no sign-in flow for + readers.

+
+
+
+ +
+

What Silent Mode does control

+
+
+

The scan report

+

Our ClamAV + VirusTotal pipeline runs on every binary we host. The + signed report is attached to the listing. We cannot stop a listing from + being on chain; we can tell readers what we found.

+
+
+

Which binaries we host

+

Silent Mode's Sia bucket. Operators can refuse to host something and + require the publisher to self-host at their own URL. The listing still + exists; the scan report says we did not host it.

+
+
+

The default sort

+

What the Browse page shows first is a choice. We publish the sort order + and let clients override it. The chain holds the data; the directory is + one lens.

+
+
+

The publish mode

+

Three modes exist (append-only, scan-first-blocking, human-review). + Operators pick which one is active per category. The current mode is + always visible on this page; a change is announced before it takes + effect.

+
+
+
+ +
+

Storage costs money

+

Sia hosting is inexpensive but not free — publishers pay for the bytes + their apps take. There is no advertising model, no data harvesting, no + subscription. Storage is the only cost, and it is small. During the + opening wave Silent Mode absorbs it; afterwards it is a flat per-listing + fee paid in BCH.

+
+ +
`, + }; +} + +/* ------------------------------- 404 -------------------------------- */ + +export function notFound(req) { + return { + title: 'Not here', + description: 'The requested page does not exist on Prometheus.', + here: '', + body: ` +
+

Not here

+

Nothing lives at ${escapeHtml(req?.url || '')}.

+
+ +
+
+

Try the home page, the browse or the + publishers' walkthrough.

+
+
`, + }; +} + +/* ------------------------------- helpers ---------------------------- */ + +function escapeHtml(s) { + return String(s) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/service/server.js b/service/server.js new file mode 100644 index 0000000..7e96c37 --- /dev/null +++ b/service/server.js @@ -0,0 +1,158 @@ +/** + * Prometheus HTTP server. + * + * One `node:http` listener, a map-based router for page routes, and a tiny + * static-file handler for /static/*. No framework, no templating engine — + * the response-rendering path for a page is: look up the handler, call it, + * wrap the result in `layout()`, flush. If any step of that stops reading + * at a glance, we have gotten too clever. + * + * Dynamic routes land here later: /api/upload, /api/manifest, /api/publish, + * /api/pay, /admin/*. For now the server is only serving pages, so the + * router is only pages. + */ + +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { createReadStream, statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, normalize, resolve } from 'node:path'; +import { layout } from './layout.js'; +import * as pages from './pages.js'; + +const PORT = Number(process.env.PORT) || 3700; +const BIND = process.env.BIND || '127.0.0.1'; + +const SELF = fileURLToPath(import.meta.url); +const SERVICE_DIR = dirname(SELF); +const PUBLIC_DIR = resolve(SERVICE_DIR, '..', 'public'); + +/** + * The page router. + * + * Exact-match paths first. Patterns (category pages, listing detail) will + * attach here when the browse milestone lands. + */ +const PAGE_ROUTES = { + '/': pages.home, + '/publish': pages.publish, + '/browse': pages.browse, + '/submit': pages.submit, + '/boundaries': pages.boundaries, +}; + +/** + * Content types for static files. Deliberately short — if the server + * learns to serve a type that is not here, add it here rather than + * reaching for `mime-types`. + */ +const MIME = { + '.css': 'text/css; charset=utf-8', + '.svg': 'image/svg+xml', + '.js': 'text/javascript; charset=utf-8', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2', + '.txt': 'text/plain; charset=utf-8', +}; + +/* ============ server ============ */ + +const server = createServer(async (req, res) => { + const started = Date.now(); + const url = normalizeRequestUrl(req.url); + + try { + if (url.startsWith('/static/')) { + await serveStatic(url.slice('/static/'.length), res); + } else if (PAGE_ROUTES[url]) { + servePage(PAGE_ROUTES[url], res); + } else { + servePage(pages.notFound, res, 404, req); + } + } catch (err) { + // The response-level error log is intentionally short — a stack trace + // in the terminal is for the operator, not every reader. + console.error('[prometheus] 500', req.method, url, err?.message || err); + if (!res.headersSent) { + res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }); + } + res.end('internal error\n'); + } + + res.on('close', () => { + const ms = Date.now() - started; + console.log(`[prometheus] ${res.statusCode} ${req.method} ${url} ${ms}ms`); + }); +}); + +server.listen(PORT, BIND, () => { + console.log(`[prometheus] listening on http://${BIND}:${PORT}`); +}); + +/* ============ helpers ============ */ + +/** + * Trim query/hash, decode once, collapse any `..` to its parent. + * Returns a leading-slash path. + */ +function normalizeRequestUrl(raw) { + const [path] = String(raw || '/').split('?'); + const decoded = decodeURIComponent(path); + // normalize + leading-slash enforcement means `/foo/..//bar` → `/bar`. + // On Windows, node:path/normalize uses backslashes; convert back. + const normalized = normalize(decoded).replaceAll('\\', '/'); + return normalized.startsWith('/') ? normalized : '/' + normalized; +} + +/** + * Render a page function to the response. + */ +function servePage(fn, res, status = 200, req = null) { + const page = fn(req); + const html = layout(page); + res.writeHead(status, { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'public, max-age=60', + }); + res.end(html); +} + +/** + * Serve a file from PUBLIC_DIR by relative path. The caller passes a + * path with no leading slash and no `..`; this double-checks via + * path.resolve against PUBLIC_DIR and refuses anything that escapes. + */ +async function serveStatic(relPath, res) { + const full = resolve(PUBLIC_DIR, relPath); + if (!full.startsWith(PUBLIC_DIR + '\\') && !full.startsWith(PUBLIC_DIR + '/')) { + res.writeHead(400, { 'content-type': 'text/plain' }); + res.end('bad path\n'); + return; + } + + let st; + try { + st = statSync(full); + } catch { + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end('not found\n'); + return; + } + if (!st.isFile()) { + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end('not found\n'); + return; + } + + const ext = '.' + relPath.split('.').pop(); + const mime = MIME[ext] || 'application/octet-stream'; + res.writeHead(200, { + 'content-type': mime, + // Static assets are versioned in the URL (`?v=1`), so a long cache is + // safe and the only way to make the viewport snappy on repeat visits. + 'cache-control': 'public, max-age=31536000, immutable', + 'content-length': st.size, + }); + createReadStream(full).pipe(res); +}