diff --git a/captcha/.gitignore b/captcha/.gitignore new file mode 100644 index 00000000..849ddff3 --- /dev/null +++ b/captcha/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/captcha/README.md b/captcha/README.md new file mode 100644 index 00000000..426310c6 --- /dev/null +++ b/captcha/README.md @@ -0,0 +1,87 @@ +# Quantus Captcha + +A proof-of-work captcha whose work is a **real Quantus mining share**. +No tracking, no image puzzles, no data labeling — the visitor's device does +~1 second of Poseidon2 hashing over the current block header, and the site +host earns any block the aggregate share stream happens to find. + +Components: + +- **Pool service** (`quantus-miner/crates/pool-service`) — connects to a + `quantus-node` as an external miner, hands out low-difficulty share + challenges with disjoint nonce ranges, verifies solves, mints single-use + tokens, submits network-difficulty shares as blocks. +- **WASM solver** (`quantus-miner/crates/solver-wasm`) — raw C-ABI WASM + module (no wasm-bindgen), built with plain `cargo build`. +- **Widget** (`widget/`) — drop-in JS: checkbox UI, Web Worker solver, + hidden token input, `quan-captcha-solved` event. +- **Demo** (`demo/`) — a spam-protected form. + +## Quick start (standalone demo, no node needed) + +```sh +# 1. Build the WASM solver into dist/ +./scripts/build-solver.sh + +# 2. Run the pool in standalone mode, serving this directory +cd ../../quantus-miner +cargo run -p pool-service -- --serve-dir ../quantus-apps/captcha + +# 3. Open the demo +open http://127.0.0.1:8787/demo/ +``` + +## Against a real node + +```sh +quantus-node --dev # external-miner QUIC endpoint on :9833 +cargo run -p pool-service -- \ + --node-addr 127.0.0.1:9833 \ + --share-difficulty 2000 \ + --site-secret "$(openssl rand -hex 16)" \ + --serve-dir ../quantus-apps/captcha +``` + +## Integrating on a website + +```html +
+ +
+ +
+ +``` + +Server-side, redeem the submitted `quan-captcha-token` exactly once +(mirrors the reCAPTCHA/Turnstile siteverify shape): + +```sh +curl -X POST https://pool.example.com/siteverify \ + -H 'content-type: application/json' \ + -d '{"secret": "", "response": ""}' +# -> {"success": true, "challenge_ts": 1751600000} +``` + +## API + +| Endpoint | Caller | Purpose | +|---|---|---| +| `POST /api/session` | widget | issue challenge: header, disjoint nonce range, share target | +| `POST /api/share` | widget | verify solved nonce, mint single-use token | +| `POST /siteverify` | site backend | redeem token (secret + response) | +| `GET /api/stats` | anyone | pool counters | + +## Threat model, honestly + +- This is a **rate limiter, not sybil resistance**: it prices requests in + compute, it does not identify humans. A GPU farm pays less per share than + a phone; keep the share difficulty in "annoying to bots, invisible to + humans" territory and layer account/balance gates for high-value actions. +- Sessions are single-use, expire in 120 s, and shares are only valid inside + the session's assigned nonce range over the session's header snapshot — + no precomputation, no replay, no share theft. +- Tokens are single-use and expire in 300 s. +- Work runs only on explicit user action (Coinhive's fatal mistake was + ambient page-load mining without consent — see the plan doc in + `../debate-tree/PLAN.md`). diff --git a/captcha/demo/index.html b/captcha/demo/index.html new file mode 100644 index 00000000..d1115f6c --- /dev/null +++ b/captcha/demo/index.html @@ -0,0 +1,90 @@ + + + + + + Quantus Captcha — demo + + + +

Quantus Captcha demo

+

+ The checkbox below does ~1 second of real Poseidon2 proof-of-work over the + current Quantus block header. The work is a genuine mining share: if it + ever meets full network difficulty, the pool submits it as a block. + No tracking, no image puzzles, no data labeling. +

+ +
+
+ + + +
+
+
+ + +
+
+
+ +

+ After solving, the widget puts a single-use token in the form. A real + backend would redeem it server-side via POST /siteverify + with its secret. This demo calls it from the page for illustration only — + never expose your secret in production. +

+ + + + + diff --git a/captcha/scripts/build-solver.sh b/captcha/scripts/build-solver.sh new file mode 100755 index 00000000..c8d96ede --- /dev/null +++ b/captcha/scripts/build-solver.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Builds the WASM solver from quantus-miner and copies it into dist/. +set -euo pipefail + +CAPTCHA_DIR="$(cd "$(dirname "$0")/.." && pwd)" +MINER_DIR="${QUANTUS_MINER_DIR:-$CAPTCHA_DIR/../../quantus-miner}" + +echo "Building solver-wasm from $MINER_DIR" +(cd "$MINER_DIR" && CARGO_TARGET_DIR=target cargo build -p solver-wasm --target wasm32-unknown-unknown --release) + +mkdir -p "$CAPTCHA_DIR/dist" +cp "$MINER_DIR/target/wasm32-unknown-unknown/release/solver_wasm.wasm" "$CAPTCHA_DIR/dist/" +echo "Wrote $CAPTCHA_DIR/dist/solver_wasm.wasm ($(wc -c < "$CAPTCHA_DIR/dist/solver_wasm.wasm") bytes)" diff --git a/captcha/widget/quan-captcha.js b/captcha/widget/quan-captcha.js new file mode 100644 index 00000000..3273a93a --- /dev/null +++ b/captcha/widget/quan-captcha.js @@ -0,0 +1,179 @@ +// Quantus Captcha widget. +// +// Usage: +//
+// +// +// On solve, a hidden input named "quan-captcha-token" is added to the widget's +// enclosing form (or the widget element itself), and a "quan-captcha-solved" +// CustomEvent (detail: { token }) is dispatched on the widget element. +// The site backend then redeems the token: POST {endpoint}/siteverify +// with JSON {"secret": "...", "response": ""}. + +(function () { + "use strict"; + + const WIDGET_CLASS = "quan-captcha"; + + const STYLE = ` + .quan-captcha-box { + display: inline-flex; align-items: center; gap: 10px; + border: 1px solid #d0d5dd; border-radius: 8px; padding: 10px 14px; + font: 14px/1.4 system-ui, -apple-system, sans-serif; color: #1f2937; + background: #fff; min-width: 260px; user-select: none; + } + .quan-captcha-check { + width: 22px; height: 22px; border: 2px solid #98a2b3; border-radius: 5px; + display: inline-flex; align-items: center; justify-content: center; + cursor: pointer; flex: none; background: #fff; transition: border-color .15s; + } + .quan-captcha-box[data-state="idle"] .quan-captcha-check:hover { border-color: #2563eb; } + .quan-captcha-box[data-state="solving"] .quan-captcha-check { + border-color: #2563eb; border-top-color: transparent; border-radius: 50%; + animation: quan-captcha-spin .8s linear infinite; + } + .quan-captcha-box[data-state="solved"] .quan-captcha-check { + border-color: #16a34a; background: #16a34a; color: #fff; cursor: default; + } + .quan-captcha-box[data-state="error"] .quan-captcha-check { border-color: #dc2626; } + .quan-captcha-label { flex: 1; } + .quan-captcha-sub { display: block; font-size: 11px; color: #667085; margin-top: 1px; } + @keyframes quan-captcha-spin { to { transform: rotate(360deg); } } + `; + + function injectStyle() { + if (document.getElementById("quan-captcha-style")) return; + const style = document.createElement("style"); + style.id = "quan-captcha-style"; + style.textContent = STYLE; + document.head.appendChild(style); + } + + function formatHashrate(h) { + if (h >= 1e6) return (h / 1e6).toFixed(1) + " MH/s"; + if (h >= 1e3) return (h / 1e3).toFixed(1) + " kH/s"; + return h.toFixed(0) + " H/s"; + } + + function initWidget(el) { + if (el.dataset.quanCaptchaInit) return; + el.dataset.quanCaptchaInit = "1"; + + const endpoint = (el.dataset.endpoint || "").replace(/\/$/, ""); + const workerUrl = el.dataset.workerUrl || endpoint + "/widget/solver-worker.js"; + const wasmUrl = el.dataset.wasmUrl || endpoint + "/dist/solver_wasm.wasm"; + + const box = document.createElement("div"); + box.className = "quan-captcha-box"; + box.dataset.state = "idle"; + box.innerHTML = + '' + + 'I\'m not a spammer' + + 'Quantus proof-of-work · no tracking, no puzzles' + + ""; + el.appendChild(box); + + const check = box.querySelector(".quan-captcha-check"); + const sub = box.querySelector(".quan-captcha-sub"); + let worker = null; + const startedAt = { t: 0 }; + + function setState(state, subText) { + box.dataset.state = state; + check.setAttribute("aria-checked", state === "solved" ? "true" : "false"); + // Own the mark in both directions so error/retry states never keep a + // stale checkmark from a previous solved transition. + check.textContent = state === "solved" ? "\u2713" : ""; + if (subText) sub.textContent = subText; + } + + function fail(message) { + if (worker) { worker.terminate(); worker = null; } + setState("error", message + " — click to retry"); + } + + async function start() { + if (box.dataset.state === "solving" || box.dataset.state === "solved") return; + setState("solving", "requesting challenge…"); + try { + const res = await fetch(endpoint + "/api/session", { method: "POST" }); + if (!res.ok) throw new Error("challenge unavailable (" + res.status + ")"); + const session = await res.json(); + + setState("solving", "computing (~" + session.expected_hashes + " hashes)…"); + startedAt.t = performance.now(); + + worker = new Worker(workerUrl); + worker.onerror = () => fail("solver failed to load"); + worker.onmessage = async (e) => { + const msg = e.data; + if (msg.type === "progress") { + const secs = (performance.now() - startedAt.t) / 1000; + sub.textContent = "computing… " + formatHashrate(msg.hashes / Math.max(secs, 0.001)); + } else if (msg.type === "error") { + fail(msg.message); + } else if (msg.type === "found") { + worker.terminate(); + worker = null; + try { + const shareRes = await fetch(endpoint + "/api/share", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ session_id: session.session_id, nonce: msg.nonce }), + }); + const share = await shareRes.json(); + if (!share.success) throw new Error(share.error || "share rejected"); + + // Do all fallible tail work BEFORE showing the solved state, so + // a throw here lands in fail() without leaving solved visuals. + const form = el.closest("form") || el; + let input = form.querySelector('input[name="quan-captcha-token"]'); + if (!input) { + input = document.createElement("input"); + input.type = "hidden"; + input.name = "quan-captcha-token"; + form.appendChild(input); + } + input.value = share.token; + + const secs = ((performance.now() - startedAt.t) / 1000).toFixed(1); + setState("solved", "verified in " + secs + "s (" + msg.hashes + " hashes)" + + (share.block_found ? " — BLOCK FOUND!" : "")); + + el.dispatchEvent(new CustomEvent("quan-captcha-solved", { + bubbles: true, + detail: { token: share.token, blockFound: !!share.block_found }, + })); + } catch (err) { + fail(String(err.message || err)); + } + } + }; + worker.postMessage({ + wasmUrl: wasmUrl, + headerHash: session.header_hash, + nonceStart: session.nonce_start, + shareTarget: session.share_target, + }); + } catch (err) { + fail(String(err.message || err)); + } + } + + check.addEventListener("click", start); + check.addEventListener("keydown", (e) => { + if (e.key === " " || e.key === "Enter") { e.preventDefault(); start(); } + }); + } + + function initAll() { + injectStyle(); + document.querySelectorAll("." + WIDGET_CLASS).forEach(initWidget); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initAll); + } else { + initAll(); + } +})(); diff --git a/captcha/widget/solver-worker.js b/captcha/widget/solver-worker.js new file mode 100644 index 00000000..ebe9c1a2 --- /dev/null +++ b/captcha/widget/solver-worker.js @@ -0,0 +1,55 @@ +// Web Worker: loads the WASM solver and grinds nonces in chunks, reporting +// progress so the widget can animate. Terminated by the main thread when done. + +const CHUNK_ITERS = 2048; + +// I/O buffer layout inside the WASM module (see solver-wasm/src/lib.rs) +const HEADER_OFF = 0; +const NONCE_OFF = 32; +const TARGET_OFF = 96; +const IO_SIZE = 224; + +function hexToBytes(hex, length) { + const clean = hex.replace(/^0x/, "").padStart(length * 2, "0"); + const bytes = new Uint8Array(length); + for (let i = 0; i < length; i++) { + bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function bytesToHex(bytes) { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +self.onmessage = async (e) => { + const { wasmUrl, headerHash, nonceStart, shareTarget } = e.data; + try { + const response = await fetch(wasmUrl); + if (!response.ok) throw new Error(`failed to fetch solver wasm: ${response.status}`); + const { instance } = await WebAssembly.instantiate(await response.arrayBuffer(), {}); + const { io_ptr, io_len, solve, memory } = instance.exports; + + if (io_len() !== IO_SIZE) throw new Error("solver wasm I/O layout mismatch"); + const ioBase = io_ptr(); + const io = () => new Uint8Array(memory.buffer, ioBase, IO_SIZE); + + io().set(hexToBytes(headerHash, 32), HEADER_OFF); + io().set(hexToBytes(nonceStart, 64), NONCE_OFF); + io().set(hexToBytes(shareTarget, 64), TARGET_OFF); + + let hashes = 0; + for (;;) { + const found = solve(CHUNK_ITERS); + hashes += CHUNK_ITERS; + if (found === 1) { + const nonce = bytesToHex(io().slice(NONCE_OFF, NONCE_OFF + 64)); + self.postMessage({ type: "found", nonce, hashes }); + return; + } + self.postMessage({ type: "progress", hashes }); + } + } catch (err) { + self.postMessage({ type: "error", message: String(err && err.message ? err.message : err) }); + } +}; diff --git a/debate-tree/PLAN.md b/debate-tree/PLAN.md new file mode 100644 index 00000000..63b29723 --- /dev/null +++ b/debate-tree/PLAN.md @@ -0,0 +1,251 @@ +# Debate Tree — Project Plan + +AI-moderated structured debate for the Quantus ecosystem, spam-protected by +Quantus-native primitives (mining-share proof-of-work and QUAN balance gates). + +This plan covers three deliverables across two repos: + +| # | Component | Location | What it is | +|---|-----------|----------|------------| +| 1 | **Share Pool** (pool middleman) | `quantus-miner/pool-service` | A pool-like service that turns captcha solves into real mining shares and pays hosts | +| 2 | **Captcha Gadget** | `quantus-apps/captcha` | Embeddable widget + verify API — a drop-in Turnstile/reCAPTCHA replacement | +| 3 | **Debate Tree** | `quantus-apps/debate-tree` | The debate webapp: tree UI, AI steelman moderator, chain-gated writes | + +Dependency direction: `debate-tree` → `captcha` → `pool-service` → `quantus-node`. +Each layer is independently useful; the captcha is a product in its own right. + +--- + +## 1. Product vision + +**Debate Tree** is a structured-debate platform: a question at the root, +candidate answers below it, pros/cons under each answer, and responses to +those, recursively. (Prior art: Kialo — the tree format is proven. Our +differentiators are the AI moderator and the chain-native spam economics.) + +**The AI is a moderator on the write path, not a participant:** + +- **Deduplication** — before a new node is accepted, embeddings + a cheap LLM + pass check whether the point already exists in the tree; near-duplicates are + redirected to the existing node ("upvote / extend this instead?"). +- **Disentangle + steelman loop** — a contributor drafts a post; the AI first + splits bundled points into separate claims (one tree node = one claim), then + offers a succinct steelman of each. The contributor picks a claim, revises or + accepts (capped at ~3 rounds per claim). Only AI-generated versions are + publishable — there is no "publish my raw original" path; the contributor's + control is exercised through accept/revise, not through bypassing the + moderator. The original text stays attached (visible on click) so the + author's own words are never lost, but the tree node itself is always a + steelman the author approved. +- **The AI never rewrites imported/seeded content.** Seeded arguments cite + their sources verbatim. + +**Spam / cost protection** (also bounds our LLM spend): + +- **Reading**: free, no account, indexable. The tree is the growth asset. +- **Creating a question**: requires a signed challenge from a wallet holding + ≥ N QUAN (threshold configurable per space). Capital-at-stake, nothing + locked or slashed. +- **Posting answers/pros/cons + starting a steelman session**: requires a + proof-of-work share via the captcha gadget. Rate limiter, not identity. +- App-layer rate limits and per-user/global LLM spend caps on top. + +**Governance tie-in**: Quantus currently has no community governance lane +(the runtime's referenda are tech-collective-only) and QIPs have no +discussion venue. Debate Tree is the deliberation layer; on-chain referenda +remain the decision layer. Tree conclusions link to referenda; content +itself stays off-chain (optionally hash-anchored per published node). + +--- + +## 2. Component 1 — Share Pool (`quantus-miner/pool-service`) + +A new crate alongside `miner-service`, reusing `pow-core` hashing and the +`quantus-miner-api` types. + +**Concept**: the node's external-miner protocol already broadcasts +`NewJob { header_hash, difficulty }` and accepts `JobResult`. The pool +service sits between a node and thousands of weak browser solvers: + +``` +quantus-node ──NewJob──► pool-service ──job + nonce-range + share-target──► browser solvers +quantus-node ◄──block── pool-service ◄──────────share (nonce)──────────── browser solvers +``` + +- Holds the current job from an upstream node (QUIC, existing protocol). +- Issues **captcha sessions**: `{ header_hash, disjoint nonce range, share + target (≪ network difficulty), expiry }`. The nonce range is the session + binding — a returned nonce identifies which session earned it. Freshness + is free: shares are only valid against the current block template. +- Verifies submitted shares (one hash) and issues a single-use + **share token** consumed by the captcha verify API. +- If a share also meets full network difficulty → submit as a real block; + reward accrues to the pool operator's account. +- Tracks per-host share counts for **pro-rata (PPLNS-style) payouts** to + registered captcha hosts. Self-hosters can point their share stream at a + community pool or run solo. + +**Economics honesty** (goes in the README, not just here): expected revenue +per captcha is (client work ÷ network hashrate) × emission rate — dust once +the chain has real hashrate. Early-chain revenue is real; long-term the +honest pitch is *non-wasteful* PoW (work secures the network instead of +being burned, unlike Friendly Captcha / Anubis) plus dust. **Pay the host, +never the solver** — paying solvers pays people to spam. + +**Deliverables**: +- [x] `pool-service` crate: upstream QUIC client, session issuance API, + share verification, share-token store, block submission +- [x] `siteverify` endpoint (see Component 2 — same service, site-facing) +- [ ] Host registration + payout ledger (payouts can be manual at first) +- [ ] Metrics (reuse `metrics` crate patterns), Docker image +- [x] Integration test against `quantus-node --dev` (manual e2e: browser + solved real shares against a dev node's block headers, 2026-07-04) + +## 3. Component 2 — Captcha Gadget (`quantus-apps/captcha`) + +A drop-in, privacy-first captcha. Positioning: Turnstile's UX without +Cloudflare, Friendly Captcha / Anubis mechanics but the work is real mining. +No puzzles, no tracking, no data labeling. Coinhive's captcha proved the UX; +Coinhive's death defines our guardrails: + +- Work only on explicit user action (form submit), never ambient page-load. +- Bounded and disclosed: "~1s of computation supports this site." +- Open source, first-party-servable loader (no single CDN domain to blocklist). + +**Pieces**: +- `solver/` — Rust → WASM build of `pow-core` hashing (lives here or under + `quantus-miner/web-miner`, which already has a Vite+WebGPU scaffold; + decide when wiring the build). WebGPU fast path, WASM fallback. +- `widget/` — TS embed: `
` + + ~3 kB loader. Renders checkbox → fetches session from pool-service → + solves → posts share → emits share token into the form. +- Server-side verify: site backend calls `POST /siteverify {token, secret}` + on pool-service (mirrors reCAPTCHA/Turnstile API shape for trivial migration). +- `demo/` — demo page + abuse-cost calculator. + +**Deliverables**: +- [x] WASM solver package (`quantus-miner/crates/solver-wasm`, raw C ABI, + ~40 kB; measured ≈120 kH/s in-browser on an M-series laptop) + — WebGPU fast path still open +- [x] Embed widget + loader, Turnstile-compatible verify API +- [x] Docs: integration guide, threat model (rate-limiter not sybil-proof; + native-GPU attacker pays less per share than a phone — tune share + target accordingly), Coinhive-lessons disclosure +- [x] Demo site (`demo/`, served by pool-service `--serve-dir`) + +## 4. Component 3 — Debate Tree webapp (`quantus-apps/debate-tree`) + +**Stack** (proposed): TypeScript web frontend + backend (framework TBD at +kickoff), Postgres + pgvector (tree + embeddings), LLM API for +steelman/dedup (cheap model for loop turns, stronger model for final +published version). Wallet auth via ML-DSA signature verification — +`quantus_sdk`'s Rust bridge and `rust-transaction-parser` are references; +server-side verification can link the same Rust crates. + +**Data model** (implemented in `spike/schema.sql` — pure adjacency tree, +plain Postgres so it ports from the spike's PGlite to hosted PG verbatim): +- `space` — a debate context (e.g. "QIPs", "PQ-migration"), holds `config` + jsonb: question threshold N QUAN, share target, model tier. +- `node` — id, space, `parent_id` (null = direct answer to the question), + kind (`answer | pro | con`), `published_text` (author-approved steelman = + the node), `original_text` (verbatim, always attached), `transcript` jsonb + (negotiation provenance, inlined — no separate session table needed for + durable state), `content_hash` (optional on-chain anchoring), `embedding` + (dedup search). Walked with a recursive CTE / adjacency list; no `ltree`, + no `status`/redirect, no `node_edge` — dupes are simply not inserted. +- `vote` — (node, account, value ±1), one row per account per node. +- **Dropped for now**: `gate_proof` (consumed share-token / balance-attestation + ledger + replay guard) lands with the captcha write-path integration. + Ephemeral steelman-negotiation state lives in server memory, not the DB. + +Spike DB is **PGlite** (Postgres compiled to WASM, in-process, persisted to +`spike/pgdata/`): zero external server, same SQL as prod. `pgvector` is an +optional add-on there; when absent, `embedding` falls back to jsonb and dedup +is disabled (deferred anyway). Reset the dev tree by deleting `spike/pgdata/`. + +**Write path**: +1. Client requests action → backend issues nonce challenge. +2. Question: wallet signs `{nonce, action, timestamp}`; backend verifies + signature + balance ≥ N via node RPC / `quantus_subsquid`. + Answer/pro/con: captcha share token required to open a steelman session. +3. Dedup check (embedding similarity → LLM confirm on borderline). +4. Steelman loop (≤ 3 rounds) → contributor approves → publish. + +**Seed content — the djb hybrid-vs-pure-PQ debate**: +- Question: *"Should TLS 1.3 standardize pure ML-KEM key agreement, or + require hybrid (ECC+PQ)?"* +- Curated from the public record with per-node citations: IETF TLS WG + mailing list, djb's IESG appeals (Oct/Dec 2025), blog.cr.yp.to, LWN + coverage. **No AI paraphrasing of imported arguments** — verbatim quotes + + neutral summaries with links. +- Map the *technical* debate only; keep the process/consensus-legitimacy + fight (appeals drama) out of the seed tree. +- **Neutrality disclosure, prominent**: Quantus is a pure-PQ chain and + therefore a party to this debate. "We have a stake; here's the map; + correct us." Invite corrections before promoting it anywhere. +- Second space: QIP discussions (own community, real decisions, zero + current venue). + +**Deliverables**: +- [x] Steelman-loop spike (see §5) — disentangle + succinct steelman loop +- [x] DB-backed tree: PGlite schema, publish/tree/vote endpoints, seeded space +- [x] Tree UI (read + write): nested pro/con/answer nodes, votes, reply-to-node, + original text on click — served from the DB +- [ ] Node detail: source citations, shareable per-node links +- [ ] Wallet auth + balance gate; captcha gate integration +- [ ] Dedup + steelman write path with round caps and spend caps +- [ ] Seeded djb tree + QIP space +- [ ] Optional: per-node hash anchoring via `system.remark` (defer) + +--- + +## 5. Build order + +**Track A (start now): pool-service + captcha as ONE vertical slice.** +Neither is testable end-to-end without the other — a pool with no solver +client proves nothing, a widget with no verifier is a mock. Milestone: +demo page on a laptop solves a share against `quantus-node --dev`, verify +endpoint accepts the token, dashboard shows accrued shares. + +**Track B (parallel, cheap): steelman-loop spike.** +The single highest product risk is whether the steelman negotiation feels +respectful rather than condescending — no chain deps, just an LLM chat +loop + prompt iteration on real contentious arguments. A weekend spike; +throwaway code, keep the prompts. + +**Then: Debate Tree webapp** consuming both tracks, launching with the +seeded djb tree + QIP space. Rationale for not building the webapp first: +its write path *is* the gates + the steelman loop; building it first means +building it twice. The captcha is also independently shippable/marketable +regardless of how Debate Tree evolves. + +**Sequencing summary**: +1. Track A slice (pool + widget + demo) — Track B spike in parallel +2. Debate Tree read UI + seeded djb/QIP content (valuable even before + writes open — "the map" is the marketing artifact) +3. Debate Tree write path (gates + moderator) +4. Payouts polish, on-chain anchoring, additional spaces + +## 6. Risks + +| Risk | Mitigation | +|------|------------| +| Cryptojacking stigma / AV & adblock flagging | Consent + bounded work + first-party loader + open source; never ambient mining | +| Share revenue ≈ dust as hashrate grows | Market as non-wasteful + host-paid, not get-rich; pool aggregation for variance | +| Native-GPU spammers vs. phone users (PoW asymmetry) | Share target tuned low (rate limiter framing); balance gate for high-value actions | +| AI steelman feels condescending / voice laundering | Spike first; contributor approval required (accept/revise, no raw-publish bypass); original text always attached | +| Moderator bias becomes tree bias | Publish steelman prompts; show diff original→published | +| Quantus not neutral on the seed debate | Prominent disclosure; verbatim citations; invite corrections | +| djb reacts badly to AI paraphrase | Never AI-rewrite imported content | +| LLM spend abuse | Share token required per steelman session; round caps; per-user/global spend caps | +| Balance gate = plutocratic speech | Gate only question creation; answers need only PoW; bonds-not-balances revisit later | + +## 7. Open questions + +- Pool payout cadence/mechanism (manual → automated on-chain batch?). +- Where the WASM solver crate lives (`quantus-apps/captcha/solver` vs + `quantus-miner/web-miner`) — decide when wiring the build. +- Webapp framework + hosting; whether backend verifies ML-DSA sigs via + linked Rust crate or a small verifier sidecar. +- Per-space QUAN thresholds — governance-adjustable? fiat-pegged? +- Whether/when to anchor node hashes on-chain. diff --git a/debate-tree/spike/.gitignore b/debate-tree/spike/.gitignore new file mode 100644 index 00000000..c1f1e5be --- /dev/null +++ b/debate-tree/spike/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +pgdata/ diff --git a/debate-tree/spike/db.mjs b/debate-tree/spike/db.mjs new file mode 100644 index 00000000..1af6c325 --- /dev/null +++ b/debate-tree/spike/db.mjs @@ -0,0 +1,142 @@ +// Debate Tree spike DB — PGlite (Postgres in-process, persisted to ./pgdata). +// Same SQL runs on a hosted Postgres later; only the connection line changes. + +import { PGlite } from "@electric-sql/pglite"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const DIR = dirname(fileURLToPath(import.meta.url)); + +let db; +let hasVector = false; + +function slugify(s) { + return ( + s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60) || "space" + ); +} + +// Default seed: djb's hybrid-vs-pure-PQ TLS question, so the tree isn't empty. +const DEFAULT_SPACE = { + slug: "tls-pq-hybrid", + question: + "Should TLS 1.3 standardize pure ML-KEM key agreement, or require hybrid (ECC+PQ)?", +}; + +export async function initDb() { + // pgvector is an optional add-on for PGlite (not bundled in 0.5.x). If it's + // present we enable it and give `embedding` a real vector type for dedup; + // otherwise we fall back to jsonb so the tree still works. Dedup is deferred, + // so the fallback is expected for now — on hosted Postgres you just + // `create extension vector;` and the same schema gets vector(1024). + let extensions = {}; + try { + const { vector } = await import("@electric-sql/pglite/vector"); + extensions = { vector }; + hasVector = true; + } catch { + hasVector = false; + } + + const dataDir = process.env.PGDATA_DIR + ? join(DIR, process.env.PGDATA_DIR) + : join(DIR, "pgdata"); + db = new PGlite(dataDir, { extensions }); + await db.waitReady; + + if (hasVector) { + await db.exec("create extension if not exists vector;"); + } + + let schema = await readFile(join(DIR, "schema.sql"), "utf8"); + schema = schema.replace( + /EMBEDDING_COL/, + hasVector ? "vector(1024)" : "jsonb" + ); + await db.exec(schema); + + await getOrCreateSpace(DEFAULT_SPACE.question, DEFAULT_SPACE.slug); + console.log( + `db ready (pgvector: ${hasVector ? "on" : "off — dedup disabled"})` + ); + return db; +} + +export async function getOrCreateSpace(question, slug) { + const s = slug || slugify(question); + const existing = await db.query( + "select * from space where slug = $1 or question = $2 limit 1", + [s, question] + ); + if (existing.rows[0]) return existing.rows[0]; + const inserted = await db.query( + "insert into space (slug, question) values ($1, $2) returning *", + [s, question] + ); + return inserted.rows[0]; +} + +export async function listSpaces() { + const r = await db.query( + `select s.*, (select count(*) from node n where n.space_id = s.id)::int as node_count + from space s order by s.created_at asc` + ); + return r.rows; +} + +export async function insertNode({ + spaceId, + parentId, + kind, + authorAccount, + publishedText, + originalText, + transcript, +}) { + const r = await db.query( + `insert into node + (space_id, parent_id, kind, author_account, published_text, original_text, transcript) + values ($1, $2, $3, $4, $5, $6, $7) + returning *`, + [ + spaceId, + parentId || null, + kind, + authorAccount || "anon", + publishedText, + originalText, + transcript ? JSON.stringify(transcript) : null, + ] + ); + return r.rows[0]; +} + +// Whole tree for a space as flat rows with vote tallies. The client nests them. +export async function getTree(spaceId) { + const r = await db.query( + `select n.id, n.parent_id, n.kind, n.author_account, + n.published_text, n.original_text, n.created_at, + coalesce(sum(v.value), 0)::int as score, + count(v.*)::int as vote_count + from node n + left join vote v on v.node_id = n.id + where n.space_id = $1 + group by n.id + order by n.created_at asc`, + [spaceId] + ); + return r.rows; +} + +export async function castVote(nodeId, account, value) { + await db.query( + `insert into vote (node_id, account, value) values ($1, $2, $3) + on conflict (node_id, account) do update set value = excluded.value`, + [nodeId, account, value] + ); +} diff --git a/debate-tree/spike/index.html b/debate-tree/spike/index.html new file mode 100644 index 00000000..f3e79764 --- /dev/null +++ b/debate-tree/spike/index.html @@ -0,0 +1,451 @@ + + + + + + Steelman spike + + + +

Debate Tree — AI-moderated argument map (spike)

+ +
+

The tree

+
+
Loading…
+
+ +
+
+
+ +
+ + + + + + + + + + + + + + +
+
+ +
+ + + + diff --git a/debate-tree/spike/package-lock.json b/debate-tree/spike/package-lock.json new file mode 100644 index 00000000..26bc74a6 --- /dev/null +++ b/debate-tree/spike/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "spike", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "spike", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "^0.5.4" + } + }, + "node_modules/@electric-sql/pglite": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.4.tgz", + "integrity": "sha512-yYZUyyXrHU7tPlCjwZQJ6hIG9DscdCCn7Uk0mYKwC1FeHX286AbcmFveMiRBEak8e9iPupjsoVImN3yJZVed2g==", + "license": "Apache-2.0" + } + } +} diff --git a/debate-tree/spike/package.json b/debate-tree/spike/package.json new file mode 100644 index 00000000..88510ae1 --- /dev/null +++ b/debate-tree/spike/package.json @@ -0,0 +1,16 @@ +{ + "name": "spike", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "module", + "dependencies": { + "@electric-sql/pglite": "^0.5.4" + } +} diff --git a/debate-tree/spike/prompts.mjs b/debate-tree/spike/prompts.mjs new file mode 100644 index 00000000..f8e04f19 --- /dev/null +++ b/debate-tree/spike/prompts.mjs @@ -0,0 +1,97 @@ +// The keeper artifact of this spike: the steelman prompts. +// The code around them is throwaway; iterate on these. + +export const SYSTEM_PROMPT = `You are the moderator of a structured debate platform. A participant has +written an argument they want to add to a debate tree. Your job is to +DISENTANGLE and STEELMAN their submission. + +Step 1 — Disentangle: if the post bundles multiple independent claims +(technical + procedural, several reasons, a list of objections), split +them into separate entries in "claims". One tree node = one claim. Do not +merge distinct points into a single paragraph. A heated rant may still +contain 2–4 separable claims — extract them. + +Step 2 — Steelman each claim: for every entry, write the strongest, clearest +version of THAT specific point, which the author must recognize as theirs. + +Hard rules: +1. Never change the author's position, weaken it into agreeableness, or + add hedges they didn't imply. +2. Strengthen: sharpen the core claim, make implicit reasoning explicit, + replace insults with force of argument, cut filler. +3. SUCCINCT: each steelman is at most 2 sentences or ~45 words. Debate + trees need scannable nodes, not essays. If the author was verbose, you + compress — you do not add length. +4. Keep the author's voice: first person if they wrote in first person, + plain language. No debate-club jargon, no "one might argue". +5. Do not invent facts, sources, or examples the author didn't reference + or clearly imply. Keep their phrasing for disputed facts. +6. "label" is a 3–8 word handle for the claim (for navigation), not a + new argument. +7. If (and only if) a specific claim is genuinely ambiguous, put ONE + short clarifying question in "question" (applies to the whole submission). + +The author will review and may push back on one claim at a time. Their +feedback is authoritative — revise to match their intent, not your taste. + +Respond with ONLY a JSON object, no markdown fences: +{ + "claims": [ + { + "id": "1", + "label": "", + "steelman": "" + } + ], + "notes": "<1–3 short bullets, each starting with '- ', on splits and edits>", + "question": "" +}`; + +export function buildFirstRoundPrompt({ question, parent, stance, original }) { + return `Debate question: ${question} +${parent ? `The participant is responding to this claim: ${parent}\n` : ""}Their stance on it: ${stance} + +Their argument, verbatim: +--- +${original} +--- + +Disentangle into separate claims if needed, then steelman each succinctly.`; +} + +export function buildRevisionPrompt({ original, claim, draft, feedback, round }) { + return `This is revision round ${round} for claim "${claim.label}" (id ${claim.id}). + +Author's full original submission (context only): +--- +${original} +--- + +The claim you are revising: +--- +${claim.steelman} +--- + +Your previous draft for this claim: +--- +${draft} +--- + +The author's feedback: +--- +${feedback} +--- + +Revise ONLY this claim's steelman. Stay succinct (max 2 sentences / ~45 words). +Their feedback wins over your judgment. + +Respond with ONLY the same JSON object shape as before, containing exactly this +one revised claim, no markdown fences: +{ + "claims": [ + { "id": "${claim.id}", "label": "", "steelman": "" } + ], + "notes": "<1-3 short bullets on what you changed>", + "question": null +}`; +} diff --git a/debate-tree/spike/schema.sql b/debate-tree/spike/schema.sql new file mode 100644 index 00000000..b7a49db4 --- /dev/null +++ b/debate-tree/spike/schema.sql @@ -0,0 +1,36 @@ +-- Debate Tree — spike schema. +-- Plain Postgres (runs on PGlite in the spike, ports verbatim to hosted PG). +-- Deliberately minimal: no gate_proof (spam gate lands with the captcha), +-- no ltree (PGlite has no ltree ext; we walk the adjacency list with a +-- recursive CTE, which is plenty at spike scale). + +create table if not exists space ( + id uuid primary key default gen_random_uuid(), + slug text unique not null, + question text not null, + config jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create table if not exists node ( + id uuid primary key default gen_random_uuid(), + space_id uuid not null references space(id) on delete cascade, + parent_id uuid references node(id) on delete cascade, -- null = direct answer to the space question + kind text not null check (kind in ('answer','pro','con')), + author_account text not null default 'anon', + published_text text not null, -- author-approved steelman = the node + original_text text not null, -- verbatim submission, always attached + transcript jsonb, -- negotiation provenance + content_hash bytea, -- for optional on-chain anchoring later + embedding EMBEDDING_COL, -- dedup search (populated later); see db.mjs + created_at timestamptz not null default now() +); + +create index if not exists node_space_parent_idx on node (space_id, parent_id); + +create table if not exists vote ( + node_id uuid not null references node(id) on delete cascade, + account text not null, + value smallint not null check (value in (-1, 1)), + primary key (node_id, account) +); diff --git a/debate-tree/spike/server.mjs b/debate-tree/spike/server.mjs new file mode 100644 index 00000000..8b25cde9 --- /dev/null +++ b/debate-tree/spike/server.mjs @@ -0,0 +1,324 @@ +// Steelman-loop spike server. Throwaway code; the prompts are the artifact. +// +// node server.mjs # stub model (offline, tests the flow) +// ANTHROPIC_API_KEY=... node server.mjs +// OPENAI_API_KEY=... node server.mjs +// OPENROUTER_API_KEY=... OPENROUTER_MODEL=anthropic/claude-sonnet-4 node server.mjs +// +// Zero dependencies; serves index.html and POST /api/steelman. + +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { SYSTEM_PROMPT, buildFirstRoundPrompt, buildRevisionPrompt } from "./prompts.mjs"; +import { + initDb, + getOrCreateSpace, + listSpaces, + insertNode, + getTree, + castVote, +} from "./db.mjs"; + +const PORT = process.env.PORT || 8788; +const DIR = dirname(fileURLToPath(import.meta.url)); + +const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY; +const OPENAI_KEY = process.env.OPENAI_API_KEY; +const OPENROUTER_KEY = process.env.OPENROUTER_API_KEY; +const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || "claude-sonnet-4-5"; +const OPENAI_MODEL = process.env.OPENAI_MODEL || "gpt-5.2"; +const OPENROUTER_MODEL = process.env.OPENROUTER_MODEL || "anthropic/claude-sonnet-4"; +const OPENROUTER_REFERER = process.env.OPENROUTER_REFERER || "http://localhost:8788"; +const OPENROUTER_TITLE = process.env.OPENROUTER_TITLE || "Debate Tree steelman spike"; + +const provider = ANTHROPIC_KEY + ? "anthropic" + : OPENAI_KEY + ? "openai" + : OPENROUTER_KEY + ? "openrouter" + : "stub"; +console.log(`model provider: ${provider}${provider === "openrouter" ? ` (${OPENROUTER_MODEL})` : ""}`); + +async function callAnthropic(messages) { + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": ANTHROPIC_KEY, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: ANTHROPIC_MODEL, + max_tokens: 1024, + system: SYSTEM_PROMPT, + messages, + }), + }); + if (!res.ok) throw new Error(`anthropic ${res.status}: ${await res.text()}`); + const body = await res.json(); + return body.content.map((c) => c.text || "").join(""); +} + +async function callOpenAI(messages) { + const res = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${OPENAI_KEY}`, + }, + body: JSON.stringify({ + model: OPENAI_MODEL, + messages: [{ role: "system", content: SYSTEM_PROMPT }, ...messages], + }), + }); + if (!res.ok) throw new Error(`openai ${res.status}: ${await res.text()}`); + const body = await res.json(); + return body.choices[0].message.content; +} + +// OpenRouter exposes an OpenAI-compatible chat API; handy for swapping models +// without changing provider code. +async function callOpenRouter(messages) { + const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${OPENROUTER_KEY}`, + "HTTP-Referer": OPENROUTER_REFERER, + "X-Title": OPENROUTER_TITLE, + }, + body: JSON.stringify({ + model: OPENROUTER_MODEL, + messages: [{ role: "system", content: SYSTEM_PROMPT }, ...messages], + }), + }); + if (!res.ok) throw new Error(`openrouter ${res.status}: ${await res.text()}`); + const body = await res.json(); + return body.choices[0].message.content; +} + +// Offline stand-in: crude split on sentence boundaries + short prefix. +function callStub(messages) { + const last = messages[messages.length - 1].content; + const isRevision = /revision round/.test(last); + + if (isRevision) { + const draft = (last.match(/Your previous draft[^]*?---\n([\s\S]*?)\n---/) || [, ""])[1].trim(); + return Promise.resolve( + JSON.stringify({ + claims: [{ id: "1", label: "Revised", steelman: draft + " (revised per your feedback)" }], + notes: "- [stub model] echoed feedback", + question: null, + }) + ); + } + + const original = (last.match(/---\n([\s\S]*?)\n---/) || [, last])[1].trim(); + const sentences = original + .split(/(?<=[.!?])\s+/) + .map((s) => + s + .replace(/\b(stupid|idiotic|insane|moronic|garbage|bullshit)\b/gi, "deeply flawed") + .replace(/!+/g, ".") + .trim() + ) + .filter((s) => s.length > 20); + + const chunks = sentences.length >= 2 ? sentences.slice(0, 4) : [original]; + const claims = chunks.map((chunk, i) => ({ + id: String(i + 1), + label: `Point ${i + 1}`, + steelman: chunk.length > 120 ? chunk.slice(0, 117) + "…" : chunk, + })); + + return Promise.resolve( + JSON.stringify({ + claims, + notes: + claims.length > 1 + ? `- [stub model] split into ${claims.length} claims\n- set an API key for real disentangling` + : "- [stub model] single claim\n- set an API key for real steelmanning", + question: null, + }) + ); +} + +const callModel = + provider === "anthropic" + ? callAnthropic + : provider === "openai" + ? callOpenAI + : provider === "openrouter" + ? callOpenRouter + : callStub; + +function parseModelJson(text) { + const cleaned = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "").trim(); + const parsed = JSON.parse(cleaned); + + let claims = parsed.claims; + // Tolerate shapes a model tends to drift into, especially on revision: + // { steelman: "..." } (old single-claim shape) + // { claim: { steelman } } (singular key) + // { revised: "..." } | { text: "..." } + if (!Array.isArray(claims)) { + const single = + (parsed.claim && typeof parsed.claim === "object" && parsed.claim) || + (typeof parsed.steelman === "string" && { steelman: parsed.steelman }) || + (typeof parsed.revised === "string" && { steelman: parsed.revised }) || + (typeof parsed.text === "string" && { steelman: parsed.text }); + if (single) { + claims = [{ id: single.id ?? "1", label: single.label ?? "Main point", steelman: single.steelman }]; + } + } + if (!Array.isArray(claims) || claims.length === 0) { + throw new Error("model reply missing claims"); + } + claims = claims.map((c, i) => { + if (typeof c.steelman !== "string" || !c.steelman.trim()) { + throw new Error(`claim ${i + 1} missing steelman`); + } + return { + id: String(c.id ?? i + 1), + label: typeof c.label === "string" && c.label.trim() ? c.label.trim() : `Point ${i + 1}`, + steelman: c.steelman.trim(), + }; + }); + + return { + claims, + notes: typeof parsed.notes === "string" ? parsed.notes : "", + question: typeof parsed.question === "string" ? parsed.question : null, + }; +} + +async function handleSteelman(req, res) { + let raw = ""; + for await (const chunk of req) raw += chunk; + const body = JSON.parse(raw); + + // Rebuild the conversation from the client-held transcript. rounds is + // [{draft, feedback}, ...] for completed rounds. + const messages = [ + { role: "user", content: buildFirstRoundPrompt(body) }, + ]; + (body.rounds || []).forEach((r, i) => { + messages.push({ + role: "assistant", + content: JSON.stringify({ + claims: [{ id: r.claimId, label: r.claimLabel, steelman: r.draft }], + }), + }); + messages.push({ + role: "user", + content: buildRevisionPrompt({ + original: body.original, + claim: { id: r.claimId, label: r.claimLabel, steelman: r.draft }, + draft: r.draft, + feedback: r.feedback, + round: i + 1, + }), + }); + }); + + const reply = await callModel(messages); + const parsed = parseModelJson(reply); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(parsed)); +} + +async function readBody(req) { + let raw = ""; + for await (const chunk of req) raw += chunk; + return raw ? JSON.parse(raw) : {}; +} + +function sendJson(res, status, obj) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(obj)); +} + +// GET /api/tree?space= — space row + flat node list. +async function handleTree(req, res, url) { + const key = url.searchParams.get("space") || ""; + const question = url.searchParams.get("question") || key; + const space = await getOrCreateSpace(question, /\s/.test(key) ? undefined : key); + const nodes = await getTree(space.id); + sendJson(res, 200, { space, nodes }); +} + +async function handleSpaces(_req, res) { + sendJson(res, 200, { spaces: await listSpaces() }); +} + +// POST /api/publish — persist an author-approved steelman as a tree node. +// parent_id null => 'answer' (direct to the question); otherwise pro/con by stance. +async function handlePublish(req, res) { + const b = await readBody(req); + if (!b.publishedText || !b.originalText || !b.question) { + return sendJson(res, 400, { error: "question, publishedText, originalText required" }); + } + const space = await getOrCreateSpace(b.question, b.spaceSlug); + const kind = !b.parentId + ? "answer" + : /oppos|con/i.test(b.stance || "") + ? "con" + : "pro"; + const node = await insertNode({ + spaceId: space.id, + parentId: b.parentId, + kind, + authorAccount: b.authorAccount, + publishedText: b.publishedText, + originalText: b.originalText, + transcript: b.transcript, + }); + sendJson(res, 200, { node, space }); +} + +async function handleVote(req, res) { + const b = await readBody(req); + if (!b.nodeId || ![1, -1].includes(b.value)) { + return sendJson(res, 400, { error: "nodeId and value (1|-1) required" }); + } + await castVote(b.nodeId, b.account || "anon", b.value); + sendJson(res, 200, { ok: true }); +} + +const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, `http://${req.headers.host}`); + if (req.method === "POST" && url.pathname === "/api/steelman") { + return await handleSteelman(req, res); + } + if (req.method === "POST" && url.pathname === "/api/publish") { + return await handlePublish(req, res); + } + if (req.method === "POST" && url.pathname === "/api/vote") { + return await handleVote(req, res); + } + if (req.method === "GET" && url.pathname === "/api/tree") { + return await handleTree(req, res, url); + } + if (req.method === "GET" && url.pathname === "/api/spaces") { + return await handleSpaces(req, res); + } + if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) { + const html = await readFile(join(DIR, "index.html")); + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + return res.end(html); + } + res.writeHead(404); + res.end("not found"); + } catch (err) { + console.error(err); + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: String(err.message || err) })); + } +}); + +await initDb(); +server.listen(PORT, () => console.log(`steelman spike: http://127.0.0.1:${PORT}/`));