From 2d41b082d7b7a42d8d310b34ead03755ec65c7c2 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Fri, 11 Sep 2026 02:11:42 +0000 Subject: [PATCH] feat(dojobay): add DojoBay app (manifest, image, catalog, ports) --- app-catalog/catalog.json | 21 + apps/PORTS.md | 1 + apps/dojobay/manifest.yml | 106 + assets/img/app-icons/dojobay.svg | 22 + core/archipelago/src/fips/app_ports.rs | 40 +- core/container/src/manifest.rs | 13 + docker/dojobay/.gitignore | 6 + docker/dojobay/Dockerfile | 43 + docker/dojobay/assets/css/styles.css | 393 +++ docker/dojobay/assets/fonts/archivo.woff2 | Bin 0 -> 34928 bytes .../dojobay/assets/fonts/hanken-grotesk.woff2 | Bin 0 -> 34704 bytes .../dojobay/assets/fonts/jetbrains-mono.woff2 | Bin 0 -> 40404 bytes docker/dojobay/assets/icons/192x192.png | Bin 0 -> 13172 bytes docker/dojobay/assets/icons/512x512.png | Bin 0 -> 25929 bytes docker/dojobay/assets/js/app.js | 1803 +++++++++++++ docker/dojobay/assets/js/markdown.js | 109 + docker/dojobay/assets/js/qrcode.js | 2297 +++++++++++++++++ docker/dojobay/content/about.md | 15 + docker/dojobay/content/faq.md | 59 + docker/dojobay/data-template/dojos.json | 1 + .../dojobay/data-template/history-daily.json | 4 + docker/dojobay/data-template/history.json | 6 + .../dojobay/data-template/paynym-codes.json | 5 + docker/dojobay/data-template/seed.json | 3 + docker/dojobay/data-template/version.json | 4 + docker/dojobay/entrypoint.sh | 80 + docker/dojobay/favicon.svg | 17 + docker/dojobay/index.html | 57 + docker/dojobay/manifest.json | 30 + docker/dojobay/nginx.conf | 72 + docker/dojobay/scripts/bootstrap-import.mjs | 317 +++ .../dojobay/scripts/migrate-seed-to-store.mjs | 161 ++ docker/dojobay/scripts/pack-source.mjs | 138 + docker/dojobay/scripts/update.mjs | 806 ++++++ docker/dojobay/server/admin.mjs | 53 + docker/dojobay/server/apply-signed-payload.ts | 181 ++ docker/dojobay/server/audit-signed.mjs | 111 + docker/dojobay/server/build-public.mjs | 39 + docker/dojobay/server/build-public.ts | 379 +++ docker/dojobay/server/check-resources.ts | 235 ++ docker/dojobay/server/check-versions.ts | 82 + docker/dojobay/server/crypto.ts | 429 +++ docker/dojobay/server/diagnose-signed.mjs | 119 + docker/dojobay/server/dns.ts | 182 ++ docker/dojobay/server/dojo-version.ts | 162 ++ docker/dojobay/server/domains.ts | 160 ++ docker/dojobay/server/fix-payload-version.mjs | 124 + docker/dojobay/server/index.mjs | 25 + docker/dojobay/server/index.ts | 1202 +++++++++ docker/dojobay/server/package-lock.json | 260 ++ docker/dojobay/server/package.json | 24 + docker/dojobay/server/paynym.mjs | 95 + docker/dojobay/server/probe.mjs | 10 + docker/dojobay/server/remove-listing.ts | 129 + docker/dojobay/server/selftest.mjs | 2027 +++++++++++++++ docker/dojobay/server/store.ts | 224 ++ docker/dojobay/server/updates.mjs | 236 ++ docker/dojobay/sw.js | 75 + docker/dojobay/types.d.ts | 148 ++ neode-ui/public/catalog.json | 21 + .../appSession/generatedAppSessionConfig.ts | 2 + 61 files changed, 13360 insertions(+), 3 deletions(-) create mode 100644 apps/dojobay/manifest.yml create mode 100644 assets/img/app-icons/dojobay.svg create mode 100644 docker/dojobay/.gitignore create mode 100644 docker/dojobay/Dockerfile create mode 100644 docker/dojobay/assets/css/styles.css create mode 100644 docker/dojobay/assets/fonts/archivo.woff2 create mode 100644 docker/dojobay/assets/fonts/hanken-grotesk.woff2 create mode 100644 docker/dojobay/assets/fonts/jetbrains-mono.woff2 create mode 100644 docker/dojobay/assets/icons/192x192.png create mode 100644 docker/dojobay/assets/icons/512x512.png create mode 100644 docker/dojobay/assets/js/app.js create mode 100644 docker/dojobay/assets/js/markdown.js create mode 100644 docker/dojobay/assets/js/qrcode.js create mode 100644 docker/dojobay/content/about.md create mode 100644 docker/dojobay/content/faq.md create mode 100644 docker/dojobay/data-template/dojos.json create mode 100644 docker/dojobay/data-template/history-daily.json create mode 100644 docker/dojobay/data-template/history.json create mode 100644 docker/dojobay/data-template/paynym-codes.json create mode 100644 docker/dojobay/data-template/seed.json create mode 100644 docker/dojobay/data-template/version.json create mode 100755 docker/dojobay/entrypoint.sh create mode 100644 docker/dojobay/favicon.svg create mode 100644 docker/dojobay/index.html create mode 100644 docker/dojobay/manifest.json create mode 100644 docker/dojobay/nginx.conf create mode 100644 docker/dojobay/scripts/bootstrap-import.mjs create mode 100644 docker/dojobay/scripts/migrate-seed-to-store.mjs create mode 100644 docker/dojobay/scripts/pack-source.mjs create mode 100644 docker/dojobay/scripts/update.mjs create mode 100644 docker/dojobay/server/admin.mjs create mode 100644 docker/dojobay/server/apply-signed-payload.ts create mode 100644 docker/dojobay/server/audit-signed.mjs create mode 100644 docker/dojobay/server/build-public.mjs create mode 100644 docker/dojobay/server/build-public.ts create mode 100644 docker/dojobay/server/check-resources.ts create mode 100644 docker/dojobay/server/check-versions.ts create mode 100644 docker/dojobay/server/crypto.ts create mode 100644 docker/dojobay/server/diagnose-signed.mjs create mode 100644 docker/dojobay/server/dns.ts create mode 100644 docker/dojobay/server/dojo-version.ts create mode 100644 docker/dojobay/server/domains.ts create mode 100644 docker/dojobay/server/fix-payload-version.mjs create mode 100644 docker/dojobay/server/index.mjs create mode 100644 docker/dojobay/server/index.ts create mode 100644 docker/dojobay/server/package-lock.json create mode 100644 docker/dojobay/server/package.json create mode 100644 docker/dojobay/server/paynym.mjs create mode 100644 docker/dojobay/server/probe.mjs create mode 100644 docker/dojobay/server/remove-listing.ts create mode 100644 docker/dojobay/server/selftest.mjs create mode 100644 docker/dojobay/server/store.ts create mode 100644 docker/dojobay/server/updates.mjs create mode 100644 docker/dojobay/sw.js create mode 100644 docker/dojobay/types.d.ts diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json index 463eb113..e7e28b62 100644 --- a/app-catalog/catalog.json +++ b/app-catalog/catalog.json @@ -619,6 +619,27 @@ "/var/lib/archipelago/vaultwarden:/data" ] } + }, + { + "id": "dojobay", + "title": "Dojo Bay", + "version": "1.0.0", + "description": "Onion-only directory of public Bitcoin Dojo nodes for Samourai, Ashigaru and Sentinel wallets, with Auth47 self-service listings.", + "icon": "/assets/img/app-icons/dojobay.svg", + "author": "Dojobay", + "category": "money", + "dockerImage": "localhost/archipelago-dojobay:1.0.0", + "repoUrl": "https://github.com/Dojobay/dojobay", + "containerConfig": { + "ports": [ + "8188:8080" + ], + "volumes": [ + "/var/lib/archipelago/dojobay/data:/app/data", + "/var/lib/archipelago/dojobay/server-data:/app/server/data" + ] + }, + "tier": "optional" } ] } diff --git a/apps/PORTS.md b/apps/PORTS.md index d3fc4f0f..b7dc833c 100644 --- a/apps/PORTS.md +++ b/apps/PORTS.md @@ -25,6 +25,7 @@ This document lists all port assignments for Archipelago apps. | did-wallet | 8083 | TCP | Web UI | 18083 | | router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 | | meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 | +| dojobay | 8188 | TCP | Web UI | 18188 | ## Development Ports (Offset: +10000) diff --git a/apps/dojobay/manifest.yml b/apps/dojobay/manifest.yml new file mode 100644 index 00000000..e14890b2 --- /dev/null +++ b/apps/dojobay/manifest.yml @@ -0,0 +1,106 @@ +app: + id: dojobay + name: Dojo Bay + version: 1.0.0 + upstream: + kind: github + repo: Dojobay/dojobay + description: Onion-only directory of public Bitcoin Dojo nodes for Samourai, Ashigaru and Sentinel wallets, with Auth47 self-service listings. + category: money + + container: + build: + context: /opt/archipelago/docker/dojobay + dockerfile: Dockerfile + tag: localhost/archipelago-dojobay:1.0.0 + network: archy-net + + dependencies: + - storage: 200Mi + + resources: + cpu_limit: 1 + memory_limit: 256Mi + disk_limit: 500Mi + + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + network_policy: bridge + + ports: + - host: 8188 + container: 8080 + protocol: tcp + bind: 127.0.0.1 + # open, not gated: Dojo Bay is a public directory. Anonymous Tor + # visitors must be able to browse listings, scan pairing QR codes and + # read the JSON data feed without a dashboard login challenge — that is + # the entire point of the site. It carries its own complete Auth47 + # sign-in (BIP47 payment-code challenge, no accounts/passwords) that + # gates listing management and the admin/moderation console, the same + # shape Gitea and BTCPay use this policy for. + auth: open + auth_rationale: >- + Public onion directory: anonymous visitors must browse, pair and fetch + the JSON feed with no dashboard login. Listing management and admin + moderation are behind the app's own Auth47 (BIP47) sign-in instead. + + volumes: + - type: bind + source: /var/lib/archipelago/dojobay/data + target: /app/data + options: [rw] + - type: bind + source: /var/lib/archipelago/dojobay/server-data + target: /app/server/data + options: [rw] + # nginx's own working files (pid, client-body/proxy temp dirs). Not + # persistent data — recreated on every start — hence tmpfs rather than a + # bind mount, and required at all only because security.readonly_root + # makes the rest of the image's filesystem read-only at runtime. + - type: tmpfs + target: /var/lib/nginx + - type: tmpfs + target: /var/run + tmpfs_options: "rw,noexec,nosuid,size=16m" + + files: + # Archipelago's Tor daemon binds a second SocksPort on this network's + # bridge gateway specifically so containers can reach it (the app itself + # cannot resolve {{NETWORK_GATEWAY}} — only a generated file can, per + # docs/app-developer-guide.md). Must sit under a declared bind-mount + # source, hence co-located with the data volume above; the container + # entrypoint reads it and points the backend's outbound Tor at it. + - path: /var/lib/archipelago/dojobay/data/tor-proxy.conf + content: "{{NETWORK_GATEWAY}}:9050" + overwrite: true + + health_check: + type: http + endpoint: http://localhost:8080 + path: / + interval: 30s + timeout: 5s + retries: 3 + + interfaces: + main: + name: Web UI + description: Dojo Bay directory + type: ui + port: 8188 + protocol: http + path: / + + metadata: + icon: /assets/img/app-icons/dojobay.svg + repo: https://github.com/Dojobay/dojobay + tier: optional + launch: + open_in_new_tab: false + features: + - Onion-only directory of Bitcoin Dojo nodes + - Auth47 self-service listings, no accounts or passwords + - Automatic 24-hour and 90-day reliability tracking diff --git a/assets/img/app-icons/dojobay.svg b/assets/img/app-icons/dojobay.svg new file mode 100644 index 00000000..827b6588 --- /dev/null +++ b/assets/img/app-icons/dojobay.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/archipelago/src/fips/app_ports.rs b/core/archipelago/src/fips/app_ports.rs index acd45e1a..0e4a0674 100644 --- a/core/archipelago/src/fips/app_ports.rs +++ b/core/archipelago/src/fips/app_ports.rs @@ -6,7 +6,41 @@ //! no listener, so allowing them is inert. pub const APP_LAUNCH_PORTS: &[u16] = &[ - 2283, 2342, 3000, 3001, 3002, 3030, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, - 8090, 8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380, 11434, - 18081, 18083, 23000, 32838, 50002, + 2283, + 2342, + 3000, + 3001, + 3002, + 3030, + 4080, + 5180, + 7778, + 8080, + 8081, + 8082, + 8083, + 8084, + 8085, + 8087, + 8090, + 8096, + 8123, + 8175, + 8176, + 8187, + 8188, + 8240, + 8334, + 8336, + 8888, + 8999, + 9000, + 9100, + 10380, + 11434, + 18081, + 18083, + 23000, + 32838, + 50002, ]; diff --git a/core/container/src/manifest.rs b/core/container/src/manifest.rs index aa9cf0cd..89470ba8 100644 --- a/core/container/src/manifest.rs +++ b/core/container/src/manifest.rs @@ -1814,11 +1814,24 @@ app: // (tailnet login on the web console), adguardhome 3000 (AGH admin // accounts + first-run wizard). All enforce their own login, and an // operator can re-gate any of them from Settings → Access control. + // + // dojobay 8188, added for the Dojo Bay app: a public onion directory + // that anonymous Tor visitors must be able to browse with no + // dashboard login; its own Auth47 (BIP47 payment-code challenge) + // gates listing management and the admin console. + // + // NOTE: as of this change, `left` also carries two entries this + // assertion does not yet list — adguardhome at 3030 (not the 3000 + // hardcoded below) and cuprate at 18090 — both pre-existing drift + // from before this change, not introduced by it. Left for whoever + // owns those apps to reconcile; not touched here to keep this diff to + // the dojobay addition. assert_eq!( open, vec![ ("adguardhome".to_string(), 3000u16), ("btcpay-server".to_string(), 23000u16), + ("dojobay".to_string(), 8188u16), ("gitea".to_string(), 3001u16), ("nginx-proxy-manager".to_string(), 8081u16), ("tailscale".to_string(), 8240u16), diff --git a/docker/dojobay/.gitignore b/docker/dojobay/.gitignore new file mode 100644 index 00000000..eafb3f46 --- /dev/null +++ b/docker/dojobay/.gitignore @@ -0,0 +1,6 @@ +# Local test artifacts only — the shipped image seeds data/ and server/data/ +# from data-template/ at container start (see entrypoint.sh); nothing real +# belongs in this build context. +server/node_modules/ +data/ +server/data/ diff --git a/docker/dojobay/Dockerfile b/docker/dojobay/Dockerfile new file mode 100644 index 00000000..1c64bdab --- /dev/null +++ b/docker/dojobay/Dockerfile @@ -0,0 +1,43 @@ +# Dojo Bay, packaged as an Archipelago app. +# +# Node 24 is required: the backend runs .ts directly via Node's type-stripping, +# and its BIP47 libraries need it too (see the upstream project's README). +# nginx serves the static directory site and proxies /api/ to the Node +# backend in the same container — see nginx.conf for why both live here +# instead of relying on a systemd pair the way the standalone deploy did. +# +# Runs fully rootless: no `user` directive in nginx.conf, so nginx's master +# and worker processes just inherit whatever UID started them (dojobay, +# below) — no privilege to drop, none ever held. +FROM node:24-alpine AS deps +WORKDIR /app/server +COPY server/package.json server/package-lock.json ./ +RUN npm ci --omit=dev + +FROM node:24-alpine +RUN apk add --no-cache nginx tini \ + && addgroup -S dojobay && adduser -S dojobay -G dojobay + +WORKDIR /app +COPY --from=deps /app/server/node_modules /app/server/node_modules +COPY server/ /app/server/ +COPY scripts/ /app/scripts/ +COPY assets/ /app/assets/ +COPY content/ /app/content/ +COPY types.d.ts /app/types.d.ts +COPY index.html favicon.svg manifest.json sw.js /app/ +COPY data-template/ /app/data-template/ +COPY nginx.conf /etc/nginx/nginx.conf +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh \ + && mkdir -p /app/data /app/server/data \ + && chown -R dojobay:dojobay /app \ + && chown -R dojobay:dojobay /var/lib/nginx /var/log/nginx /run + +USER dojobay:dojobay +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD wget -q -O- http://127.0.0.1:8080/ >/dev/null || exit 1 + +# tini reaps the two children (node + nginx) and forwards signals cleanly. +ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"] diff --git a/docker/dojobay/assets/css/styles.css b/docker/dojobay/assets/css/styles.css new file mode 100644 index 00000000..d8af80fb --- /dev/null +++ b/docker/dojobay/assets/css/styles.css @@ -0,0 +1,393 @@ +/* Self-hosted variable fonts (latin subset). No external CDN. */ +@font-face{font-family:'Archivo';font-style:normal;font-weight:100 900;font-display:swap;src:url('../fonts/archivo.woff2') format('woff2')} +@font-face{font-family:'Hanken Grotesk';font-style:normal;font-weight:100 900;font-display:swap;src:url('../fonts/hanken-grotesk.woff2') format('woff2')} +@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:100 800;font-display:swap;src:url('../fonts/jetbrains-mono.woff2') format('woff2')} + +:root{ + --bg:#0a0a0a; --panel:#141414; --panel2:#1c1c1c; --line:#2a2a2a; --line-soft:#1c1c1c; + --text:#f4f4f3; --muted:#a0a0a0; --faint:#6b6b6b; + --accent:#b5302a; --accent-2:#d6534a; --accent-bg:rgba(181,48,42,.12); --accent-line:rgba(181,48,42,.34); + --btc:#f7931a; --btc-text:#1a1206; --grey-sel:#8a8a8a; + --up:#3fb950; --up-bg:rgba(63,185,80,.14); --down:#d6584f; --down-dim:#5a3330; + /* Every use of this was written as var(--warn,#e0a020) against a token that + was never declared, so the fallback always won. Declared here so the + amber is adjustable in one place; --mid is the 90-day middle band, which + was a bare hex literal for the same reason. */ + --warn:#e0a020; --mid:#b9a13a; + /* Same class of bug, found by auditing every var() reference against the + declarations: .admin-row asked for --card and had been taking #0e0e10 by + fallback since it was written. Declared at that value rather than at + --panel, so nothing changes appearance; whether the admin rows were meant + to sit a shade darker than the cards they resemble is a separate + question, and not one to answer by accident a second time. */ + --card:#0e0e10; + --code-bg:#070707; --code-fg:#e6a39b; + } + *{box-sizing:border-box;margin:0;padding:0} + html,body{background:var(--bg);color:var(--text)} + body{font-family:'Hanken Grotesk',system-ui,sans-serif;line-height:1.5;-webkit-font-smoothing:antialiased} + .mono{font-family:'JetBrains Mono',ui-monospace,monospace} + .disp{font-family:'Archivo',sans-serif} + a{color:inherit;text-decoration:none} + button{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit} + .wrap{max-width:1120px;margin:0 auto;padding:0 22px} + .eyebrow{font-family:'JetBrains Mono',monospace;font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--faint)} + :focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:3px} + + + header{border-bottom:1px solid var(--line-soft);position:sticky;top:0;background:rgba(11,11,12,.86);backdrop-filter:blur(8px);z-index:20} + header .wrap{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:16px 22px} + .brand{display:flex;align-items:center;gap:12px} + .brand .name{font-weight:800;font-size:18px;letter-spacing:-.01em} + .brand .sub{font-size:10.5px;color:var(--faint);margin-top:1px;letter-spacing:.04em} + nav{display:flex;gap:6px;align-items:center} + nav .lnk{color:var(--muted);font-size:14px;padding:7px 11px;border-radius:7px;transition:color .15s,background .15s} + nav .lnk:hover{color:var(--text);background:var(--panel)} + .onion-pill{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--accent);border:1px solid var(--accent-line);background:var(--accent-bg);padding:6px 10px;border-radius:7px;margin-left:4px} + .onion-pill:hover{background:rgba(247,147,26,.16)} + .burger{display:none;background:none;border:0;color:var(--text);padding:6px;cursor:pointer;border-radius:7px} + /* The Auth47 challenge, shown under its QR. Wraps anywhere because it is one + unbroken token, and carries its own copy button for the same-device case. */ + /* Column, always, at every width. Side by side the button lands wherever the + URI happens to stop wrapping, so it sits mid-line on one screen and below + on another, and on the narrow case it crowds the text it belongs to. The + URI is a single unbroken token that has to wrap anyway, so there is no + width at which a row reads better. */ + .a47-uri{display:flex;flex-direction:column;align-items:center;gap:10px; + margin-top:10px;text-align:left} + .a47-uri code{font-size:10.5px;color:var(--faint);word-break:break-all;line-height:1.5; + max-width:44ch;width:100%} + .a47-uri .copybtn{align-self:center} + .upd-line{margin:6px 0} + /* Self-update has never completed a run on real hardware. The badge is not + decoration: a maintainer clicking Update from GitHub is the first person + who will find out whether it works, and should know that before clicking. */ + .upd-exp{display:inline-block;font-size:10px;letter-spacing:.08em;text-transform:uppercase; + font-family:'JetBrains Mono',monospace;color:var(--warn,#e0a020); + border:1px solid rgba(224,160,32,.45);background:rgba(224,160,32,.10); + border-radius:5px;padding:1px 6px;margin-left:8px;vertical-align:1px} + /* Full width. It was capped at 62ch, which is right for prose a reader is + settling into and wrong for a warning beside the control it warns about: + it left the paragraph as a narrow column against a wide panel, and the + ragged right edge read as a layout fault rather than as deliberate + measure. */ + .upd-exp-note{font-size:11.5px;color:var(--faint);line-height:1.55;margin:8px 0 0;width:100%} + .upd-none{font-size:11.5px;color:var(--faint);margin:6px 0 0} + /* An update that did not finish. Warning-coloured rather than faint, because + the failure it describes is invisible everywhere else: the code is on disk, + the footer already shows the new build, and only the process serving the + page is stale. */ + .upd-warn{font-size:12px;line-height:1.55;margin:8px 0 0;padding:9px 11px;border-radius:7px; + color:#e9d6d2;background:rgba(214,88,79,.10);border:1px solid rgba(214,88,79,.45)} + .upd-warn b{color:var(--down)} + .upd-controls{display:flex;gap:8px;align-items:center;margin-top:6px;flex-wrap:wrap} + .upd-bar{height:8px;border-radius:6px;background:var(--panel2);overflow:hidden;margin:8px 0} + .upd-bar-fill{height:100%;transition:width .4s ease;border-radius:6px} + .upd-log{font-size:11px;color:var(--faint);background:var(--panel2);border-radius:8px;padding:8px 10px;margin:6px 0;white-space:pre-wrap;line-height:1.5;max-height:120px;overflow:auto} + /* The import plan. Refused rows are coloured rather than hidden: a directory + publishing listings this instance will not accept is the most informative + thing on the table, and collapsing it to a count would bury it. */ + table.imp{width:100%;border-collapse:collapse;font-size:12px;margin:8px 0} + table.imp th{text-align:left;font-weight:600;color:var(--faint);font-size:11px; + padding:4px 8px 4px 0;border-bottom:1px solid var(--line-soft)} + table.imp td{padding:5px 8px 5px 0;border-bottom:1px solid var(--line-soft);vertical-align:top} + tr.imp-merge td{color:var(--muted)} + tr.imp-refuse td{color:var(--down)} + .op-avatar{width:20px;height:20px;border-radius:50%;object-fit:cover; + border:1px solid var(--line-soft);display:inline-block;vertical-align:middle} + /* PayNym avatar centred on the pairing QR (QR is generated at EC level H, + so the ~5% of symbol area the avatar covers is well within recovery) */ + .tile{position:relative} + .qr-avatar{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%); + width:21%;height:21%;object-fit:cover;border-radius:8px; + border:3px solid #fff;background:#fff} + /* payment code chip on cards: truncated, click copies the full code */ + /* The payment code owns its own line and spans the card, so it reads as the + identity of the listing rather than one chip among several. The verified + domain and its verify button sit on the line beneath. */ + .pcode{display:block;width:100%;text-align:left;margin:2px 0 8px;padding:5px 11px;font-size:11.5px; + letter-spacing:.02em;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap; + background:var(--panel2);border:1px solid var(--line-soft);border-radius:8px;cursor:pointer} + .pcode:hover{color:var(--text);border-color:var(--accent)} + .pcode.done{color:var(--up)} + /* inline display-field editor (Manage rows and admin rows) */ + .medit{margin-top:10px;padding-top:10px;border-top:1px solid var(--line-soft);display:flex;flex-direction:column;gap:8px} + .medit label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--muted)} + .medit input{background:var(--panel);border:1px solid var(--line-soft);border-radius:7px;color:var(--text); + padding:7px 9px;font-size:13px;font-family:inherit} + .medit input:focus{outline:none;border-color:var(--accent)} + .medit-actions{display:flex;gap:8px;align-items:center} + .copybtn[disabled],.abtn[disabled]{opacity:.4;cursor:default} + .burger:hover{background:var(--panel)} + + .controls{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px;padding:30px 22px 18px} + .seg{display:inline-flex;background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:3px} + .seg button{font-family:'JetBrains Mono',monospace;font-size:12px;padding:8px 18px;border-radius:7px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);transition:background .15s,color .15s} + .seg button.on{color:#0a0a0a;font-weight:700} + .seg button[data-net="mainnet"].on{background:var(--btc);color:var(--btc-text)} + .seg button[data-net="testnet"].on{background:var(--grey-sel);color:#0a0a0a} + .fresh{font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:9px;flex-wrap:wrap} + .fresh .dot{width:7px;height:7px;border-radius:99px;background:var(--up);display:inline-block;box-shadow:0 0 0 3px var(--up-bg)} + .fresh b{color:var(--text);font-weight:700} + .fresh .sep{color:var(--faint)} + + .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px} + .card{background:var(--panel);border:1px solid var(--line-soft);border-radius:12px;padding:18px;transition:border-color .18s,transform .18s} + .card:hover{border-color:var(--line);transform:translateY(-2px)} + .card.inactive{opacity:.74} + .ctop{display:flex;align-items:center;gap:10px} + .ctop .sd{width:9px;height:9px;border-radius:99px;flex-shrink:0} + .sd.active{background:var(--up);box-shadow:0 0 0 3px var(--up-bg)} + .sd.inactive{background:var(--down);box-shadow:0 0 0 3px rgba(214,88,79,.14)} + .cname{font-family:'Archivo',sans-serif;font-weight:700;font-size:16px;letter-spacing:-.01em;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + a.cname{transition:color .15s} + a.cname:hover{color:var(--accent)} + a.cname .ext{font-size:11px;color:var(--faint);vertical-align:middle} + a.cname:hover .ext{color:var(--accent)} + .cbadge{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.08em;text-transform:uppercase;padding:3px 8px;border-radius:5px;font-weight:700;flex-shrink:0} + /* Stale data: the updater has not refreshed dojos.json for several intervals, + so we stop asserting status. Badges go neutral rather than green or red, + because "unknown" is the honest answer, not "down". */ + /* The empty directory. Deliberately quiet: a bordered panel rather than a + warning colour, because an instance with nothing published yet is usually + new rather than broken. */ + .empty{border:1px dashed var(--line);border-radius:10px;padding:26px 22px;text-align:center; + color:var(--muted);font-size:14px;line-height:1.65;margin:0 0 18px} + .empty b{color:var(--text)} + .empty-cta{margin-top:8px;font-size:13px;color:var(--faint)} + /* The Tor port picker. Two presets, because there are two answers in practice + and a free-text field would invite typos into the one value that has to be + right for any of the commands below it to work. */ + .portpick{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0 14px} + .portpick .k{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:.06em; + text-transform:uppercase;color:var(--faint)} + .pbtn{font:inherit;font-family:'JetBrains Mono',monospace;font-size:13px;padding:6px 11px; + border-radius:7px;border:1px solid var(--line);background:transparent;color:var(--muted);cursor:pointer} + .pbtn .w{display:block;font-family:'Hanken Grotesk',sans-serif;font-size:10.5px;color:var(--faint); + letter-spacing:0;text-transform:none;margin-top:1px} + .pbtn:hover{border-color:var(--line-soft);color:var(--text)} + .pbtn.on{border-color:var(--accent);color:var(--accent-2);background:rgba(181,48,42,.10)} + .pbtn.on .w{color:var(--accent-2)} + .stale-banner{margin:0 0 18px;padding:12px 14px;border-radius:8px;font-size:13.5px;line-height:1.6; + color:var(--text);background:rgba(214,88,79,.10);border:1px solid rgba(214,88,79,.35)} + .stale-banner b{color:var(--down)} + .grid.stale .sd.active,.grid.stale .sd.inactive{background:var(--faint);box-shadow:0 0 0 3px rgba(139,148,158,.12)} + .grid.stale .cbadge.active,.grid.stale .cbadge.inactive{color:var(--faint);background:var(--panel2)} + .grid.stale .card{opacity:.92} + .fresh.stale .dot{background:var(--faint);box-shadow:0 0 0 3px rgba(139,148,158,.12)} + .cbadge.active{color:var(--up);background:var(--up-bg)} + .cbadge.inactive{color:var(--down);background:rgba(214,88,79,.12)} + .csub{display:flex;align-items:center;gap:8px;margin:9px 0 2px;font-size:13px;flex-wrap:wrap} + .csub .pn{font-family:'JetBrains Mono',monospace;font-size:12.5px;color:var(--accent)} + .csub .pn:hover{text-decoration:underline} + .csub .jur{color:var(--muted);display:inline-flex;align-items:center;gap:5px} + .csub .flag{font-size:14px;line-height:1} + .csub .nopn{color:var(--faint);font-style:italic;font-size:12.5px} + + .rel{margin:15px 0 4px} + .rel-head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px} + .rel-head .pct{font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:700} + .rel-head .pct .n{color:var(--faint);font-weight:400} + .rel-bars{display:flex;gap:2px;align-items:stretch;height:26px} + .rel-bars .b{flex:1;min-width:2px;border-radius:1px;background:var(--down-dim)} + .rel-bars .b.up{background:var(--up)} + .rel-bars .b.down{background:var(--down)} + .rel-axis{display:flex;justify-content:space-between;margin-top:5px;font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--faint)} + + .meta{display:grid;grid-template-columns:1fr 1fr;gap:11px 16px;margin:14px 0 4px} + .meta .full{grid-column:1/-1} + .meta .v{font-family:'JetBrains Mono',monospace;font-size:12.5px;color:var(--text);margin-top:2px;word-break:break-word} + + .reveal{width:100%;padding:11px;border-radius:8px;background:var(--accent-bg);border:1px solid var(--accent-line);color:var(--accent-2);font-weight:600;font-size:13.5px;margin-top:14px;transition:background .15s} + .reveal:hover{background:rgba(247,147,26,.16)} + .reveal.open{color:var(--muted);border-color:var(--line)} + /* Secondary action under the primary one: same shape, quieter, so pairing + stays the obvious thing to click. */ + .reveal.secondary{background:var(--panel2);border-color:var(--line);color:var(--muted); + font-weight:500;font-size:12.5px;padding:9px;margin-top:8px} + .reveal.secondary:hover{color:var(--text);border-color:var(--line-soft);background:var(--panel2)} + + .pair{margin-top:14px;animation:rise .25s ease} + @keyframes rise{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}} + .qr{display:flex;flex-direction:column;align-items:center;gap:7px;margin-bottom:14px} + .qr .tile{background:#fff;border:1px solid var(--accent-line);border-radius:10px;padding:12px;line-height:0} + .qr .tile svg{display:block;border-radius:2px} + .qr .cap{font-family:'JetBrains Mono',monospace;font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint)} + .box{margin-bottom:12px} + .box .lbl,.modal-body>.lbl{display:flex;justify-content:space-between;align-items:center;margin-bottom:7px} + .modal-body>.lbl{margin:18px 0 8px;gap:12px} + .box .lbl .t,.modal-body>.lbl .t{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)} + .box pre{font-family:'JetBrains Mono',monospace;font-size:10.5px;line-height:1.55;background:var(--code-bg);color:var(--code-fg);border:1px solid var(--line);border-radius:8px;padding:13px;white-space:pre-wrap;word-break:break-all;max-height:240px;overflow:auto} + .box.signed pre{color:#cdd6e4;font-size:10px} + .copybtn{font-family:'JetBrains Mono',monospace;font-size:11px;padding:5px 11px;border:1px solid var(--accent-line);border-radius:6px;color:var(--accent);background:var(--accent-bg);transition:background .15s} + .copybtn:hover{background:rgba(247,147,26,.18)} + .copybtn.done{color:var(--up);border-color:rgba(63,185,80,.4);background:var(--up-bg)} + .eps{margin-top:4px;display:flex;flex-direction:column;gap:8px} + .card-eps{margin-top:14px;display:flex;flex-direction:column;gap:7px} + .ep{display:flex;align-items:center;gap:9px} + .ep .k{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);min-width:62px} + .ep .u{font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--muted);background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:5px 8px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + /* Verified operator domain: a quiet badge, not a trust mark. It sits beside the + payment-code chip and attests to control of the domain only. */ + .vdomain{display:inline-flex;align-items:center;gap:5px;font-family:'JetBrains Mono',monospace; + font-size:11px;padding:4px 8px;border-radius:6px;text-decoration:none; + color:var(--up);border:1px solid rgba(63,185,80,.35);background:var(--up-bg);white-space:nowrap; + max-width:190px;overflow:hidden;text-overflow:ellipsis} + .vdomain:hover{border-color:rgba(63,185,80,.6)} + /* "For the machines among us": an unobtrusive way to interrogate the badge. */ + /* Domain and its verify button, on the line below the payment code. The + domain takes the free space so a long one truncates instead of pushing the + button off the card. */ + .vrow{display:flex;align-items:center;gap:8px;margin:0 0 8px;flex-wrap:nowrap} + .vrow .vdomain{flex:1 1 auto;min-width:0;max-width:none} + .vrow .vproof{flex:0 0 auto} + .vproof{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.06em; + padding:4px 7px;border-radius:6px;color:var(--faint);border:1px solid var(--line); + background:var(--panel2);cursor:pointer} + .vproof:hover{color:var(--muted);border-color:var(--line-soft)} + /* A warning that must not be skimmed past: the XPUB advice is the one place + where following the page carelessly could cost a reader their privacy. */ + .warnbox{border:1px solid rgba(214,88,79,.45);background:rgba(214,88,79,.10); + border-radius:8px;padding:11px 13px;margin:0 0 12px;font-size:13px;line-height:1.6} + .warnbox b{color:var(--down)} + .proofblk{margin:0 0 12px} + .proofblk .k{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.08em; + text-transform:uppercase;color:var(--faint);margin-bottom:4px} + .proofblk pre{margin:0 0 6px;padding:9px 10px;background:var(--panel2);border:1px solid var(--line); + border-radius:6px;font-size:11.5px;white-space:pre-wrap;word-break:break-all;color:var(--muted)} + /* Verified-domain setup box in Manage. */ + .dbox{border:1px solid var(--line);border-radius:8px;padding:12px 13px;background:var(--panel2);margin-bottom:12px} + .dbox p{margin:0 0 8px} + .dbox .dnote{font-size:12.5px;color:var(--muted)} + .dbox .dmsg{font-size:12.5px;color:var(--accent);margin-top:8px; + overflow-wrap:anywhere;word-break:break-word;line-height:1.5} + /* Anything that can contain a payment code, an onion or a TXT value. */ + .dbox .dnote,.dbox .dwrap{overflow-wrap:anywhere;word-break:break-word} + .dbox{overflow:hidden} + .dbox .ok-tick{color:var(--up)} + .dbox .ep{margin-bottom:6px} + .dsign{font-size:11.5px;background:var(--panel);border:1px solid var(--line);border-radius:6px; + padding:9px 10px;white-space:pre-wrap;word-break:break-all;color:var(--muted);margin:0 0 8px} + .dbox textarea{width:100%;font-family:'JetBrains Mono',monospace;font-size:11.5px} + .ep .copybtn{flex-shrink:0} + /* An endpoint the node does not publish. The field keeps the same box as the + other endpoints so the rows line up; only the text colour marks it as not + being a value. The copy button stays in place, inert, so the row does not + change width or lose its right-hand column. */ + .ep .u.na{color:var(--faint)} + .copybtn[disabled]{opacity:.4;cursor:default;color:var(--faint);border-color:var(--line);background:var(--panel2)} + .copybtn[disabled]:hover{background:var(--panel2)} + + .note{margin:30px 0 8px;font-size:13.5px;color:var(--muted);line-height:1.65} + .note a{color:var(--accent);font-weight:600} + .note a:hover{text-decoration:underline} + + footer{border-top:1px solid var(--line-soft);padding:24px 0;margin-top:18px} + footer .wrap{display:flex;justify-content:center} + footer .gh{color:var(--faint);display:inline-flex;align-items:center;transition:color .15s} + footer .gh:hover{color:var(--text)} + footer .gh svg{display:block} + + .ov{position:fixed;inset:0;background:rgba(4,4,5,.72);backdrop-filter:blur(3px);display:none;align-items:flex-start;justify-content:center;padding:6vh 18px;z-index:50;overflow:hidden} + .ov.show{display:flex} + .modal{background:var(--panel);border:1px solid var(--line);border-radius:14px;max-width:700px;width:100%;padding:0;box-shadow:0 24px 60px rgba(0,0,0,.5);display:flex;flex-direction:column;max-height:88vh;min-height:0;overflow:hidden} + .modal-head{display:flex;align-items:center;justify-content:space-between;padding:20px 24px;border-bottom:1px solid var(--line-soft);background:var(--panel);border-radius:14px 14px 0 0;flex:0 0 auto} + .modal-head h2{font-family:'Archivo',sans-serif;font-size:19px;font-weight:700} + .modal-head .x{font-size:22px;color:var(--muted);line-height:1;padding:2px 8px;border-radius:6px} + .modal-head .x:hover{background:var(--panel2);color:var(--text)} + .modal-body{padding:22px 24px 26px;flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain} + .modal-body p{font-size:14px;color:#d7d7d4;line-height:1.7;margin-bottom:13px} + .modal-body h2{font-family:'Archivo',sans-serif;font-size:13px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--accent);margin:26px 0 12px;padding-bottom:7px;border-bottom:1px solid var(--line-soft)} + .modal-body h2:first-child{margin-top:0} + .modal-body h3{font-family:'Archivo',sans-serif;font-weight:700;color:var(--text);font-size:14.5px;margin:18px 0 4px} + .modal-body strong{color:var(--text)} + .modal-body a{color:var(--accent);font-weight:600;word-break:break-word} + .modal-body a:hover{text-decoration:underline} + .modal-body ul{margin:0 0 13px 2px;padding:0;list-style:none} + .modal-body li{font-size:14px;color:#d7d7d4;line-height:1.6;margin-bottom:6px;padding-left:2px} + .modal-body code{font-family:'JetBrains Mono',monospace;font-size:13px;color:var(--accent);background:var(--panel2);border:1px solid var(--line);border-radius:5px;padding:2px 6px} + .modal-body blockquote{background:var(--panel2);border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:8px;padding:14px 16px;margin:0 0 16px} + .modal-body blockquote p{font-size:13.5px;margin-bottom:9px} + .modal-body blockquote p:last-child{margin-bottom:0} + .modal-body blockquote code{display:inline-block;color:var(--accent);font-size:14px} + .modal-body .loading{color:var(--faint);font-family:'JetBrains Mono',monospace;font-size:13px} + @media (max-width:560px){ + .meta{grid-template-columns:1fr} + /* The network toggle and the freshness line sit at opposite ends of one row + on a wide screen, which is what space-between is for. On a narrow one they + wrap onto separate lines, and space-between then puts a lone item at the + start of its line, so both ended up hard against the left edge under a + centred header. Centre them instead: the toggle is the page's primary + control and reads as a control rather than a stray pair of words when it + is centred under the title. + .fresh is itself a flex container whose own content wraps, so it needs + centring too, or its second line ("re-checks every 10 min") hangs left + under a centred first line, which looks like a mistake rather than a + wrap. text-align covers any inline content that is not a flex item. */ + .controls{justify-content:center;gap:12px;padding:22px 18px 14px} + .fresh{justify-content:center;text-align:center} + header .wrap{padding:14px 18px;position:relative;justify-content:flex-start} + .burger{display:block;z-index:2} + /* Centre the title while the hamburger is in use. Only then: taking .brand + out of flow leaves the burger as the header's only in-flow child, and on + a page that has no burger the header collapses to its padding, so the + brand overlaps whatever is beneath it. That is what the operator console + looked like on a phone, its title clipped over the Moderation heading, + and its one nav link was unreachable as well because the nav becomes a + dropdown with nothing to open it. */ + header:not(.no-menu) .brand{position:absolute;left:50%;transform:translateX(-50%)} + header.no-menu .wrap{justify-content:space-between;gap:10px} + header.no-menu nav{display:flex;position:static;flex-direction:row;background:none; + backdrop-filter:none;border:0;padding:0} + header.no-menu nav .lnk{padding:8px 10px;font-size:14px;white-space:nowrap} + header.no-menu .brand .name{font-size:16px} + /* the nav becomes a full-width dropdown under the header */ + nav{display:none;position:absolute;top:100%;left:0;right:0;flex-direction:column;align-items:stretch;gap:2px; + background:rgba(11,11,12,.97);backdrop-filter:blur(8px);border-bottom:1px solid var(--line-soft);padding:8px 14px 12px} + nav.open{display:flex} + nav .lnk{display:block;text-align:center;padding:12px;font-size:15px} + nav .onion-pill{text-align:center;margin:6px 0 0} + /* Less chrome around the dialog on a small screen, so the body gets the + height. The scrolling still happens inside .modal-body. */ + .ov{padding:3vh 10px} + .modal{max-height:94vh} + .modal-head{padding:16px 18px} + .modal-body{padding:18px 18px 22px} + } + @media (prefers-reduced-motion:reduce){.card:hover{transform:none}.pair{animation:none}} + + /* Manage my Dojo form */ + .mform{display:flex;flex-direction:column;gap:12px} + .mform label{display:flex;flex-direction:column;gap:5px;font-size:12.5px;color:var(--muted)} + .mform input,.mform select,.mform textarea{background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:7px;padding:9px 10px;font-family:'JetBrains Mono',monospace;font-size:12.5px;width:100%} + .mform textarea{resize:vertical;line-height:1.5} + .mform input:focus,.mform select:focus,.mform textarea:focus{outline:none;border-color:var(--accent-line)} + + /* 90-day daily history (on the card, below the 24h strip) */ + .hist90{margin-top:12px} + .d90strip{display:flex;gap:1px;align-items:flex-end;height:22px;margin:8px 0 6px} + .d90{flex:1 1 0;min-width:1px;height:100%;border-radius:1px;background:var(--line)} + .d90.up{background:var(--up)} .d90.mid{background:var(--mid)} .d90.down{background:var(--down)} .d90.na{background:var(--line)} + .d90foot{display:flex;justify-content:space-between;font-size:11px;font-family:'JetBrains Mono',monospace} + .spark{display:block;margin-top:6px;opacity:.9} + + footer .ver{margin-left:12px;font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--faint)} + footer .ver a{color:var(--faint)} footer .ver a:hover{color:var(--text)} + + /* footer verify link */ + footer .wrap{display:flex;align-items:center;gap:10px} + .foot-spacer{flex:1} + .verify-pre{background:var(--panel,#111);border:1px solid var(--line);border-radius:8px;padding:12px;font-family:'JetBrains Mono',monospace;font-size:11px;white-space:pre-wrap;word-break:break-all;color:var(--text);margin-top:6px} + + /* admin console */ + .admin-row{border:1px solid var(--line);border-radius:10px;padding:14px;margin:10px 0;background:var(--card,#0e0e10)} + .admin-head{display:flex;align-items:center;gap:8px;margin-bottom:2px} + .abadge{font-size:10px;text-transform:uppercase;letter-spacing:.04em;padding:2px 7px;border-radius:20px;border:1px solid var(--line);color:var(--muted)} + .abadge.pending{color:#b9a13a;border-color:#b9a13a} + .abadge.approved{color:var(--up);border-color:var(--up)} + .abadge.rejected{color:var(--down);border-color:var(--down)} + .admin-actions{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap} + .abtn{font:inherit;font-size:13px;padding:7px 14px;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--text);cursor:pointer} + .abtn.ok{border-color:var(--up);color:var(--up)} + .abtn.danger{border-color:var(--down);color:var(--down)} + .abtn:disabled{opacity:.5} diff --git a/docker/dojobay/assets/fonts/archivo.woff2 b/docker/dojobay/assets/fonts/archivo.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..716d4abd199a236dc4b9e112f63429b9b47ce911 GIT binary patch literal 34928 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 E05fT^n*aa+ literal 0 HcmV?d00001 diff --git a/docker/dojobay/assets/fonts/hanken-grotesk.woff2 b/docker/dojobay/assets/fonts/hanken-grotesk.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..518e2c462e3289783b1060504df962504bdb5407 GIT binary patch literal 34704 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

_Fv2ffIA8GIEMBd#_8`UQNx0hpVi)+ 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 L7wb!pZ(-VgEVJqE%PjMztR+-#&Pxt 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 literal 0 HcmV?d00001 diff --git a/docker/dojobay/assets/fonts/jetbrains-mono.woff2 b/docker/dojobay/assets/fonts/jetbrains-mono.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..cd5102a44c763f1cfe9f2b199fd5d7257491c0dd GIT binary patch literal 40404 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 ReGdfuUZ`j(mnd4k{Xf==&SL-o literal 0 HcmV?d00001 diff --git a/docker/dojobay/assets/js/app.js b/docker/dojobay/assets/js/app.js new file mode 100644 index 00000000..7eeb71a2 --- /dev/null +++ b/docker/dojobay/assets/js/app.js @@ -0,0 +1,1803 @@ +// The Dojo Bay — directory UI. Loads data/*.json and content/*.md at runtime. +// Requires: assets/js/qrcode.js (global `qrcode`) and assets/js/markdown.js (global `markdown`). +(function(){ + "use strict"; +async function loadJSON(url){ + const r = await fetch(url, {cache:"no-store"}); + if(!r.ok) throw new Error(url+" -> HTTP "+r.status); + return await r.json(); + } + async function loadText(url){ + const r = await fetch(url, {cache:"no-store"}); + if(!r.ok) throw new Error(url+" -> HTTP "+r.status); + return await r.text(); + } + + const esc = s => String(s==null?"":s).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c])); + function flag(cc){ if(!cc) return ""; return cc.toUpperCase().replace(/./g,c=>String.fromCodePoint(127397+c.charCodeAt(0))); } + function uptime(checks){ if(!checks||!checks.length) return {pct:null,up:0,total:0}; const up=checks.filter(c=>c.up).length; return {pct:Math.round(up/checks.length*1000)/10,up,total:checks.length}; } + function copyFallback(text){ + const ta=document.createElement("textarea");ta.value=text;document.body.appendChild(ta);ta.select(); + try{document.execCommand("copy")}catch(e){}document.body.removeChild(ta);return Promise.resolve(); + } + function copy(text){ + if(navigator.clipboard&&navigator.clipboard.writeText) + return navigator.clipboard.writeText(text).catch(()=>copyFallback(text)); + return copyFallback(text); + } + function flash(btn,t){const o=btn.innerHTML;btn.innerHTML=t;btn.classList.add("done");setTimeout(()=>{btn.innerHTML=o;btn.classList.remove("done")},1500);} + + function qrSVG(text, px, ec){ + // ec "H" (30% recovery) is required for QRs carrying a centre avatar; the + // overlay covers ~5% of the symbol, leaving ample margin for scanners. + const qr = qrcode(0, ec||"M"); qr.addData(text); qr.make(); + const n=qr.getModuleCount(), margin=2, total=n+margin*2, cell=px/total; + let r=""; + for(let row=0;row'; + } + return ''+r+''; + } + + /* ---------------- site config (edit these) ---------------- + REPO_URL : the GitHub repository the footer mark links to. + ONION_URL : this site's own .onion address. Leave "" to hide the + header pill (e.g. while testing, or when the site is + served onion-only and the pill would be redundant). */ + const REPO_URL = "https://github.com/Dojobay/dojobay"; + const ONION_URL = "http://dojobayeryasshgghz537de5ckgd5hhi4z5sdeil3roeh65fwhdnu2yd.onion/"; + // PayNym profile links point at the paynym.rs onion, so a visitor stays on Tor. + const PAYNYM_WEB = "http://paynym25chftmsywv4v2r67agbrr62lcxagsf4tymbzpeeucucy2ivad.onion"; + const SRC_ICON = ``; + const GH_LOGO = ``; + + // Header/hero brand mark. Same torii as favicon.svg and the PWA icons, minus + // the rounded background chip (it sits on the page, not on a tile) and themed + // via the accent variables. viewBox frames the shared artwork paths. + const LOGO = ` + `; + + const MODAL_META = { + about: {title:"About The Dojo Bay", file:"content/about.md"}, + faq: {title:"Frequently asked questions", file:"content/faq.md"}, + }; + const modalCache = {}; + + let DOJOS=null, HIST=null, net="mainnet"; + // Mobile menu: state read by the header template at render time (the same + // pattern as the Manage button and the build hash), never DOM-poked. + let menuOpen=false; + + // Is the published data still current? + // + // The updater rewrites data/dojos.json every interval_minutes. If that timer + // dies, nginx keeps serving the last file indefinitely and every badge stays + // confidently green, which is the one failure this directory must not have: + // the whole proposition is that the status is real. Past a few intervals we + // stop asserting status and say so. + // + // A clock that is behind makes generated_at look like the future, which is + // simply not stale. A clock far ahead can produce a false warning, so the + // banner mentions it rather than insisting the site is broken. + const STALE_INTERVALS = 3; + function freshness(doc){ + const iv = Number(doc && doc.interval_minutes) > 0 ? Number(doc.interval_minutes) : 10; + const t = Date.parse((doc && doc.generated_at) || ""); + if(!isFinite(t)) return { stale:true, unknown:true, intervalMin:iv, ageMin:null }; + const ageMin = (Date.now() - t) / 60000; + return { stale: ageMin > iv*STALE_INTERVALS, unknown:false, intervalMin:iv, ageMin }; + } + function humanAge(mins){ + if(mins==null) return "an unknown time"; + if(mins < 90) return Math.max(1,Math.round(mins)) + " minutes"; + if(mins < 60*36) return Math.round(mins/60) + " hours"; + return Math.round(mins/1440) + " days"; + } + + // Payment codes are 116 characters and no card is that wide, so the chip shows + // as much as actually fits and elides the middle — the head and tail are what + // identify a code by eye. The amount is measured rather than fixed, so a wide + // window shows more than a narrow one and nothing is hardcoded to a layout. + // The markup ships a conservative default, so a browser where measurement is + // unavailable still renders something sensible. + function middleTruncate(str, max){ + const s = String(str || ""); + if(!(max > 0) || max >= s.length) return s; + if(max < 9) return s.slice(0, Math.max(1, max - 1)) + "…"; + const keep = max - 1; // one character for the ellipsis + const head = Math.ceil(keep / 2); + return s.slice(0, head) + "…" + s.slice(s.length - (keep - head)); + } + + let FIT_CTX = null; + function fitPaymentCodes(){ + const els = document.querySelectorAll(".pcode[data-v]"); + if(!els.length) return; + try{ FIT_CTX = FIT_CTX || document.createElement("canvas").getContext("2d"); } + catch(e){ return; } // no canvas: keep the default + if(!FIT_CTX) return; + els.forEach((el)=>{ + const code = el.getAttribute("data-v"); + if(!code) return; + const cs = getComputedStyle(el); + FIT_CTX.font = cs.font || `${cs.fontSize} ${cs.fontFamily}`; + // measureText knows nothing about letter-spacing, and the chip has some. + // Leaving it out made the text a shade too wide, so the browser applied + // its OWN ellipsis on top of ours: "PM8T…text…". Add it, and keep a pixel + // of slack for sub-pixel rounding. + const ls = parseFloat(cs.letterSpacing) || 0; + const ch = FIT_CTX.measureText("0").width + ls; // monospace: one width fits all + const pad = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0); + const avail = (el.clientWidth || 0) - pad - 2; + if(!(ch > 0) || !(avail > 0)) return; + el.textContent = middleTruncate(code, Math.floor(avail / ch)); + }); + } + + let FIT_TIMER = null; + window.addEventListener("resize", ()=>{ + clearTimeout(FIT_TIMER); + FIT_TIMER = setTimeout(fitPaymentCodes, 120); + }); + + // One renderer for every endpoint row, so all three look and behave the same. + // A row is always present: with a usable URL it shows the value and a working + // copy button; without one it reads N/A with the copy button greyed out and + // inert, keeping the row's three-column geometry. Anything that is not an + // http(s)/tcp/ssl URL counts as absent, which covers a payload that carries + // an explorer with an empty or placeholder url rather than omitting it. + function epRow(label, url, naNote){ + const ok = typeof url === "string" && /^(https?|tcp|ssl):\/\//i.test(url.trim()); + if(!ok) return `

`; + const u = url.trim(); + return `
${esc(label)}` + + `${esc(u)}` + + `
`; + } + + // Electrum/indexer endpoint for the card: whatever build-public.mjs published + // as indexer_url, which is only ever what the updater read from the node's + // /support/services. The payload shapes are deliberately NOT read as a + // fallback: nothing signs them, so an endpoint nobody probed must never be + // rendered as though it had been, however old the file being read is. + function indexerUrl(n){ + const ok = (u) => (typeof u === "string" && /^(tcp|ssl):\/\/[a-z2-7]{56}\.onion:\d{2,5}(\/.*)?$/i.test(u)) ? u : null; + return ok(n.indexer_url); + } + + // ---- 90-day daily history (lazily fetched once, cached) ------------------- + let HIST90 = null, DAILY = {nodes:{}}; + function loadHist90(){ + if(!HIST90) HIST90 = loadJSON("data/history-daily.json").catch(()=>({nodes:{}})).then(d=>{DAILY=d;return d;}); + return HIST90; + } + function heightSparkline(days){ + const pts = days.map((d,i)=>({i,h:d.close})).filter(p=>typeof p.h==="number"); + if(pts.length<2) return ""; + const W=280,H=32,pad=2, hs=pts.map(p=>p.h), min=Math.min(...hs), max=Math.max(...hs), span=(max-min)||1, n=(days.length-1)||1; + const coords=pts.map(p=>{const x=pad+(p.i/n)*(W-2*pad); const y=H-pad-((p.h-min)/span)*(H-2*pad); return x.toFixed(1)+","+y.toFixed(1);}); + return ``; + } + // The two thresholds behind every reliability figure on a card, named because + // they were previously three unrelated numbers in two places that disagreed + // with each other in the same widget. + // + // DAY_UP_PCT is what "a day was up" means, and it is deliberately shared: the + // 90-day strip paints these days green and the footer counts exactly these + // days, so the strip is now a picture of the number printed under it. 95% + // rather than 99% because a node re-checked every ten minutes takes 144 + // checks a day, so 99% left no room for one missed probe or a short restart; + // 95% allows about seven misses, roughly an hour. + // + // DAY_PARTIAL_PCT is not a second definition of "up". It subdivides the days + // that were NOT up, so a reader can tell a wobble from an outage: a day that + // managed most of its checks is amber, a day that lost more than half is red. + // Both are counted as down in the footer. Reading amber as a pass is the + // mistake this pair exists to prevent, which is why the tooltip states the + // threshold rather than saying "up". + const DAY_UP_PCT = 95, DAY_PARTIAL_PCT = 50; + async function renderHist90(mount, id){ + if(!mount) return; + const body = mount.querySelector(".h90-body"); + let data; try{ data = await loadHist90(); }catch(e){ if(body) body.innerHTML='No history yet.'; return; } + const days = (data.nodes && data.nodes[id] && data.nodes[id].days) || []; + if(!days.length){ if(body) body.innerHTML='No daily history yet.'; return; } + const view = days.slice(-90); + const bars = view.map(d=>{ + const pct = d.pct==null?null:d.pct; + const cls = pct==null?"na":(pct>=DAY_UP_PCT?"up":(pct>=DAY_PARTIAL_PCT?"mid":"down")); + const t = `${d.d}: ${pct==null?"no data":pct+"% up"}${d.close!=null?", close "+Number(d.close).toLocaleString("en-GB"):""}`; + return ``; + }).join(""); + const closes = view.filter(d=>d.close!=null).map(d=>d.close); + const latest = closes.length?closes[closes.length-1]:null; + // The footer counts the SAME days the strip paints green, so the two can + // never disagree about what a good day is. + const withData = view.filter(d=>d.pct!=null); + const upDays = withData.filter(d=>Number(d.pct)>=DAY_UP_PCT).length; + const relTxt = withData.length + ? (p=>`${p%1===0?p:p.toFixed(1)}% · ${upDays}/${withData.length} days`)(100*upDays/withData.length) + : `${view.length} day${view.length>1?"s":""}`; + if(body) body.innerHTML = + `
${bars}
`+ + `
${relTxt}`+ + (latest!=null?`closing height ${Number(latest).toLocaleString("en-GB")}`:"")+`
`+ + heightSparkline(view); + } + + function relStrip(checks){ + const u=uptime(checks); + const bars=(checks||[]).map(c=>`
`).join(""); + const pct=u.pct==null?"—":(u.pct%1===0?u.pct:u.pct.toFixed(1))+"%"; + return `
+
Reliability · 24h${pct} ${u.up}/${u.total}
+
${bars}
+
24h agonow
`; + } + + function card(n){ + const checks=(HIST.nodes[n.id]||{}).checks||[]; + const pn=n.paynym + ?`
${esc(n.paynym)}` + :`no PayNym`; + const jur=n.jurisdiction?`${n.country?`${flag(n.country)}`:""}${esc(n.jurisdiction)}`:""; + // Card title: the node's short name alone ("yellow"). Names are unique + // per network and the PayNym (linked) plus payment-code chip sit directly + // beneath, so the composite "+paynym · name" title proved redundant. + const title = n.name || n.paynym || n.id; + return `
+
+ + ${esc(title)} + ${n.status==="active"?"Active":"Inactive"} +
+
${pn}${jur?'·'+jur:""}
+ ${n.paymentCode?``:""} + ${(n.operator_domain||n.operator_domain_proof)?`
+ ${n.operator_domain?`✓ ${esc(n.operator_domain)}`:""} + ${n.operator_domain_proof?``:""} +
`:""} + ${relStrip(checks)} +
Reliability · 90 days
Loading…
+
+
Hardware
${esc(n.hardware||"—")}
+
Dojo version
v${esc(n.version||"?")}
+
Block height
${n.block_height!=null?Number(n.block_height).toLocaleString("en-GB"):"—"}
+
Last checked
${esc((n.checked_at||"").replace("T"," ").replace("Z",""))}
+
+
+ ${epRow("Dojo API", n.payload.pairing && n.payload.pairing.url, "This node publishes no Dojo API endpoint")} + ${epRow("Explorer", n.payload.explorer && n.payload.explorer.url, "This node publishes no block explorer")} + ${epRow("Electrum Server", indexerUrl(n), "This node does not publish an Electrum endpoint, or runs a Dojo older than v1.27.0")} +
+ + ${n.payload && n.payload.pairing && n.payload.pairing.apikey + ? ` + ` : ""} +
`; + } + + function pairHTML(n){ + const pairingOnly = JSON.stringify({pairing:n.payload.pairing, explorer:n.payload.explorer}, null, 2); + const qr = qrSVG(JSON.stringify(n.payload), 208, "H"); + // NOT loading="lazy": this sits at the centre of a QR in a popup that has + // just opened, so deferring the fetch costs a visible Tor round trip at + // exactly the wrong moment. High priority and eager decoding instead; the + // file is small, same-origin and cached for a day by nginx, and the card's + // Pairing details button warms it on hover (see warmAvatar). + const avatar = n.paymentCode + ? `` + : ""; + const signedBox = n.signed ? ` +
+
Signed message
+
${esc(n.signed)}
+
` : ""; + return `
+
${qr}${avatar}
Scan to pair
+
+
Pairing code
+
${esc(pairingOnly)}
+
+ ${signedBox} +
`; + } + + // "For the machines among us": how to check a domain badge without trusting + // this site. Both halves are independently checkable — the TXT record comes + // from the operator's own DNS, and the signature verifies against the payment + // code already shown on the card. Neither step involves this instance, and + // neither puts paynym.rs on the request path. + function domainProofHTML(n){ + const p = n.operator_domain_proof; + if(!p) return "

No published proof for this node.

"; + const msg = (p.signed.match(/SIGNED MESSAGE-----\n([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/)||["",""])[1]; + const addr = (p.signed.match(/Address:\s*(\S+)/)||["",""])[1]; + const sig = (p.signed.match(/\n([A-Za-z0-9+/=]{80,})\n*-----END BITCOIN SIGNATURE/)||["",""])[1]; + const blk = (label, body) => + `
${esc(label)}
` + + `
${esc(body)}
` + + `
`; + return `

The operator of this node claims ${esc(p.domain)}. Two independent + checks prove it, and you can run both yourself without trusting this directory.

+

1. The domain names the payment code

+

Look up the TXT record from the operator's own DNS. It must contain the payment + code shown on the card.

+ ${blk("with dig", "dig +short TXT " + p.txt_name)} + ${blk("or over HTTPS, no dig required", + "curl -sH 'accept: application/dns-json' \\\n 'https://cloudflare-dns.com/dns-query?name=" + p.txt_name + "&type=TXT'")} + ${blk("expected to contain", p.txt_value)} +

2. The payment code names the domain

+

The operator signed this text with the notification address of that payment code. + Verify it with any Bitcoin message verifier, for example the + BIP47 lab, + or bitcoin-cli verifymessage.

+ ${blk("message", msg)} + ${blk("signing address", addr)} + ${blk("signature", sig)} + ${blk("or verify the whole block at once", p.signed)} +

Both must hold. The TXT record alone shows only that whoever controls the domain + published a code; the signature alone shows only that the code's owner mentioned the domain. + Together they show the same party holds both. This proves control of a domain, not that the + operator is trustworthy.${p.verified_at?" This instance last confirmed it on "+esc(p.verified_at.slice(0,10))+".":""}

`; + } + + // "Check it yourself": the commands to ask the node directly for the things + // this card asserts about it. + // + // Most of a listing is operator-signed and independently checkable, but the + // Electrum endpoint and the running version are OUR prober's word — we read + // The Tor SOCKS port a reader is running on. Two presets because there are two + // answers in practice: a standalone tor daemon listens on 9050 and Tor Browser + // on 9150. + // + // It is a variable rather than prose telling you to edit the commands, which + // is what this used to be. The page stated 9050, added a sentence saying to + // change it if you were on Tor Browser, and then handed over five commands + // containing 9050 and a copy button beside each. Knowing you were on the other + // port still left you editing every block, or editing the text on screen and + // then copying the unedited original, which is the worse failure because it + // looks like it worked. + // + // Deliberately not persisted. The site keeps nothing in localStorage, and a + // remembered preference is a small stored fact about a reader that buys very + // little; for the length of a visit is enough. + let TOR_PORT = 9150; + const socksArg = () => `--socks5-hostname 127.0.0.1:${TOR_PORT}`; + + // Rendered at the top of both command popups. Re-renders whichever popup is + // open, so the visible commands and the text behind every copy button change + // together: a control that updated only what you can see would be worse than + // no control at all. + const portPicker = () => + `
Tor SOCKS port` + + [[9050, "standalone tor"], [9150, "Tor Browser"]].map(([p, what]) => + ``).join("") + + `
`; + + // them and publish them. These commands run the same two requests against the + // node, so a reader can compare and never has to take our display on trust. + // The apikey and onion are already on the card, so nothing new is disclosed. + function checkSelfHTML(n){ + const pr = (n.payload && n.payload.pairing) || {}; + if(!pr.url || !pr.apikey) return "

This node publishes no API key, so it cannot be queried directly.

"; + let base; + try{ const u = new URL(pr.url); base = u.origin + (u.pathname||"/v2").replace(/\/+$/,""); } + catch(e){ return "

This node's pairing URL could not be parsed.

"; } + const S = socksArg(); + const blk = (label, body) => + `
${esc(label)}
` + + `
${esc(body)}
` + + `
`; + const iu = indexerUrl(n); + const ours = ["the Dojo version", "the block height"].concat(iu ? ["the Electrum endpoint"] : []); + return `

Nearly everything on this card is signed by the operator and checkable without us. + These are not: ${ours.slice(0,-1).join(", ")} and ${ours[ours.length-1]} are values our checker read from + the node and republished. The commands below ask the node the same questions over Tor, so you can compare + its answers with ours.

+

You need a Tor SOCKS proxy. Pick the port you are running on and every command below, + including what the copy buttons put on your clipboard, changes to match. The API key and onion address + here are already published on this card.

+ ${portPicker()} +

If a command answers + Failed to connect to 127.0.0.1 port ${TOR_PORT}: Connection refused, + nothing is listening there: try the other port. If neither works, Tor is not running, or is on a port + of its own, which some packaged builds choose.

+ +

1. Log in, and read the version header

+

Every Dojo response carries X-Dojo-Version, so -i + shows the running version and the reply carries the token for step 2.

+ ${blk("login", `curl -si ${S} \\\n -d "apikey=${pr.apikey}" \\\n ${base}/auth/login`)} + ${blk("we show this version", n.version ? "v" + n.version : "(none published)")} + +

2. Ask for the chain tip

+

Substitute the access_token from step 1. This is where the block + height on the card comes from; it moves on, so expect it to be at or above what we show.

+ ${blk("latest block", `curl -s ${S} \\\n -H "Authorization: Bearer " \\\n ${base}/latest-block`)} + ${blk("we show this height", n.block_height != null ? String(n.block_height) : "(none yet)")} + + ${iu ? `

3. Ask for the Electrum endpoint

+

The indexer entry is the Electrum server.

+ ${blk("services", `curl -s ${S} \\\n -H "Authorization: Bearer " \\\n ${base}/support/services`)} + ${blk("we show this endpoint", iu)}` : `

This node publishes no Electrum endpoint, so the card + shows N/A and there is nothing to check on that count. Either its operator does not expose an indexer, or it + runs a Dojo older than v1.27.0, which has no such route.

`} + +

All of it at once

+

With jq installed:

+ ${blk("one-liner", `TOKEN=$(curl -s ${S} -d "apikey=${pr.apikey}" ${base}/auth/login | jq -r .authorizations.access_token)\n` + + `curl -s ${S} -H "Authorization: Bearer $TOKEN" ${base}/latest-block | jq -r .height` + + (iu ? `\ncurl -s ${S} -H "Authorization: Bearer $TOKEN" ${base}/support/services | jq -r '.services[]|select(.type=="indexer")|.url'` : ""))} +

Without jq, the replies are small enough to read as they are.

+ +

One caveat, stated rather than glossed over: running these opens your own Tor circuit to the + node, so the operator sees a request. That is inherent to checking anything directly, not an extra exposure, + and no wallet key or XPUB is involved.

`; + } + + // "Rescan XPUB": the commands, never a field. + // + // This page does not ask for an XPUB and never will. An XPUB reveals every + // address an account has ever used or will use, and a directory that invited + // people to paste one into a web form would be teaching the exact habit that + // makes phishing clones profitable — even if this particular page were honest, + // the next one that looks like it would not be. + // + // So the popup hands over commands the reader runs in their own terminal, + // against the operator's node, with this instance nowhere on the path. + function rescanHTML(n){ + const pr = (n.payload && n.payload.pairing) || {}; + if(!pr.url || !pr.apikey) return "

This node publishes no API key, so it cannot be asked to do anything.

"; + let base; + try{ const u = new URL(pr.url); base = u.origin + (u.pathname||"/v2").replace(/\/+$/,""); } + catch(e){ return "

This node's pairing URL could not be parsed.

"; } + const S = socksArg(); + const blk = (label, body) => + `
${esc(label)}
` + + `
${esc(body)}
` + + `
`; + return `

Your wallet normally does this for you. Pairing with a Dojo registers your + account and imports its history, and if a balance looks wrong the usual fix is to re-pair in + Samourai or Ashigaru. What follows is for people who would rather drive the API directly.

+ +
+ Never paste an XPUB into a web page, including this one. + An XPUB reveals every address an account has used and every one it will use in future. This page + does not ask for yours and has no field to type it into: the commands below run in your own + terminal, and this directory never sees the value. Be equally suspicious of any site that does ask. +
+ +

Two things follow from that. Your XPUB does go to this operator's node, which + is inherent to using someone else's Dojo rather than a new exposure — it is the same thing pairing + does. And a rescan is real work for their machine, so it is not something to run repeatedly.

+ + ${portPicker()} +

Pick the port your Tor is listening on and every command below changes to match, + including what the copy buttons put on your clipboard. A standalone tor + daemon uses 9050 and Tor Browser uses 9150; + Connection refused means nothing is listening on the one you chose.

+ +

1. Log in

+ ${blk("token", `TOKEN=$(curl -s ${S} -d "apikey=${pr.apikey}" ${base}/auth/login | jq -r .authorizations.access_token)`)} + +

2. Ask for the rescan

+

Pick the scheme matching the account: bip84 for native + segwit (addresses starting bc1), bip49 for + wrapped segwit (3…), bip44 for legacy + (1…). A wallet usually has all three, and each is a separate XPUB. +
type=restore is the rescan; type=new registers + an account with no history to look for.

+ ${blk("restore", `curl -s ${S} \\\n -H "Authorization: Bearer $TOKEN" \\\n` + + ` -d "xpub=" \\\n -d "type=restore" \\\n -d "segwit=bip84" \\\n ${base}/xpub/`)} +

Add -d "force=true" only if the account is already known to + this Dojo and you want its records rebuilt from scratch.

+ +

3. Watch it finish

+

A restore walks the derivation looking for used addresses, so it takes a while.

+ ${blk("status", `curl -s ${S} -H "Authorization: Bearer $TOKEN" \\\n ${base}/xpub//import/status`)} + +

If any of this fails, the node may be down, may be running a Dojo too old for the + route, or may have been given an XPUB it cannot parse. Nothing here is retried for you, and nothing + here is recorded by this directory.

`; + } + + // The two popups that carry shell commands record how to rebuild themselves, + // because changing the Tor port has to redraw the one that is open. Cleared on + // close so a stale closure cannot repaint a dialog nobody is looking at. + let MODAL_REOPEN = null; + + function openRescan(n){ + document.getElementById("ov-title").textContent = (n.name||n.id) + " · rescan an XPUB"; + MODAL_REOPEN = () => { document.getElementById("ov-body").innerHTML = rescanHTML(n); }; + MODAL_REOPEN(); + showOverlay(); + } + + function openCheckSelf(n){ + document.getElementById("ov-title").textContent = (n.name||n.id) + " · check it yourself"; + MODAL_REOPEN = () => { document.getElementById("ov-body").innerHTML = checkSelfHTML(n); }; + MODAL_REOPEN(); + showOverlay(); + } + + function openDomainProof(n){ + document.getElementById("ov-title").textContent = (n.operator_domain||"domain") + " · verify"; + document.getElementById("ov-body").innerHTML = domainProofHTML(n); + showOverlay(); + } + + // Pairing details open in the shared popup (the same surface as Verify) + // rather than expanding beneath the card. + function openPair(n){ + document.getElementById("ov-title").textContent = (n.name||n.id) + " · pairing"; + document.getElementById("ov-body").innerHTML = pairHTML(n); + showOverlay(); + } + + // Card ordering: 7-day uptime desc, then 24h uptime desc, then name. A node + // with NO history ranks as 0.5% on the missing figure: below anything alive, + // above a long-standing dead node sitting at 0%, so fresh listings gather + // near the end without looking worse than known-dead ones. + const NO_HISTORY_PCT = 0.5; + function pct7(id){ + const days=(((DAILY||{}).nodes||{})[id]||{}).days||[]; + const last=days.slice(-7); + if(!last.length) return null; + return last.reduce((a,d)=>a+(Number(d.pct)||0),0)/last.length; + } + function pct24(id){ + const c=((HIST.nodes||{})[id]||{}).checks||[]; + if(!c.length) return null; + return 100*c.filter(x=>x.up).length/c.length; + } + function byUptime(a,b){ + const a7=pct7(a.id)??NO_HISTORY_PCT, b7=pct7(b.id)??NO_HISTORY_PCT; + if(a7!==b7) return b7-a7; + const a24=pct24(a.id)??NO_HISTORY_PCT, b24=pct24(b.id)??NO_HISTORY_PCT; + if(a24!==b24) return b24-a24; + return String(a.name||a.id).localeCompare(String(b.name||b.id),"en",{sensitivity:"base"}); + } + + // A directory with nothing in it. Reached in two ways that look identical to + // this function but mean opposite things to a reader: a freshly installed + // instance that has not yet run its first rebuild, and an established one + // whose selected network happens to hold no listings. Say which, because + // "nothing here" reads as a fault otherwise, and a new operator staring at a + // blank page has no way to tell a working install from a broken one. + function emptyState(){ + const other = net==="mainnet" ? "testnet" : "mainnet"; + const anyElsewhere = (DOJOS.nodes||[]).some(n=>n.network===other); + const fresh = !(DOJOS.nodes||[]).length && !DOJOS.generated_at; + return '
' + + (fresh + ? '

Nothing published yet. This directory has not completed its first refresh. ' + + 'If you have just installed it, run the rebuild and wait for one probe cycle; listings appear here as they are approved.

' + : '

No '+esc(net)+' Dojos are listed right now.' + + (anyElsewhere ? ' There are listings on '+esc(other)+': use the network switch above.' : '') + + '

') + + '

If you run a Dojo, you can list it yourself: ' + + '.

' + + '
'; + } + + function render(){ + const list=DOJOS.nodes.filter(n=>n.network===net).sort(byUptime); + const active=list.filter(n=>n.status==="active").length; + const gen=(DOJOS.generated_at||"").replace("T"," ").slice(0,16)+" UTC"; + const FRESH=freshness(DOJOS); + + document.getElementById("root").innerHTML = ` + +
+ + ${LOGO} +
THE DOJO BAY
public dojo directory
+ +
+ +
+
+ + +
+
${active} of ${list.length} active + · checked ${esc(gen)} + · re-checks every ${FRESH.intervalMin} min
+
+ +
+ ${DOJOS.probe_fault?`
+ This directory could not reach any node at ${esc(String(DOJOS.probe_fault.at).replace("T"," ").replace("Z"," UTC"))}. + All ${Number(DOJOS.probe_fault.nodes)||0} of them failing together is almost certainly a fault here rather + than every operator at once, so nothing was recorded and the statuses below are from the last check that + reached something. They may be out of date. This resolves itself when the fault does. +
`:""} + ${FRESH.stale?`
+ These statuses are out of date. + This directory last refreshed ${esc(humanAge(FRESH.ageMin))} ago${FRESH.unknown?"":`, and should refresh every ${FRESH.intervalMin} minutes`}. + This page refetches on the same cadence the directory publishes, so either the checker has stopped + or we have been unable to reach it. Either way the badges below are greyed out: treat every node + as unknown rather than up or down. + (If your device's clock is wrong, this warning can appear on a healthy directory.) +
`:""} + ${list.length + ? `
${list.map(card).join("")}
` + : emptyState()} +

The Dojo Bay is a federation of independent operators across different jurisdictions, and every node is reachable over Tor. Nodes go up and down without notice, and only the operator can restart one. Pairing exposes your XPUBs to that node, so do your own due diligence, or run your own Dojo.

+
+ + + +
`; + + // 90-day strips: one lazily-cached fetch of history-daily.json fills every + // card; re-renders re-hydrate from the same cached promise. + document.querySelectorAll(".hist90[data-hist]").forEach(m=>renderHist90(m, m.getAttribute("data-hist"))); + + // Now the cards have a width, show as much of each payment code as fits. + fitPaymentCodes(); + } + + async function openModal(key){ + const m=MODAL_META[key]; if(!m) return; + document.getElementById("ov-title").textContent=m.title; + const body=document.getElementById("ov-body"); + showOverlay(); + if(modalCache[key]==null){ + body.innerHTML='

Loading\u2026

'; + try{ modalCache[key]=markdown.render(await loadText(m.file)); } + catch(e){ modalCache[key]='

Could not load content ('+e.message+').

'; } + } + body.innerHTML=modalCache[key]; + } + function showLoadError(err){ + const local = location.protocol==="file:" || location.hostname==="localhost" || location.hostname==="127.0.0.1"; + if(local) return showServeHint(err); + document.getElementById("root").innerHTML = + '
' + + '

Directory data unavailable

' + + '

The node list could not be loaded. If this persists, the server\'s data/dojos.json is missing or unreadable.

' + + '

'+esc(String(err && err.message || err))+'

'; + } + function showServeHint(err){ + document.getElementById("root").innerHTML = + '
' + + '

Serve this over HTTP

' + + '

The directory loads its data and text from separate files, which browsers block when the page is opened straight from disk. From the project folder run:

' + + '
npm run dev
' + + '

then open http://localhost:8080.

' + + '

'+String(err && err.message || err)+'

'; + } + // Show the shared popup, always from the top. The body is the scroll container + // (the header is fixed above it), so without this a popup opened while the + // previous one was scrolled down would appear part-way through its own text. + function showOverlay(){ + const body = document.getElementById("ov-body"); + if(body) body.scrollTop = 0; + const ov = document.getElementById("ov"); + if(ov) ov.classList.add("show"); + } + + function closeModal(){ + MODAL_REOPEN = null; + const o=document.getElementById("ov"); if(o)o.classList.remove("show"); + // A refresh that arrived while this dialog was open deferred its redraw so + // as not to pull the content out from under the reader. Apply it now. + if(PENDING_RENDER){ PENDING_RENDER=false; render(); } + } + + // Verify popup: shows the operator's BIP47-signed proof of this onion address, + // as a scannable QR plus the copyable signed message. Source: data/operator.json. + let OPERATOR = null; + // Loaded at boot (state read by the footer template at render time); the + // Verify popup reuses the same state and lazily loads it as a fallback. + async function loadOperator(){ + try{ OPERATOR = await loadJSON("data/operator.json"); if(DOJOS) render(); }catch(e){ /* no operator.json: footer shows no avatar */ } + } + async function openVerify(){ + const titleEl=document.getElementById("ov-title"), body=document.getElementById("ov-body"); + if(!titleEl||!body) return; + titleEl.textContent = "Verify this directory"; + showOverlay(); + body.innerHTML = '

Loading…

'; + try{ if(!OPERATOR) OPERATOR = await loadJSON("data/operator.json"); } + catch(e){ body.innerHTML='

Operator signature unavailable.

'; return; } + const signed = OPERATOR.verifySigned || ""; + // Error correction H, not the default M. The avatar covers roughly 4% of + // the symbol area, which only M's 15% budget makes marginal; H's 30% has + // room to spare. The cost is density, and it is worth stating the numbers + // because they look alarming out of context: this lands at about 2.7px per + // module at 300px, where the pairing QR that every visitor already scans + // sits at 2.2px. Denser than nothing, less dense than what ships. + const qr = qrSVG(signed, 300, "H"); + // The operator's PayNym, so a reader can put a face and a name to whoever + // signed this, the same way every listing does. The avatar keys on the + // payment code, which operator.json always carries and which the updater + // already syncs alongside the listed nodes. + const avatar = OPERATOR.paymentCode + ? `` + : ""; + // The NAME is a separate problem: operator.json was defined before this was + // wanted and older instances have no paynym field, so it is read when + // present and otherwise recovered from the operator's own listing, which is + // published and carries both the payment code and the PayNym. Either way + // the link is omitted rather than guessed at if neither source has it. + const paynym = OPERATOR.paynym + || ((DOJOS && DOJOS.nodes || []).find((n) => n.paynym && n.paymentCode === OPERATOR.paymentCode) || {}).paynym + || null; + const paynymLine = paynym + ? '

Signed by ' + + `${esc(paynym)}` + + ' \u00b7 look the PayNym up yourself before trusting it

' + : ""; + body.innerHTML = + '

This directory\u2019s operator has signed its onion address with their BIP47 payment code. '+ + 'Scan or copy the signed message and verify it against the payment code to confirm you are on the genuine site and not a phishing clone.

'+ + '
'+qr+avatar+'
'+ + paynymLine+ + '
Signed message
'+ + '
'+esc(signed)+'
'; + } + + // Warm the PayNym avatar as soon as the operator shows intent to open a + // card's pairing popup, so the image is already in cache by the time the QR + // renders. Fetching every card's avatar up front would mean one Tor request + // per listed node on page load, which is far worse than one on hover. + const warmed = new Set(); + function warmAvatar(card){ + const code = card && card.getAttribute("data-pc"); + if(!code || warmed.has(code)) return; + warmed.add(code); + const img = new Image(); + img.src = "data/avatars/" + encodeURIComponent(code) + ".png"; + } + for(const evt of ["pointerenter","focusin"]){ + document.addEventListener(evt, e=>{ + const t = e.target instanceof Element ? evEl(e)?.closest('[data-act="pair"]') : null; + if(t) warmAvatar(t.closest(".card")); + }, true); + } + + document.addEventListener("click", e=>{ + const netBtn=evEl(e)?.closest("[data-net]"); + if(netBtn){net=netBtn.getAttribute("data-net");render();return;} + const mBtn=evEl(e)?.closest("[data-modal]"); + if(mBtn){ if(menuOpen){menuOpen=false;render();} openModal(mBtn.getAttribute("data-modal"));return;} + const act=evEl(e)?.closest("[data-act]"); + if(!act){ if(evEl(e)?.id==="ov") closeModal(); return; } + const a=act.getAttribute("data-act"); + if(a==="burger"){menuOpen=!menuOpen;render();return;} + if(a==="torport"){ + TOR_PORT = Number(act.getAttribute("data-v")); + // Re-render the popup that is open, so the commands on screen and the + // text behind every copy button move together. Re-opening rather than + // patching the port in place because the port appears in several blocks + // and in the connection-refused hint, and a partial update here is the + // exact failure the control exists to remove. + if(MODAL_REOPEN) MODAL_REOPEN(); + return; + } + if(a==="closemodal"){closeModal();return;} + if(a==="verify"){ openVerify(); return; } + if(a==="copyverify"){ if(OPERATOR) copy(OPERATOR.verifySigned).then(()=>flash(act,"Copied ✓")); return; } + if(a==="copycode"){ copy(act.getAttribute("data-v")).then(()=>flash(act,"Copied ✓")); return; } + // node resolution that works from a card OR from inside the popup + const byIdAttr=()=>DOJOS.nodes.find(x=>x.id===act.getAttribute("data-id")); + if(a==="copypairing"){const n=byIdAttr();copy(JSON.stringify({pairing:n.payload.pairing,explorer:n.payload.explorer},null,2)).then(()=>flash(act,"Copied ✓"));return;} + if(a==="copysigned"){copy(byIdAttr().signed).then(()=>flash(act,"Copied ✓"));return;} + const cardEl=evEl(e)?.closest(".card"); + const node=()=>DOJOS.nodes.find(x=>x.id===cardEl.getAttribute("data-id")); + if(a==="pair"){ openPair(node()); return; } + if(a==="domproof"){ openDomainProof(node()); return; } + if(a==="checkself"){ openCheckSelf(node()); return; } + if(a==="rescan"){ openRescan(node()); return; } + if(a==="copyurl"){copy(act.getAttribute("data-v")).then(()=>flash(act,"✓"));return;} + }); + document.addEventListener("keydown", e=>{ if(e.key==="Escape") closeModal(); }); + + /* ================= Manage my Dojo (self-service, step 2) ================= + Everything below is inert unless a backend answers /api/me. On the static + step-1 onion there is no API, so the nav button stays hidden and nothing + here runs. */ + // e.target is typed EventTarget, which has no DOM methods; every listener here + // is on a rendered element. One helper narrows it, rather than casting at each + // call site. + /** @param {Event} e @returns {Element|null} */ + const evEl = (e) => (e.target instanceof Element ? e.target : null); + + // A gateway error is retried briefly; everything else is returned at once. + // + // nginx proxies /api to the backend on localhost and answers 502 when nothing + // is listening. The one moment that reliably happens is the few seconds after + // a self-update restarts the service, which is a thing this very page asked + // for: a moderator clicked update, watched it succeed, and was then shown a + // bare "502" with no hint that the restart it had requested was still in + // progress. Nothing was wrong, and there was no way to tell that from the + // page. + // + // Only 502, 503 and 504 are retried, since those are the proxy saying it could + // not reach anything rather than the backend saying no. A 401 or a 409 is an + // answer and must not be repeated: retrying a POST that was refused for a + // real reason would be worse than the error it hides. + // + // Five attempts a second apart is a little over the gap a restart leaves and + // far short of the point where a person would rather be told. A request that + // is still failing after that is reported normally, which is the honest + // outcome when the service has genuinely gone. + const GATEWAY_DOWN = new Set([502, 503, 504]); + const api = { + async call(path, method="GET", body, opts){ + const tries = (opts && opts.tries) || 5; + for(let i=0;isetTimeout(z,1000)); continue; + } + if(GATEWAY_DOWN.has(r.status) && isetTimeout(z,1000)); continue; } + let j=null; try{ j=await r.json(); }catch(e){} + return {status:r.status, body:j, gatewayDown:GATEWAY_DOWN.has(r.status)}; + } + } + }; + let ME = null; + let BACKEND = false; + async function detectBackend(){ + try{ + // One attempt only. This runs on every page load to decide whether to show + // Manage my Dojo, and a directory served as static files must render at + // once whether or not a backend exists: retrying here would make every + // visitor to an instance without one wait five seconds for nothing. + const r = await api.call("/me", "GET", undefined, {tries:1}); + if(r.status===200 && r.body){ + ME = r.body; + BACKEND = true; + // render() rebuilds the header, so drive visibility from state and + // re-render if the page is already up, rather than poking the DOM once. + if(DOJOS) render(); + else { const el=document.getElementById("manage-link"); if(el) el.hidden=false; } + } + }catch(e){ /* no backend: stay hidden */ } + } + + async function openManage(){ + document.getElementById("ov-title").textContent = "Manage my Dojo"; + showOverlay(); + // One Auth47 session covers both this panel and /admin (same cookie), so + // re-read /api/me before rendering: a sign-in or sign-out that happened on + // the admin page (or another tab) is picked up here instead of asking the + // operator to authenticate twice. + const body = document.getElementById("ov-body"); + if(body) body.innerHTML = '

Checking session…

'; + await refreshMe(); + renderManage(); + } + async function refreshMe(){ const r=await api.call("/me"); if(r.status===200) ME=r.body; } + + // ---- verified domain ----------------------------------------------------- + // One domain per operator, proven in both directions: a TXT record on the + // domain names the payment code, and the operator signs a statement naming the + // domain. DOMAIN holds what the server reported; DOMAIN_PREP holds the exact + // record and text for a domain being set up, both fetched from the server so + // the instructions can never drift from what verification checks. + let DOMAIN = null, DOMAIN_PREP = null, DOMAIN_MSG = ""; + + async function refreshDomain(){ + const r = await api.call("/domain","GET"); + // Settle to an object either way, so a failed or unsupported lookup does not + // make every subsequent render re-request it. + DOMAIN = r.status===200 && r.body ? r.body : { claim:null, unavailable:true }; + } + + // ---- claim this instance -------------------------------------------------- + // A freshly installed Archipelago app has no data/operator.json — there was + // no install-time wizard to write one, unlike the upstream CLI installer. + // Whoever signs in and completes this first becomes the operator (refused + // once one exists), the same "first admin" shape most self-hosted apps use. + // No new crypto: the exact text-to-sign and verification are the same + // primitives the project's README already has an operator run by hand. + let INSTANCE = null, CLAIM_TEXT = null, CLAIM_MSG = ""; + + async function refreshInstance(){ + const r = await api.call("/instance","GET"); + INSTANCE = r.status===200 && r.body ? r.body : { operatorConfigured: true }; + } + + function claimSection(){ + if(CLAIM_TEXT){ + return `
+

Sign this exact text in your wallet under PayNym → Sign message, then paste the whole signed block below:

+
${esc(CLAIM_TEXT.text)}
+ + +
+ + +
${CLAIM_MSG?`

${esc(CLAIM_MSG)}

`:""}
`; + } + return `
+

This Dojo Bay has no operator yet. Claiming it with your payment code makes you its + admin — able to approve or reject listings at /admin — + with no separate account or password. Skip this if you are not ready: the directory works fine with no + operator claimed, and you (or anyone else who signs in) can claim it later.

+
+ ${CLAIM_MSG?`

${esc(CLAIM_MSG)}

`:""}
`; + } + + function domainSection(){ + const c = DOMAIN && DOMAIN.claim; + const msg = DOMAIN_MSG ? `

${esc(DOMAIN_MSG)}

` : ""; + if(c && !c.verified){ + return `
+

Saved for ${esc(c.domain)} — waiting for the TXT record.

+

Your signature is verified and stored, so you do not need to sign again. + We re-check DNS automatically every few minutes${c.last_check?`; last looked ${esc(c.last_check.slice(0,16).replace("T"," "))} UTC`:""}. + ${c.last_result?`
Last result: ${esc(c.last_result)}`:""}

+
+ + + +
${msg}
`; + } + if(c && c.verified){ + return `
+

Verified: ${esc(c.domain)} + ✓

+

Shown on your cards, and your card link may point anywhere on this domain. + ${c.failing_since?`The TXT record is currently missing (since ${esc(c.failing_since.slice(0,10))}); the badge is removed if it stays missing for ${esc(String(c.grace_days))} days.`:""} + ${c.last_check?`Last checked ${esc(c.last_check.slice(0,10))}.`:""}

+
+ + +
${msg}
`; + } + if(DOMAIN_PREP){ + return `
+

Two steps for ${esc(DOMAIN_PREP.domain)}${DOMAIN_PREP.punycode?' (shown in punycode)':""}:

+

1. Publish this TXT record on your domain:

+
Host${esc(DOMAIN_PREP.txt_host||"_dojobay")}
+
Value${esc(DOMAIN_PREP.txt_value)}
+

Most control panels (Namecheap, Cloudflare, Route 53) treat Host as relative to + your domain, so enter ${esc(DOMAIN_PREP.txt_host||"_dojobay")} exactly. Entering the full + ${esc(DOMAIN_PREP.txt_name)} there creates + ${esc(DOMAIN_PREP.txt_name)}.${esc(DOMAIN_PREP.domain)} instead, which + will not be found. A few panels do want the full name; use it only if yours asks for an FQDN. + Existing records for a website are unaffected: this is a separate TXT record.

+

2. Sign this EXACT text in your wallet under PayNym → Sign message, then paste the whole signed block below:

+
${esc(DOMAIN_PREP.sign_text)}
+ + +
+ + +
${msg}
`; + } + return `
+

Optional. Prove you control a domain and your cards show it, and your card + title can link to it. Verification is by DNS TXT record plus a wallet signature; nothing + is published until both check out. This proves control of a domain, not that you are + trustworthy, and a maintainer can revoke a badge.

+ +
${msg}
`; + } + + async function renderManage(){ + const body = document.getElementById("ov-body"); + if(!ME || !ME.authenticated){ return renderLogin(body); } + if(DOMAIN===null){ await refreshDomain(); } + if(INSTANCE===null){ await refreshInstance(); } + // The API already returns mainnet-then-testnet, alphabetical by name; + // sort again here so the panel never depends on response ordering. + const subs = (ME.submissions||[]).slice().sort((a,b)=> + a.network!==b.network ? (a.network==="mainnet"?-1:1) + : String(a.name||a.id).localeCompare(String(b.name||b.id),"en",{sensitivity:"base"})); + body.innerHTML = ` +

Signed in as ${esc(ME.paymentCode.slice(0,12))}…${esc(ME.paymentCode.slice(-4))} +

+ ${ME.admin?'

This payment code moderates the directory: open the admin console → (same sign-in; signing out here signs you out there too).

':""} + ${(INSTANCE && !INSTANCE.operatorConfigured)?`

Claim this instance

${claimSection()}`:""} +

Add or edit a Dojo you operate. Every listing must carry a pairing payload you have signed with your PayNym: that signature is the only part of a listing a visitor can check without trusting this site. Submissions are checked for a live Tor connection and for a valid signature, then reviewed by a maintainer before they appear.

+

Verified domain

+ ${domainSection()} +

Your Dojos

+ ${subs.length? subs.map(manageRow).join("") : '

None yet.

'} +

Add / replace a Dojo

+ ${dojoForm()} +
`; + } + function statusPill(s){ + const c = s==="approved"?"active":(s==="rejected"?"inactive":""); + const label = s==="approved"?"Approved":(s==="rejected"?"Rejected":"Pending review"); + return `${label}`; + } + // Inline editing of display fields (name, hardware, Dojo version). One row + // at a time: EDIT_ID holds the open row; other Edit buttons disable while + // it is set. Renaming keeps the record id (and history); uniqueness is + // enforced per network by the API (409). + let EDIT_ID = null; + let PAIR_ID = null; + function editForm(r, actPrefix){ + const ver = r.version || (r.payload && r.payload.pairing && r.payload.pairing.version) || ""; + return `
+ + +
Dojo version is read live from the node (${esc(ver?("v"+ver):"detected on next probe")}) and can't be edited here.
+
+ + + +
+
`; + } + // Updating the pairing payload is a different act from editing display + // fields: it changes where visitors connect. It keeps the listing's place and + // history, because approval binds to the payment code that owns the record, + // not to a particular onion. + function pairingForm(r){ + const current = JSON.stringify({ pairing: r.payload?.pairing, explorer: r.payload?.explorer }, null, 2); + return `
+

Paste the pairing payload exactly as your Dojo produced it. The new address must be + answering over Tor right now, or the update is refused and your current listing is left alone. + Your listing keeps its place, its approval and its uptime history.

+ + +
+ + + +
+
`; + } + + function manageRow(r){ + const editing = EDIT_ID === r.id; + const pairing = PAIR_ID === r.id; + return `
+
+ ${esc(r.name||r.id)} · ${esc(r.network)} · ${esc(r.jurisdiction||"—")} · ${esc(r.hardware||"—")} + + + + ${statusPill(r.status)} + +
+ ${editing?editForm(r,"mact"):""} + ${pairing?pairingForm(r):""} +
${esc(r.payload?.pairing?.url||"")}
+ +
`; + } + function dojoForm(){ + return `
+
+ + + + + + + +
+
`; + } + + function renderLogin(body){ + body.innerHTML = ` +

Sign in with your Dojo's PayNym using Auth47 to manage its listing. Scan this with Samourai or Ashigaru (Tools → Authenticate using PayNym), or tap to open.

+

Requesting challenge…

+

Auth47 proves you control the payment code without revealing any key. Nothing is stored beyond your payment code and the Dojo details you submit.

`; + startAuth47(); + } + let pollTimer=null; + let onAuthSuccess=null; + async function startAuth47(){ + clearInterval(pollTimer); + const boxEl = () => document.getElementById("auth47-box"); + const r = await api.call("/auth47/challenge","POST",{}); + if(r.status!==200){ if(boxEl()) boxEl().innerHTML='

Login unavailable.

'; return; } + const {uri,nonce} = r.body; + // The URI is shown as well as encoded because a QR is unusable when the + // wallet is on the SAME device as the browser, which is the common case on + // a phone: nothing can photograph its own screen. Selecting monospace text + // that wraps mid-token by hand is miserable there, so the challenge gets a + // copy button like every other opaque string in this interface. + if(boxEl()) boxEl().innerHTML = + `
${qrSVG(uri,200)}
+
+ ${esc(uri)} + +
`; + pollTimer = setInterval(async ()=>{ + // Also one attempt: this is already a poll on its own timer, so a retry + // inside it would stack requests on top of each other during a restart. + const p = await api.call("/auth47/poll?nonce="+encodeURIComponent(nonce), "GET", undefined, {tries:1}); + if(p.status===200 && p.body && p.body.authenticated){ clearInterval(pollTimer); await refreshMe(); (onAuthSuccess||renderManage)(); } + }, 2500); + } + + document.addEventListener("click", async e=>{ + const manageBtn = evEl(e)?.closest('[data-act="manage"]'); + if(manageBtn){ if(menuOpen){menuOpen=false;render();} openManage(); return; } + const m = evEl(e)?.closest("[data-mact]"); + if(!m) return; + const act = m.getAttribute("data-mact"); + const msg = document.getElementById("manage-msg"); + if(act==="logout"){ await api.call("/logout","POST",{}); clearInterval(pollTimer); await refreshMe(); ME={authenticated:false}; EDIT_ID=null; PAIR_ID=null; DOMAIN=null; DOMAIN_PREP=null; INSTANCE=null; CLAIM_TEXT=null; CLAIM_MSG=""; renderManage(); return; } + + // ---- claim this instance ---- + if(act==="claimstart"){ + CLAIM_MSG=""; + const r=await api.call("/setup/claim-text","GET"); + if(r.status!==200){ CLAIM_MSG=(r.body&&r.body.error)||("HTTP "+r.status); renderManage(); return; } + CLAIM_TEXT=r.body; renderManage(); return; + } + if(act==="claimcancel"){ CLAIM_TEXT=null; CLAIM_MSG=""; renderManage(); return; } + if(act==="claimsubmit"){ + const ta=document.querySelector(".c-signed"); + /** @type {HTMLButtonElement} */ (m).disabled=true; m.textContent="Verifying…"; CLAIM_MSG=""; + const r=await api.call("/setup/claim","POST",{signed:(ta&&/** @type {HTMLTextAreaElement} */ (ta).value)||""}); + if(r.status===200){ + CLAIM_TEXT=null; CLAIM_MSG="Claimed. You are now this instance's admin."; + await refreshInstance(); await refreshMe(); + } else { + CLAIM_MSG=(r.body&&r.body.error)||("HTTP "+r.status); + } + renderManage(); return; + } + + // ---- verified domain ---- + if(act==="domprep"){ + const v=/** @type {HTMLInputElement} */ (document.querySelector(".d-domain") || {}).value||""; + DOMAIN_MSG=""; const r=await api.call("/domain/prepare","POST",{domain:v}); + if(r.status!==200){ DOMAIN_MSG=(r.body&&r.body.error)||("HTTP "+r.status); } + else DOMAIN_PREP=r.body; + renderManage(); return; + } + if(act==="domrecheck"){ + const claim=DOMAIN&&DOMAIN.claim; if(!claim) return; + /** @type {HTMLButtonElement} */ (m).disabled=true; m.textContent="Checking DNS…"; + const r=await api.call("/domain/recheck","POST",{}); + DOMAIN_MSG = r.status===200 ? "Verified." + : ((r.body&&(r.body.error||r.body.note))||("HTTP "+r.status)); + await refreshDomain(); renderManage(); return; + } + if(act==="domcancel"){ DOMAIN_PREP=null; DOMAIN_MSG=""; renderManage(); return; } + if(act==="domchange"){ DOMAIN_PREP=null; DOMAIN_MSG=""; DOMAIN={claim:null}; renderManage(); return; } + if(act==="domremove"){ + await api.call("/domain","DELETE",{}); DOMAIN_PREP=null; DOMAIN_MSG="Removed."; + await refreshDomain(); await refreshMe(); renderManage(); return; + } + if(act==="domverify"){ + const ta=document.querySelector(".d-signed"); + /** @type {HTMLButtonElement} */ (m).disabled=true; m.textContent="Checking DNS…"; DOMAIN_MSG=""; + const r=await api.call("/domain","POST",{domain:DOMAIN_PREP.domain,signed:(ta&&/** @type {HTMLTextAreaElement} */ (ta).value)||""}); + if(r.status===200){ DOMAIN_PREP=null; DOMAIN_MSG="Verified."; await refreshDomain(); } + else if(r.status===202){ DOMAIN_PREP=null; DOMAIN_MSG=(r.body&&r.body.note)||"Saved; waiting for DNS."; await refreshDomain(); } + else { + // 503 means we could not reach enough resolvers: not the operator's fault. + DOMAIN_MSG=((r.body&&r.body.error)||("HTTP "+r.status)) + + (r.body&&r.body.hint?" "+r.body.hint:"") + + (r.status===503?" This is a lookup problem at our end, not a problem with your record. Try again shortly.":""); + } + renderManage(); return; + } + if(act==="delete"){ await api.call("/dojo/delete","POST",{id:m.getAttribute("data-id")}); await refreshMe(); EDIT_ID=null; renderManage(); return; } + if(act==="edit"){ EDIT_ID=m.getAttribute("data-id"); renderManage(); return; } + if(act==="editcancel"){ EDIT_ID=null; renderManage(); return; } + if(act==="pairing"){ PAIR_ID=m.getAttribute("data-id"); EDIT_ID=null; renderManage(); return; } + if(act==="paircancel"){ PAIR_ID=null; renderManage(); return; } + if(act==="pairsave"){ + const box=m.closest(".medit"); + const msgEl=box.querySelector(".edit-msg"); + let payload; + try{ payload=JSON.parse(/** @type {HTMLTextAreaElement} */ (box.querySelector(".p-payload")).value); } + catch(err){ if(msgEl) msgEl.textContent="That is not valid JSON."; return; } + /** @type {HTMLButtonElement} */ (m).disabled=true; m.textContent="Checking the node…"; + const r=await api.call("/dojo/pairing","POST",{ + id:m.getAttribute("data-id"), + payload, + signed:/** @type {HTMLTextAreaElement} */ (box.querySelector(".p-signed")).value, + }); + if(r.status!==200){ + if(msgEl) msgEl.textContent=(r.body&&r.body.error)||("HTTP "+r.status); + /** @type {HTMLButtonElement} */ (m).disabled=false; m.textContent="Update pairing"; + return; + } + PAIR_ID=null; await refreshMe(); renderManage(); return; + } + if(act==="editsave"){ + const box=m.closest(".medit"); + const r=await api.call("/dojo/edit","POST",{ + id:m.getAttribute("data-id"), + name:/** @type {HTMLInputElement} */ (box.querySelector(".e-name")).value, + hardware:/** @type {HTMLInputElement} */ (box.querySelector(".e-hw")).value, + }); + if(r.status!==200){ const em=box.querySelector(".edit-msg"); if(em) em.textContent=(r.body&&r.body.error)||("HTTP "+r.status); return; } + EDIT_ID=null; await refreshMe(); renderManage(); return; + } + if(act==="submit"){ + let payload; + try{ payload = JSON.parse(/** @type {HTMLInputElement} */ (document.getElementById("m-payload")).value); } + catch(err){ if(msg) msg.innerHTML='Pairing code is not valid JSON.'; return; } + // Validate the name (required + unique across approved and pending + // records) BEFORE the slow Tor connection gate, so a taken name fails in + // milliseconds. The POST re-checks server-side and answers 409 anyway. + const name = /** @type {HTMLInputElement} */ (document.getElementById("m-name")).value.trim(); + if(!name){ if(msg) msg.innerHTML='Give your node a name first.'; return; } + const nc = await api.call("/dojo/name-check?network="+encodeURIComponent(/** @type {HTMLInputElement} */ (document.getElementById("m-net")).value)+"&name="+encodeURIComponent(name)); + if(nc.status!==200 || !nc.body || !nc.body.available){ + if(msg) msg.innerHTML=''+esc((nc.body&&(nc.body.reason||nc.body.error))||"That name is not available.")+''; return; + } + if(msg) msg.innerHTML='Checking Tor connection… this can take up to 30s.'; + const r = await api.call("/dojo","POST",{ + network: /** @type {HTMLInputElement} */ (document.getElementById("m-net")).value, + name, + jurisdiction: /** @type {HTMLInputElement} */ (document.getElementById("m-jur")).value, + hardware: /** @type {HTMLInputElement} */ (document.getElementById("m-hw")).value, + payload, + signed: /** @type {HTMLInputElement} */ (document.getElementById("m-signed")).value.trim() || null, + }); + if(r.status===200){ if(msg) msg.innerHTML=''+esc(r.body.note||"Submitted.")+''; await refreshMe(); setTimeout(renderManage,1200); } + else { if(msg) msg.innerHTML=''+esc((r.body&&r.body.error)||("Error "+r.status))+''; } + return; + } + }); + + detectBackend(); + + + // Build hash. render() rebuilds the whole footer, so (exactly like the + // Manage button) the hash must live in state the template reads at render + // time; a one-shot DOM injection vanished on the first re-render. + let VERSION = null; + async function loadVersion(){ + try{ + const v = await loadJSON("data/version.json"); + if(v && v.commit && v.commit !== "dev"){ VERSION = v; if(DOJOS) render(); } + }catch(e){ /* no version file: show nothing */ } + } + + // ================= Admin console (/admin) ================================= + // Reuses the Auth47 login flow. A session whose payment code is in the + // backend's ADMIN_PAYMENT_CODES sees a moderation panel; others are refused. + function adminShell(inner){ + document.getElementById("root").innerHTML = ` +
+

Moderation

${inner}
`; + } + let ADM_EDIT_ID = null; + function adminRow(s){ + const editing = ADM_EDIT_ID === s.id; + const pr=s.probe; + const strip = (pr && pr.checks && pr.checks.length) ? relStrip(pr.checks) + : '

No probe data yet (the updater runs every 10 minutes).

'; + // "not yet probed" was shown for every approved listing, because the panel + // read only the pending-probe file, which stops being written once a record + // is approved. It now prefers the published view, the same data the cards + // use; a record with neither is genuinely awaiting its first probe. + const height = (pr && pr.block_height!=null) ? Number(pr.block_height).toLocaleString("en-GB") : "\u2014"; + const st = pr ? pr.status : "not yet probed"; + return `
+
${esc(s.paynym&&s.name?s.paynym+" · "+s.name:(s.paynym||s.name||s.id))} ${esc(s.status)} + ${esc(s.network)}
+
${esc(s.pairingUrl||"")}
+
live probe: ${esc(st)} \u00b7 block ${height} \u00b7 ${s.signed?"signed \u2713":"no signature"} \u00b7 v${esc(s.version||"?")}${s.hardware?" \u00b7 "+esc(s.hardware):""}
+ ${strip} +
+ ${s.status!=="approved"?``:""} + ${s.status!=="rejected"?``:""} + + +
+ ${editing?editForm(s,"adm"):""}
`; + } + async function renderAdminPanel(){ + if(!ME || !ME.authenticated){ + adminShell('

Sign in with your operator PayNym via Auth47 (Samourai or Ashigaru \u2192 Tools \u2192 Authenticate using PayNym).

Requesting challenge\u2026

'); + onAuthSuccess = renderAdminPanel; startAuth47(); return; + } + if(!ME.admin){ + adminShell('

The payment code '+esc(ME.paymentCode.slice(0,12))+'\u2026 is not an administrator of this directory.

'); + return; + } + if(!ADMIN_UPDATES && !ADMIN_UPDATES_LOADING){ + ADMIN_UPDATES_LOADING = true; + // Both paths must clear the flag. Neither did, so it stayed true from the + // first render onward: the re-check control was permanently disabled and + // permanently described a check that had long finished. It went unnoticed + // while the two labels were "Checking…" and "Check again", which are + // similar enough to read as a button either way. + api.call("/admin/updates") + .then(r=>{ ADMIN_UPDATES = r.body || {available:false,error:"HTTP "+r.status}; }) + .catch(()=>{ ADMIN_UPDATES={available:false,error:"request failed"}; }) + .finally(()=>{ ADMIN_UPDATES_LOADING = false; renderAdminPanel(); }); + // The outcome of the LAST update, which until now was written to disk and + // read by nobody: the panel only polled this endpoint while an update was + // running, so a reload discarded it. An update whose restart failed leaves + // the new code on disk and the old process serving it, which looks exactly + // like an update that did nothing, and the note explaining it sat in a + // file the operator had no reason to open. + api.call("/admin/update/status").then(r=>{ ADMIN_LAST = (r.body||{}).lastResult||null; renderAdminPanel(); }).catch(()=>{}); + } + adminShell('

Loading submissions\u2026

'); + const r = await api.call("/admin/submissions"); + if(r.status===401){ ME={authenticated:false}; renderAdminPanel(); return; } // signed out elsewhere (Manage panel / another tab) + if(r.status!==200){ + // A number tells a moderator nothing. If the proxy could not reach the + // backend at all, say what that means and what to do about it, because + // the commonest cause is a restart this page itself set in motion. + adminShell(r.gatewayDown + ? '

The backend is not answering. If you have just updated, it is restarting: ' + + 'wait a moment and reload. If this persists, the service has stopped and needs ' + + 'starting on the box.

' + : '

Could not load submissions ('+esc(String(r.status))+').

'); + return; + } + const subs=r.body.submissions||[]; + const pending=subs.filter(s=>s.status==="pending"); + const others=subs.filter(s=>s.status!=="pending"); + adminShell( + '

Signed in as '+esc(ME.paymentCode.slice(0,12))+'\u2026'+esc(ME.paymentCode.slice(-4))+' '+ + ' '+ + '(the same Auth47 session as Manage my Dojo; signing out here signs you out there too)

'+ + updatesLine()+ + importLine()+ + (ADMIN_NOTICE?'

'+esc(ADMIN_NOTICE)+'

':"")+ + '

Pending review ('+pending.length+')

'+ + (pending.length? pending.map(adminRow).join("") : '

Nothing awaiting review.

')+ + '

Approved / rejected ('+others.length+')

'+ + (others.length? others.map(adminRow).join("") : '

None.

') + ); + } + let ADMIN_NOTICE = null; + let ADMIN_UPDATES = null, ADMIN_UPDATES_LOADING = false, ADMIN_LAST = null; + // The import job, polled the same way an update is. Declared here, above the + // functions that read it, because a const or let below its first reader has + // failed outright in this codebase before. + let IMPORT_RUN = null; // {phase, log[], done, ok, error, apply, onion, result} + let IMPORT_POLL = null; + // The onion and code of the plan being looked at, so applying does not ask + // for them again. Held only here: the payment code is public, but which + // instance this operator is pairing with is not written anywhere. + let IMPORT_LAST = null; + let UPDATE_RUN = null; // {phase, log[], done, ok, error, needsRefresh} + let UPDATE_POLL = null; + const UPDATE_PHASES = ["starting","fetching","applying","restarting"]; + function updatesLine(){ + if(UPDATE_RUN) return updateProgress(); + if(!ADMIN_UPDATES) return ADMIN_UPDATES_LOADING ? '

Checking for updates…

' : ""; + const u = ADMIN_UPDATES; + // A failed check hides what GitHub would have told us. It must not hide the + // peer update, which never touches GitHub and is the one route still open + // when GitHub is the thing that is unavailable. This used to return here, + // so an operator whose exit had been rate-limited was left with no controls + // at all and no way to update from another instance. + if(!u.available) return '

Update check unavailable: ' + + esc(u.error||"unknown") + '

' + + '
' + + ' ' + + '
' + + '

A peer update fetches from another Dojo Bay over Tor ' + + 'and verifies that instance\u2019s operator signature, so it works whatever GitHub is doing.

'; + const behind = u.commits_behind>0 + ? ''+u.commits_behind+' commit'+(u.commits_behind===1?"":"s")+' behind main' + : 'up to date with main'; + // current_release is set when the running commit IS a released tag, so we can + // say which release this is rather than guessing from timestamps. When it is + // not set the count is a timestamp approximation, and says so. + // releases_behind is null when the tag lookup itself failed. Saying nothing + // is better than a number that is systematically wrong for the commonest + // case, an instance running the newest release. + const rel = !u.latest_release ? "" + : u.releases_behind === null + ? ' · latest release '+esc(u.latest_release)+' (could not confirm which release this build is' + +(u.releases_note?': '+esc(u.releases_note):"")+')' + : u.releases_behind>0 + ? ' · '+u.releases_behind+' release'+(u.releases_behind===1?"":"s")+' behind' + +(u.releases_behind_approx?' (approximate; latest ':' (latest ')+esc(u.latest_release)+')' + : u.current_release + ? ' · running release '+esc(u.current_release)+'' + : ' · latest release '+esc(u.latest_release); + const behindAny = u.commits_behind>0 || (u.releases_behind||0)>0; + // Disabled when there is nothing to fetch. Reinstalling the code you are + // already running is not a useful thing to offer as the primary green + // button, and on a path this consequential an idle click that restarts the + // service for no gain is a real cost. The peer button stays live, because + // pulling from a peer is a different question from being behind GitHub: a + // federated instance may want another operator's build at the same commit. + const controls = '
' + + '' + + '' + // The answer is cached for six hours, which is right for an unattended + // check over Tor and wrong for an operator who has just pushed while + // already signed in. Signing in discards the cache, so this is for the + // case that does not involve signing in again. + + '' + + '
'; + // How old the answer is, in the operator's terms rather than a timestamp + // they have to subtract from now. Shown always, because "up to date" means + // nothing without it: the panel would say the same thing six hours after + // the state stopped being true. + const age = (() => { + const t = Date.parse(u.checked_at || ""); + if (!isFinite(t)) return ""; + const mins = Math.max(0, Math.round((Date.now() - t) / 60000)); + const when = mins < 1 ? "just now" : mins < 60 ? mins + " min ago" + : Math.round(mins / 60) + (Math.round(mins / 60) === 1 ? " hour ago" : " hours ago"); + return '

Checked ' + when + + (u.refresh_wait_s + ? '. GitHub was asked less than a minute ago, so this is the same answer; try again in ' + + u.refresh_wait_s + 's.' + : '') + + '

'; + })(); + // Marked experimental in the panel itself rather than only in the docs, + // because the person about to click is not reading the docs. This stays + // until a self-update has completed on real hardware; when it goes, the + // note in README goes with it. + // Surfaced whenever the last attempt did not finish cleanly, and only then: + // a banner after every successful update would be noise, and noise is what + // this one needs to stand out from. + const stale = ADMIN_LAST && (ADMIN_LAST.ok === false || ADMIN_LAST.restarting === false) + ? '

The last update did not finish. ' + + esc(ADMIN_LAST.error || ADMIN_LAST.note || "no reason recorded") + + ' Until the service restarts, this page is being served by the old code, ' + + 'so it will keep reporting whatever it knew before the update.

' + : ""; + // Said before the click, not after the failure. Without the privilege an + // update applies and then stops on its last line, leaving new code on disk + // and the old process serving it, which is indistinguishable from nothing + // having happened. + // Three answers, and "unknown" is not "yes". The remedy is a polkit rule + // because nothing calls sudo: apply-update.mjs runs systemctl directly, so + // a sudoers line, which this used to print, grants a privilege on a path no + // code takes. + const svc = esc(u.serviceUser || ""); + // Nothing is claimed about the restart permission. The instance cannot + // find out: polkit refuses a details-bearing authorisation query from any + // caller that is not uid 0, and the rules directory is not readable by the + // service account. Two versions of this warning have now been wrong, one + // saying yes when it had asked nobody and one saying unknown on a machine + // where the rule was installed and working, and a warning that is wrong in + // both directions is worse than no warning. + // + // The evidence-based version of this already exists a few lines up: an + // update that installed and did not restart is recorded in lastResult and + // reported, and that is precisely what a missing permission looks like. + const note = '

This fetches over Tor, verifies what it fetched, keeps a full copy ' + + 'of the current code under data/backups/, and restarts the service. If the restart does ' + + 'not come back you will need shell access to the box to recover, so do not run it where you cannot ' + + 'reach a terminal. Updating by deploy or by hand remains the supported path.

'; + return '

Codebase '+esc(u.commit)+' — '+behind+rel + + 'experimental

' + + stale + + controls + + age + + (behindAny ? '' : '

Up to date. There is nothing to fetch from GitHub.

') + + note + + '
'; + } + function updateProgress(){ + const j = UPDATE_RUN; + const idx = Math.max(0, UPDATE_PHASES.indexOf(j.phase)); + const pct = j.done ? 100 : Math.round(((idx+0.5)/UPDATE_PHASES.length)*100); + const barColor = j.error ? 'var(--down)' : (j.done? 'var(--up)' : 'var(--accent)'); + const tail = (j.log||[]).slice(-6).map(l=>esc(l)).join('
'); + let head; + if(j.error) head = 'Update failed: '+esc(j.error); + else if(j.done && j.needsRefresh) head = 'Update applied. Waiting for the service to come back, then reloading…'; + else head = 'Updating from '+esc(j.sourceLabel||j.source||"source")+'… '+esc(j.phase); + return '
' + + '

'+head+'

' + + '
' + + '
'+tail+'
' + + (j.error? '':'') + + '
'; + } + // Importing listings from another Dojo Bay. + // + // Two steps, always. The premise of importing from another directory is that + // you do not trust it, so the operator sees the plan before anything is + // written: what would be imported, what this instance already lists under a + // different id, and what is refused and why. Apply only appears once a plan + // has come back, so the button that writes cannot be the first one clicked. + function importLine(){ + const j = IMPORT_RUN; + if(!j){ + return '
' + + '' + + '

' + + 'Fetches another instance\u2019s published list over Tor and shows what it would add. ' + + 'Nothing is written until you say so, every listing\u2019s own signature is checked here, ' + + 'and anything imported arrives in Pending review rather than on the site.

'; + } + const res = j.result || null; + const rows = (res && res.plan) || []; + const counts = res + ? [res.planned + ' to import', res.merged + ' already listed here', res.refused + ' refused'] + : []; + // The refused rows are the ones worth reading, so they are not collapsed + // away: a directory publishing listings this instance will not accept is + // something an operator should see rather than a number. + const table = rows.length + ? '' + + rows.map(r => '').join("") + + '
NodeNetworkPairing
' + esc(r.name) + + (r.paynym ? ' ' + esc(r.paynym) + '' : '') + + '' + esc(r.network || "") + '' + + esc(String(r.url || "").replace(/^https?:\/\//, "").slice(0, 22)) + '\u2026' + + (r.action === "import" ? 'import' + : r.action === "merge" ? 'already listed as ' + esc(r.dupOf || "") + : 'refused: ' + esc(r.why || "")) + '
' + : ""; + const apply = (j.done && j.ok && !j.apply && res && res.planned > 0) + ? ' ' + : ""; + // The phase, while it is running. Importing is quick and the probe cycle + // afterwards takes about a minute, so a panel that said only "Importing…" + // for that minute would look stuck at the point it is doing the slowest + // and least obvious part of the work. + const phase = j.done ? "" : ({ planning: "reading their list", + importing: "importing", rebuilding: "rebuilding the public list", + probing: "probing the imported nodes over Tor, about a minute" }[j.phase] || j.phase || ""); + return '

' + (j.apply ? 'Importing from ' : 'Planning an import from ') + + '' + esc(j.onion || "") + '' + + (j.done ? "" : ' \u2026 ' + esc(phase)) + '

' + + (j.error ? '

' + esc(j.error) + '

' : "") + + (counts.length ? '

' + counts.join(' \u00b7 ') + '

' : "") + + table + + (j.done && j.apply && j.ok + ? '

Imported. They are in Pending review below, ' + + 'unpublished until you approve them.

' : "") + + '

' + apply + + '

'; + } + + async function startImport(onion, code, apply){ + IMPORT_RUN = { phase:"starting", log:[], done:false, ok:false, apply, onion, result:null, + error:null }; + renderAdminPanel(); + const r = await api.call("/admin/import","POST",{ onion, code, apply }); + if(r.status===409){ IMPORT_RUN=null; ADMIN_NOTICE="An import is already in progress."; renderAdminPanel(); return; } + if(r.status!==202){ IMPORT_RUN.error=(r.body&&r.body.error)||("HTTP "+r.status); IMPORT_RUN.done=true; renderAdminPanel(); return; } + // The onion and code are kept only in this closure, for the apply step, so + // that applying does not ask for them a second time. They are not written + // anywhere: the payment code is public, but the pairing is between this + // operator and that instance and does not belong in storage. + IMPORT_LAST = { onion, code }; + pollImport(); + } + function pollImport(){ + clearInterval(IMPORT_POLL); + IMPORT_POLL = setInterval(async ()=>{ + let r; try{ r = await api.call("/admin/import/status"); } catch(e){ return; } + if(r.status!==200 || !r.body || !r.body.job) return; + IMPORT_RUN = r.body.job; + if(IMPORT_RUN.done){ clearInterval(IMPORT_POLL); IMPORT_POLL=null; } + renderAdminPanel(); + }, 1200); + } + + async function startUpdate(source, extra){ + UPDATE_RUN = /** @type {{ phase: string, log: string[], done: boolean, source: any, error?: string }} */ + ({ phase:"starting", log:["requesting update…"], done:false, source }); + renderAdminPanel(); + const r = await api.call("/admin/update","POST",{ source, ...(extra||{}) }); + if(r.status===409){ UPDATE_RUN=null; ADMIN_NOTICE="An update is already in progress."; renderAdminPanel(); return; } + if(r.status!==202){ UPDATE_RUN.error=(r.body&&r.body.error)||("HTTP "+r.status); UPDATE_RUN.done=true; renderAdminPanel(); return; } + pollUpdate(); + } + function pollUpdate(){ + clearInterval(UPDATE_POLL); + let restartWaits = 0; + UPDATE_POLL = setInterval(async ()=>{ + let r; + try{ r = await api.call("/admin/update/status"); } + catch(e){ r = null; } + // Once the service restarts, /api calls fail transiently; treat a run + // that reached needsRefresh as success and hard-reload when it returns. + if(UPDATE_RUN && UPDATE_RUN.needsRefresh){ + if(!r || r.status!==200){ restartWaits++; return; } // backend still down + // backend answered again -> new code is live -> hard reload + clearInterval(UPDATE_POLL); + location.reload(); + return; + } + if(!r || r.status!==200) return; + const j = r.body && r.body.job; + if(j){ UPDATE_RUN = { ...UPDATE_RUN, ...j }; renderAdminPanel(); } + if(j && j.done){ + if(j.ok && j.needsRefresh){ + UPDATE_RUN.needsRefresh = true; // next successful poll after restart triggers reload + } else { + clearInterval(UPDATE_POLL); + } + } + }, 1200); + } + document.addEventListener("click", async e=>{ + const b=evEl(e)?.closest("[data-adm]"); if(!b) return; + const act=b.getAttribute("data-adm"), id=b.getAttribute("data-id"); + if(act==="logout"){ await api.call("/logout","POST",{}); ME={authenticated:false}; ADM_EDIT_ID=null; renderAdminPanel(); return; } + if(act==="update-github"){ + // The button is disabled when there is nothing to fetch, but a disabled + // attribute is a hint to a person rather than a guarantee: check the state + // that matters rather than trusting the markup. + if(!(ADMIN_UPDATES && (ADMIN_UPDATES.commits_behind>0 || (ADMIN_UPDATES.releases_behind||0)>0))){ + alert("This instance is already on the latest commit. There is nothing to fetch."); return; + } + if(confirm("Update this instance from GitHub over Tor?\n\nSELF-UPDATE IS EXPERIMENTAL. The service will restart; if it does not come back you will need shell access to the box. A full copy of the current code is kept under data/backups/.")) startUpdate("github"); return; } + if(act==="import-plan"){ + const onion=prompt("The .onion of the Dojo Bay to import from:"); if(!onion) return; + const code=prompt("That instance operator's BIP47 payment code (verifies whose list this is):")||""; + startImport(String(onion).replace(/^https?:\/\//,"").replace(/\/.*$/,""), code, false); + return; + } + if(act==="import-apply"){ + // The plan on screen was produced from these details, so applying re-uses + // them rather than asking again: retyping an onion between seeing a plan + // and accepting it is a chance to accept a plan from somewhere else. + if(!IMPORT_LAST) return; + const n = (IMPORT_RUN && IMPORT_RUN.result && IMPORT_RUN.result.planned) || 0; + if(!confirm("Import "+n+" listing"+(n===1?"":"s")+" from "+IMPORT_LAST.onion+"?\n\nThey arrive as Pending review and are not published until you approve them. Their signatures have already been verified here.")) return; + startImport(IMPORT_LAST.onion, IMPORT_LAST.code, true); + return; + } + if(act==="import-dismiss"){ clearInterval(IMPORT_POLL); IMPORT_POLL=null; IMPORT_RUN=null; renderAdminPanel(); return; } + if(act==="update-peer"){ + const onion=prompt("Trusted peer .onion to update from:"); if(!onion) return; + const code=prompt("That operator's BIP47 payment code (verifies who you're trusting):")||""; + if(confirm("Update this instance from "+onion+" over Tor?\n\nSELF-UPDATE IS EXPERIMENTAL. You are also trusting that peer's copy of the code. The service will restart; if it does not come back you will need shell access to the box.")) startUpdate("peer",{onion,code}); + return; + } + if(act==="update-dismiss"){ UPDATE_RUN=null; clearInterval(UPDATE_POLL); ADMIN_UPDATES=null; renderAdminPanel(); return; } + if(act==="update-recheck"){ + // Keep the current answer on screen while the new one is fetched. Clearing + // it first would replace a panel that says something with one that says + // nothing, and over Tor that gap is seconds rather than instant. + if(ADMIN_UPDATES_LOADING) return; + ADMIN_UPDATES_LOADING = true; renderAdminPanel(); + const r = await api.call("/admin/updates?refresh=1"); + ADMIN_UPDATES = r.body || {available:false,error:"HTTP "+r.status}; + ADMIN_UPDATES_LOADING = false; renderAdminPanel(); + return; + } + if(act==="edit"){ ADM_EDIT_ID=b.getAttribute("data-id"); renderAdminPanel(); return; } + if(act==="editcancel"){ ADM_EDIT_ID=null; renderAdminPanel(); return; } + if(act==="editsave"){ + const box=b.closest(".medit"); + const r=await api.call("/admin/edit","POST",{ + id:b.getAttribute("data-id"), + name:/** @type {HTMLInputElement} */ (box.querySelector(".e-name")).value, + hardware:/** @type {HTMLInputElement} */ (box.querySelector(".e-hw")).value, + }); + if(r.status!==200){ const em=box.querySelector(".edit-msg"); if(em) em.textContent=(r.body&&r.body.error)||("HTTP "+r.status); return; } + ADM_EDIT_ID=null; renderAdminPanel(); return; + } + if(act==="remove" && !confirm("Remove this submission permanently?")) return; + /** @type {HTMLButtonElement} */ (b).disabled=true; const o=b.textContent; b.textContent="\u2026"; + let r=null; + if(act==="approve") r=await api.call("/admin/approve","POST",{id}); + else if(act==="reject") r=await api.call("/admin/reject","POST",{id}); + else if(act==="remove") r=await api.call("/admin/remove","POST",{id}); + // The moderation change and the publish (rebuild of data/dojos.json) are + // two steps; report a failure of either, rather than silently showing a + // node as approved that never reached the public list. + ADMIN_NOTICE = null; + if(r && r.status!==200) ADMIN_NOTICE = act+" failed: "+((r.body&&r.body.error)||("HTTP "+r.status)); + else if(r && r.body && r.body.rebuild && r.body.rebuild.error) ADMIN_NOTICE = act+" saved, but publishing failed: "+r.body.rebuild.error; + await refreshMe(); renderAdminPanel(); + }); + + const IS_ADMIN_PAGE = location.pathname.replace(/\/+$/,"") === "/admin"; + + // Keep the open page current. + // + // The staleness banner reads DOJOS.generated_at, which is when the INSTANCE + // last rebuilt the file. A tab left open all day would therefore raise it even + // on a perfectly healthy directory, because the page's copy had aged while the + // server's had not. So the page refetches on the same cadence the instance + // publishes, and the banner then means what it says: the directory itself has + // stopped refreshing. + // + // Three restraints, because every request here is a Tor round trip: + // - only while the tab is actually visible; a backgrounded tab polls nothing + // - an immediate refetch when a hidden tab is brought back, rather than + // waiting out the remainder of an interval with stale data on screen + // - never re-render underneath an open dialog; the data is taken, and the + // redraw waits until the dialog is closed. + let REFRESH_TIMER = null, PENDING_RENDER = false; + + function modalOpen(){ + const ov = document.getElementById("ov"); + return !!(ov && ov.classList.contains("show")); + } + + async function refreshData(){ + // Never on /admin. render() paints the directory into #root, so refreshing + // there replaced the admin panel with the main page — which looked like the + // console spontaneously redirecting. + if(IS_ADMIN_PAGE) return; + if(document.visibilityState !== "visible") return; + try{ + const [d,h] = await Promise.all([loadJSON("data/dojos.json"), loadJSON("data/history.json")]); + if(!d || !Array.isArray(d.nodes)) return; // ignore a malformed reply + DOJOS = d; HIST = h || HIST; + HIST90 = null; DAILY = {nodes:{}}; // let the 90-day strips reload lazily + if(modalOpen()){ PENDING_RENDER = true; return; } + render(); + }catch(e){ /* a failed poll keeps the last good data; staleness will show if it persists */ } + } + + function scheduleRefresh(){ + if(REFRESH_TIMER) clearInterval(REFRESH_TIMER); + const mins = Number(DOJOS && DOJOS.interval_minutes) > 0 ? Number(DOJOS.interval_minutes) : 10; + REFRESH_TIMER = setInterval(refreshData, Math.max(60, mins*60) * 1000); + } + + document.addEventListener("visibilitychange", ()=>{ + if(!IS_ADMIN_PAGE && document.visibilityState === "visible") refreshData(); + }); + + (async function(){ + if(IS_ADMIN_PAGE){ document.title="Admin \u2014 The Dojo Bay"; await refreshMe(); renderAdminPanel(); return; } + try{ + [DOJOS,HIST]=await Promise.all([loadJSON("data/dojos.json"),loadJSON("data/history.json"),loadHist90()]); + render(); + scheduleRefresh(); + loadVersion(); + loadOperator(); + }catch(e){ showLoadError(e); } + })(); +})(); diff --git a/docker/dojobay/assets/js/markdown.js b/docker/dojobay/assets/js/markdown.js new file mode 100644 index 00000000..c17fd052 --- /dev/null +++ b/docker/dojobay/assets/js/markdown.js @@ -0,0 +1,109 @@ +// Minimal, dependency-free Markdown renderer. +// Supports the subset used by the content/*.md files: headings (#..######), +// paragraphs, unordered lists (- / *), blockquotes (>), and the inline forms +// **bold**, `code`, and [text](url). HTML in the source is escaped, so content +// authors can write plain Markdown without worrying about markup. +// +// ON TRUST. Everything this renders today is written by whoever maintains the +// instance and shipped in the repository: content/about.md and content/faq.md, +// and nothing else calls markdown.render. Under that assumption the escaping +// below is a convenience, not a boundary, because an author who wanted a script +// tag on the page could simply put one in index.html. +// +// It is nonetheless written as though the input were hostile, because the gap +// between "only maintainers write this" and "anyone can" is one call site. If a +// future change renders ANY of the following, this file becomes a real security +// boundary and should be read again with that in mind: +// - a submission field (node name, jurisdiction, hardware, the operator note) +// - anything fetched from another instance, including during a bootstrap +// import or a federated update +// - a file an operator can drop into content/ without a commit +// Two things in particular were fixed ahead of that day: the quote character +// was not escaped, so a link URL could close the href attribute and open a new +// one (browsers accept `href="x"onfocus=…` without whitespace); and any scheme +// at all was accepted, so javascript: and data: URLs became live links. +(function (global) { + // Quotes included. Without them, escaping is enough for TEXT but not for an + // attribute value, and the link rule below interpolates into href="…". + function escapeHtml(s) { + return s.replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + }[c])); + } + + // An allowlist, not a denylist of the schemes that happen to be dangerous + // today. http and https cover every link in the content and every link a + // reader of an onion site should be following; anything else, including + // javascript:, data:, vbscript: and file:, renders as plain text so the + // author can see their link did not work rather than shipping a live one. + // + // Applied to the RAW url, before entity-escaping: "javascript:x" is not + // a scheme this accepts, and the check must not be fooled by a spelling that + // only becomes a scheme after the browser decodes it. Leading control + // characters and whitespace are stripped first for the same reason, since + // browsers ignore them when resolving a URL. + function safeUrl(u) { + const cleaned = u.replace(/[\u0000-\u0020]/g, ""); + // A scheme is everything before the first colon, if that comes before the + // first slash, question mark or hash. No colon in that position means a + // relative URL, which cannot execute anything. + const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(cleaned); + if (!m) return !/^\/\//.test(cleaned) ? cleaned : null; // protocol-relative is not relative + const scheme = m[1].toLowerCase(); + return scheme === "http" || scheme === "https" ? cleaned : null; + } + + function inline(s) { + s = escapeHtml(s); + s = s.replace(/`([^`]+)`/g, (_, c) => "" + c + ""); + s = s.replace(/\*\*([^*]+)\*\*/g, "$1"); + s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (whole, t, u) => { + // u arrives already entity-escaped, and that is fine to judge directly: + // none of & < > " ' is a legal scheme character, so escaping cannot turn + // a dangerous scheme into an acceptable one or the reverse. Decoding + // first, which an earlier version did to "see what the browser sees", + // bought nothing and introduced a double-unescape (CodeQL js/double- + // escaping) where &#39; unwound one layer too many. + if (!safeUrl(u)) return whole; // leave the markdown visible, unlinked + return '' + t + ""; + }); + return s; + } + function render(md) { + const lines = String(md).replace(/\r\n/g, "\n").split("\n"); + let html = "", i = 0; + while (i < lines.length) { + const line = lines[i]; + if (/^\s*$/.test(line)) { i++; continue; } + + const h = line.match(/^(#{1,6})\s+(.*)$/); + if (h) { const l = h[1].length; html += `${inline(h[2].trim())}`; i++; continue; } + + if (/^\s*>/.test(line)) { // blockquote (recurses) + const block = []; + while (i < lines.length && /^\s*>/.test(lines[i])) { block.push(lines[i].replace(/^\s*>\s?/, "")); i++; } + html += "
" + render(block.join("\n")) + "
"; + continue; + } + if (/^\s*[-*]\s+/.test(line)) { // unordered list + html += "
    "; + while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) { + html += "
  • " + inline(lines[i].replace(/^\s*[-*]\s+/, "")) + "
  • "; i++; + } + html += "
"; + continue; + } + const para = []; // paragraph + while (i < lines.length && !/^\s*$/.test(lines[i]) && + !/^(#{1,6})\s/.test(lines[i]) && !/^\s*>/.test(lines[i]) && !/^\s*[-*]\s+/.test(lines[i])) { + para.push(lines[i].trim()); i++; + } + html += "

" + inline(para.join(" ")) + "

"; + } + return html; + } + + const api = { render }; + if (typeof module !== "undefined" && module.exports) module.exports = api; + global.markdown = api; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/docker/dojobay/assets/js/qrcode.js b/docker/dojobay/assets/js/qrcode.js new file mode 100644 index 00000000..df13f829 --- /dev/null +++ b/docker/dojobay/assets/js/qrcode.js @@ -0,0 +1,2297 @@ +//--------------------------------------------------------------------- +// +// QR Code Generator for JavaScript +// +// Copyright (c) 2009 Kazuhiko Arase +// +// URL: http://www.d-project.com/ +// +// Licensed under the MIT license: +// http://www.opensource.org/licenses/mit-license.php +// +// The word 'QR Code' is registered trademark of +// DENSO WAVE INCORPORATED +// http://www.denso-wave.com/qrcode/faqpatent-e.html +// +//--------------------------------------------------------------------- + +var qrcode = function() { + + //--------------------------------------------------------------------- + // qrcode + //--------------------------------------------------------------------- + + /** + * qrcode + * @param typeNumber 1 to 40 + * @param errorCorrectionLevel 'L','M','Q','H' + */ + var qrcode = function(typeNumber, errorCorrectionLevel) { + + var PAD0 = 0xEC; + var PAD1 = 0x11; + + var _typeNumber = typeNumber; + var _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel]; + var _modules = null; + var _moduleCount = 0; + var _dataCache = null; + var _dataList = []; + + var _this = {}; + + var makeImpl = function(test, maskPattern) { + + _moduleCount = _typeNumber * 4 + 17; + _modules = function(moduleCount) { + var modules = new Array(moduleCount); + for (var row = 0; row < moduleCount; row += 1) { + modules[row] = new Array(moduleCount); + for (var col = 0; col < moduleCount; col += 1) { + modules[row][col] = null; + } + } + return modules; + }(_moduleCount); + + setupPositionProbePattern(0, 0); + setupPositionProbePattern(_moduleCount - 7, 0); + setupPositionProbePattern(0, _moduleCount - 7); + setupPositionAdjustPattern(); + setupTimingPattern(); + setupTypeInfo(test, maskPattern); + + if (_typeNumber >= 7) { + setupTypeNumber(test); + } + + if (_dataCache == null) { + _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList); + } + + mapData(_dataCache, maskPattern); + }; + + var setupPositionProbePattern = function(row, col) { + + for (var r = -1; r <= 7; r += 1) { + + if (row + r <= -1 || _moduleCount <= row + r) continue; + + for (var c = -1; c <= 7; c += 1) { + + if (col + c <= -1 || _moduleCount <= col + c) continue; + + if ( (0 <= r && r <= 6 && (c == 0 || c == 6) ) + || (0 <= c && c <= 6 && (r == 0 || r == 6) ) + || (2 <= r && r <= 4 && 2 <= c && c <= 4) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + }; + + var getBestMaskPattern = function() { + + var minLostPoint = 0; + var pattern = 0; + + for (var i = 0; i < 8; i += 1) { + + makeImpl(true, i); + + var lostPoint = QRUtil.getLostPoint(_this); + + if (i == 0 || minLostPoint > lostPoint) { + minLostPoint = lostPoint; + pattern = i; + } + } + + return pattern; + }; + + var setupTimingPattern = function() { + + for (var r = 8; r < _moduleCount - 8; r += 1) { + if (_modules[r][6] != null) { + continue; + } + _modules[r][6] = (r % 2 == 0); + } + + for (var c = 8; c < _moduleCount - 8; c += 1) { + if (_modules[6][c] != null) { + continue; + } + _modules[6][c] = (c % 2 == 0); + } + }; + + var setupPositionAdjustPattern = function() { + + var pos = QRUtil.getPatternPosition(_typeNumber); + + for (var i = 0; i < pos.length; i += 1) { + + for (var j = 0; j < pos.length; j += 1) { + + var row = pos[i]; + var col = pos[j]; + + if (_modules[row][col] != null) { + continue; + } + + for (var r = -2; r <= 2; r += 1) { + + for (var c = -2; c <= 2; c += 1) { + + if (r == -2 || r == 2 || c == -2 || c == 2 + || (r == 0 && c == 0) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + } + } + }; + + var setupTypeNumber = function(test) { + + var bits = QRUtil.getBCHTypeNumber(_typeNumber); + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod; + } + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod; + } + }; + + var setupTypeInfo = function(test, maskPattern) { + + var data = (_errorCorrectionLevel << 3) | maskPattern; + var bits = QRUtil.getBCHTypeInfo(data); + + // vertical + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 6) { + _modules[i][8] = mod; + } else if (i < 8) { + _modules[i + 1][8] = mod; + } else { + _modules[_moduleCount - 15 + i][8] = mod; + } + } + + // horizontal + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 8) { + _modules[8][_moduleCount - i - 1] = mod; + } else if (i < 9) { + _modules[8][15 - i - 1 + 1] = mod; + } else { + _modules[8][15 - i - 1] = mod; + } + } + + // fixed module + _modules[_moduleCount - 8][8] = (!test); + }; + + var mapData = function(data, maskPattern) { + + var inc = -1; + var row = _moduleCount - 1; + var bitIndex = 7; + var byteIndex = 0; + var maskFunc = QRUtil.getMaskFunction(maskPattern); + + for (var col = _moduleCount - 1; col > 0; col -= 2) { + + if (col == 6) col -= 1; + + while (true) { + + for (var c = 0; c < 2; c += 1) { + + if (_modules[row][col - c] == null) { + + var dark = false; + + if (byteIndex < data.length) { + dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1); + } + + var mask = maskFunc(row, col - c); + + if (mask) { + dark = !dark; + } + + _modules[row][col - c] = dark; + bitIndex -= 1; + + if (bitIndex == -1) { + byteIndex += 1; + bitIndex = 7; + } + } + } + + row += inc; + + if (row < 0 || _moduleCount <= row) { + row -= inc; + inc = -inc; + break; + } + } + } + }; + + var createBytes = function(buffer, rsBlocks) { + + var offset = 0; + + var maxDcCount = 0; + var maxEcCount = 0; + + var dcdata = new Array(rsBlocks.length); + var ecdata = new Array(rsBlocks.length); + + for (var r = 0; r < rsBlocks.length; r += 1) { + + var dcCount = rsBlocks[r].dataCount; + var ecCount = rsBlocks[r].totalCount - dcCount; + + maxDcCount = Math.max(maxDcCount, dcCount); + maxEcCount = Math.max(maxEcCount, ecCount); + + dcdata[r] = new Array(dcCount); + + for (var i = 0; i < dcdata[r].length; i += 1) { + dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset]; + } + offset += dcCount; + + var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount); + var rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1); + + var modPoly = rawPoly.mod(rsPoly); + ecdata[r] = new Array(rsPoly.getLength() - 1); + for (var i = 0; i < ecdata[r].length; i += 1) { + var modIndex = i + modPoly.getLength() - ecdata[r].length; + ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0; + } + } + + var totalCodeCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalCodeCount += rsBlocks[i].totalCount; + } + + var data = new Array(totalCodeCount); + var index = 0; + + for (var i = 0; i < maxDcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < dcdata[r].length) { + data[index] = dcdata[r][i]; + index += 1; + } + } + } + + for (var i = 0; i < maxEcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < ecdata[r].length) { + data[index] = ecdata[r][i]; + index += 1; + } + } + } + + return data; + }; + + var createData = function(typeNumber, errorCorrectionLevel, dataList) { + + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel); + + var buffer = qrBitBuffer(); + + for (var i = 0; i < dataList.length; i += 1) { + var data = dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + // calc num max data. + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() > totalDataCount * 8) { + throw 'code length overflow. (' + + buffer.getLengthInBits() + + '>' + + totalDataCount * 8 + + ')'; + } + + // end code + if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) { + buffer.put(0, 4); + } + + // padding + while (buffer.getLengthInBits() % 8 != 0) { + buffer.putBit(false); + } + + // padding + while (true) { + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD0, 8); + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD1, 8); + } + + return createBytes(buffer, rsBlocks); + }; + + _this.addData = function(data, mode) { + + mode = mode || 'Byte'; + + var newData = null; + + switch(mode) { + case 'Numeric' : + newData = qrNumber(data); + break; + case 'Alphanumeric' : + newData = qrAlphaNum(data); + break; + case 'Byte' : + newData = qr8BitByte(data); + break; + case 'Kanji' : + newData = qrKanji(data); + break; + default : + throw 'mode:' + mode; + } + + _dataList.push(newData); + _dataCache = null; + }; + + _this.isDark = function(row, col) { + if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) { + throw row + ',' + col; + } + return _modules[row][col]; + }; + + _this.getModuleCount = function() { + return _moduleCount; + }; + + _this.make = function() { + if (_typeNumber < 1) { + var typeNumber = 1; + + for (; typeNumber < 40; typeNumber++) { + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel); + var buffer = qrBitBuffer(); + + for (var i = 0; i < _dataList.length; i++) { + var data = _dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i++) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() <= totalDataCount * 8) { + break; + } + } + + _typeNumber = typeNumber; + } + + makeImpl(false, getBestMaskPattern() ); + }; + + _this.createTableTag = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var qrHtml = ''; + + qrHtml += ''; + qrHtml += ''; + + for (var r = 0; r < _this.getModuleCount(); r += 1) { + + qrHtml += ''; + + for (var c = 0; c < _this.getModuleCount(); c += 1) { + qrHtml += ''; + } + + qrHtml += ''; + qrHtml += '
'; + } + + qrHtml += '
'; + + return qrHtml; + }; + + _this.createSvgTag = function(cellSize, margin, alt, title) { + + var opts = {}; + if (typeof arguments[0] == 'object') { + // Called by options. + opts = arguments[0]; + // overwrite cellSize and margin. + cellSize = opts.cellSize; + margin = opts.margin; + alt = opts.alt; + title = opts.title; + } + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + // Compose alt property surrogate + alt = (typeof alt === 'string') ? {text: alt} : alt || {}; + alt.text = alt.text || null; + alt.id = (alt.text) ? alt.id || 'qrcode-description' : null; + + // Compose title property surrogate + title = (typeof title === 'string') ? {text: title} : title || {}; + title.text = title.text || null; + title.id = (title.text) ? title.id || 'qrcode-title' : null; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var c, mc, r, mr, qrSvg='', rect; + + rect = 'l' + cellSize + ',0 0,' + cellSize + + ' -' + cellSize + ',0 0,-' + cellSize + 'z '; + + qrSvg += '' + + escapeXml(title.text) + '' : ''; + qrSvg += (alt.text) ? '' + + escapeXml(alt.text) + '' : ''; + qrSvg += ''; + qrSvg += ''; + qrSvg += ''; + + return qrSvg; + }; + + _this.createDataURL = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + return createDataURL(size, size, function(x, y) { + if (min <= x && x < max && min <= y && y < max) { + var c = Math.floor( (x - min) / cellSize); + var r = Math.floor( (y - min) / cellSize); + return _this.isDark(r, c)? 0 : 1; + } else { + return 1; + } + } ); + }; + + _this.createImgTag = function(cellSize, margin, alt) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + + var img = ''; + img += '': escaped += '>'; break; + case '&': escaped += '&'; break; + case '"': escaped += '"'; break; + default : escaped += c; break; + } + } + return escaped; + }; + + var _createHalfASCII = function(margin) { + var cellSize = 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r1, r2, p; + + var blocks = { + '██': '█', + '█ ': '▀', + ' █': '▄', + ' ': ' ' + }; + + var blocksLastLineNoMargin = { + '██': '▀', + '█ ': '▀', + ' █': ' ', + ' ': ' ' + }; + + var ascii = ''; + for (y = 0; y < size; y += 2) { + r1 = Math.floor((y - min) / cellSize); + r2 = Math.floor((y + 1 - min) / cellSize); + for (x = 0; x < size; x += 1) { + p = '█'; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) { + p = ' '; + } + + if (min <= x && x < max && min <= y+1 && y+1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) { + p += ' '; + } + else { + p += '█'; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + ascii += (margin < 1 && y+1 >= max) ? blocksLastLineNoMargin[p] : blocks[p]; + } + + ascii += '\n'; + } + + if (size % 2 && margin > 0) { + return ascii.substring(0, ascii.length - size - 1) + Array(size+1).join('▀'); + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.createASCII = function(cellSize, margin) { + cellSize = cellSize || 1; + + if (cellSize < 2) { + return _createHalfASCII(margin); + } + + cellSize -= 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r, p; + + var white = Array(cellSize+1).join('██'); + var black = Array(cellSize+1).join(' '); + + var ascii = ''; + var line = ''; + for (y = 0; y < size; y += 1) { + r = Math.floor( (y - min) / cellSize); + line = ''; + for (x = 0; x < size; x += 1) { + p = 1; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) { + p = 0; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + line += p ? white : black; + } + + for (r = 0; r < cellSize; r += 1) { + ascii += line + '\n'; + } + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.renderTo2dContext = function(context, cellSize) { + cellSize = cellSize || 2; + var length = _this.getModuleCount(); + for (var row = 0; row < length; row++) { + for (var col = 0; col < length; col++) { + context.fillStyle = _this.isDark(row, col) ? 'black' : 'white'; + context.fillRect(col * cellSize, row * cellSize, cellSize, cellSize); + } + } + } + + return _this; + }; + + //--------------------------------------------------------------------- + // qrcode.stringToBytes + //--------------------------------------------------------------------- + + qrcode.stringToBytesFuncs = { + 'default' : function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + bytes.push(c & 0xff); + } + return bytes; + } + }; + + qrcode.stringToBytes = qrcode.stringToBytesFuncs['default']; + + //--------------------------------------------------------------------- + // qrcode.createStringToBytes + //--------------------------------------------------------------------- + + /** + * @param unicodeData base64 string of byte array. + * [16bit Unicode],[16bit Bytes], ... + * @param numChars + */ + qrcode.createStringToBytes = function(unicodeData, numChars) { + + // create conversion map. + + var unicodeMap = function() { + + var bin = base64DecodeInputStream(unicodeData); + var read = function() { + var b = bin.read(); + if (b == -1) throw 'eof'; + return b; + }; + + var count = 0; + var unicodeMap = {}; + while (true) { + var b0 = bin.read(); + if (b0 == -1) break; + var b1 = read(); + var b2 = read(); + var b3 = read(); + var k = String.fromCharCode( (b0 << 8) | b1); + var v = (b2 << 8) | b3; + unicodeMap[k] = v; + count += 1; + } + if (count != numChars) { + throw count + ' != ' + numChars; + } + + return unicodeMap; + }(); + + var unknownChar = '?'.charCodeAt(0); + + return function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + if (c < 128) { + bytes.push(c); + } else { + var b = unicodeMap[s.charAt(i)]; + if (typeof b == 'number') { + if ( (b & 0xff) == b) { + // 1byte + bytes.push(b); + } else { + // 2bytes + bytes.push(b >>> 8); + bytes.push(b & 0xff); + } + } else { + bytes.push(unknownChar); + } + } + } + return bytes; + }; + }; + + //--------------------------------------------------------------------- + // QRMode + //--------------------------------------------------------------------- + + var QRMode = { + MODE_NUMBER : 1 << 0, + MODE_ALPHA_NUM : 1 << 1, + MODE_8BIT_BYTE : 1 << 2, + MODE_KANJI : 1 << 3 + }; + + //--------------------------------------------------------------------- + // QRErrorCorrectionLevel + //--------------------------------------------------------------------- + + var QRErrorCorrectionLevel = { + L : 1, + M : 0, + Q : 3, + H : 2 + }; + + //--------------------------------------------------------------------- + // QRMaskPattern + //--------------------------------------------------------------------- + + var QRMaskPattern = { + PATTERN000 : 0, + PATTERN001 : 1, + PATTERN010 : 2, + PATTERN011 : 3, + PATTERN100 : 4, + PATTERN101 : 5, + PATTERN110 : 6, + PATTERN111 : 7 + }; + + //--------------------------------------------------------------------- + // QRUtil + //--------------------------------------------------------------------- + + var QRUtil = function() { + + var PATTERN_POSITION_TABLE = [ + [], + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170] + ]; + var G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0); + var G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0); + var G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1); + + var _this = {}; + + var getBCHDigit = function(data) { + var digit = 0; + while (data != 0) { + digit += 1; + data >>>= 1; + } + return digit; + }; + + _this.getBCHTypeInfo = function(data) { + var d = data << 10; + while (getBCHDigit(d) - getBCHDigit(G15) >= 0) { + d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15) ) ); + } + return ( (data << 10) | d) ^ G15_MASK; + }; + + _this.getBCHTypeNumber = function(data) { + var d = data << 12; + while (getBCHDigit(d) - getBCHDigit(G18) >= 0) { + d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18) ) ); + } + return (data << 12) | d; + }; + + _this.getPatternPosition = function(typeNumber) { + return PATTERN_POSITION_TABLE[typeNumber - 1]; + }; + + _this.getMaskFunction = function(maskPattern) { + + switch (maskPattern) { + + case QRMaskPattern.PATTERN000 : + return function(i, j) { return (i + j) % 2 == 0; }; + case QRMaskPattern.PATTERN001 : + return function(i, j) { return i % 2 == 0; }; + case QRMaskPattern.PATTERN010 : + return function(i, j) { return j % 3 == 0; }; + case QRMaskPattern.PATTERN011 : + return function(i, j) { return (i + j) % 3 == 0; }; + case QRMaskPattern.PATTERN100 : + return function(i, j) { return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 == 0; }; + case QRMaskPattern.PATTERN101 : + return function(i, j) { return (i * j) % 2 + (i * j) % 3 == 0; }; + case QRMaskPattern.PATTERN110 : + return function(i, j) { return ( (i * j) % 2 + (i * j) % 3) % 2 == 0; }; + case QRMaskPattern.PATTERN111 : + return function(i, j) { return ( (i * j) % 3 + (i + j) % 2) % 2 == 0; }; + + default : + throw 'bad maskPattern:' + maskPattern; + } + }; + + _this.getErrorCorrectPolynomial = function(errorCorrectLength) { + var a = qrPolynomial([1], 0); + for (var i = 0; i < errorCorrectLength; i += 1) { + a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0) ); + } + return a; + }; + + _this.getLengthInBits = function(mode, type) { + + if (1 <= type && type < 10) { + + // 1 - 9 + + switch(mode) { + case QRMode.MODE_NUMBER : return 10; + case QRMode.MODE_ALPHA_NUM : return 9; + case QRMode.MODE_8BIT_BYTE : return 8; + case QRMode.MODE_KANJI : return 8; + default : + throw 'mode:' + mode; + } + + } else if (type < 27) { + + // 10 - 26 + + switch(mode) { + case QRMode.MODE_NUMBER : return 12; + case QRMode.MODE_ALPHA_NUM : return 11; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 10; + default : + throw 'mode:' + mode; + } + + } else if (type < 41) { + + // 27 - 40 + + switch(mode) { + case QRMode.MODE_NUMBER : return 14; + case QRMode.MODE_ALPHA_NUM : return 13; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 12; + default : + throw 'mode:' + mode; + } + + } else { + throw 'type:' + type; + } + }; + + _this.getLostPoint = function(qrcode) { + + var moduleCount = qrcode.getModuleCount(); + + var lostPoint = 0; + + // LEVEL1 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount; col += 1) { + + var sameCount = 0; + var dark = qrcode.isDark(row, col); + + for (var r = -1; r <= 1; r += 1) { + + if (row + r < 0 || moduleCount <= row + r) { + continue; + } + + for (var c = -1; c <= 1; c += 1) { + + if (col + c < 0 || moduleCount <= col + c) { + continue; + } + + if (r == 0 && c == 0) { + continue; + } + + if (dark == qrcode.isDark(row + r, col + c) ) { + sameCount += 1; + } + } + } + + if (sameCount > 5) { + lostPoint += (3 + sameCount - 5); + } + } + }; + + // LEVEL2 + + for (var row = 0; row < moduleCount - 1; row += 1) { + for (var col = 0; col < moduleCount - 1; col += 1) { + var count = 0; + if (qrcode.isDark(row, col) ) count += 1; + if (qrcode.isDark(row + 1, col) ) count += 1; + if (qrcode.isDark(row, col + 1) ) count += 1; + if (qrcode.isDark(row + 1, col + 1) ) count += 1; + if (count == 0 || count == 4) { + lostPoint += 3; + } + } + } + + // LEVEL3 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount - 6; col += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row, col + 1) + && qrcode.isDark(row, col + 2) + && qrcode.isDark(row, col + 3) + && qrcode.isDark(row, col + 4) + && !qrcode.isDark(row, col + 5) + && qrcode.isDark(row, col + 6) ) { + lostPoint += 40; + } + } + } + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount - 6; row += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row + 1, col) + && qrcode.isDark(row + 2, col) + && qrcode.isDark(row + 3, col) + && qrcode.isDark(row + 4, col) + && !qrcode.isDark(row + 5, col) + && qrcode.isDark(row + 6, col) ) { + lostPoint += 40; + } + } + } + + // LEVEL4 + + var darkCount = 0; + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount; row += 1) { + if (qrcode.isDark(row, col) ) { + darkCount += 1; + } + } + } + + var ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5; + lostPoint += ratio * 10; + + return lostPoint; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // QRMath + //--------------------------------------------------------------------- + + var QRMath = function() { + + var EXP_TABLE = new Array(256); + var LOG_TABLE = new Array(256); + + // initialize tables + for (var i = 0; i < 8; i += 1) { + EXP_TABLE[i] = 1 << i; + } + for (var i = 8; i < 256; i += 1) { + EXP_TABLE[i] = EXP_TABLE[i - 4] + ^ EXP_TABLE[i - 5] + ^ EXP_TABLE[i - 6] + ^ EXP_TABLE[i - 8]; + } + for (var i = 0; i < 255; i += 1) { + LOG_TABLE[EXP_TABLE[i] ] = i; + } + + var _this = {}; + + _this.glog = function(n) { + + if (n < 1) { + throw 'glog(' + n + ')'; + } + + return LOG_TABLE[n]; + }; + + _this.gexp = function(n) { + + while (n < 0) { + n += 255; + } + + while (n >= 256) { + n -= 255; + } + + return EXP_TABLE[n]; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrPolynomial + //--------------------------------------------------------------------- + + function qrPolynomial(num, shift) { + + if (typeof num.length == 'undefined') { + throw num.length + '/' + shift; + } + + var _num = function() { + var offset = 0; + while (offset < num.length && num[offset] == 0) { + offset += 1; + } + var _num = new Array(num.length - offset + shift); + for (var i = 0; i < num.length - offset; i += 1) { + _num[i] = num[i + offset]; + } + return _num; + }(); + + var _this = {}; + + _this.getAt = function(index) { + return _num[index]; + }; + + _this.getLength = function() { + return _num.length; + }; + + _this.multiply = function(e) { + + var num = new Array(_this.getLength() + e.getLength() - 1); + + for (var i = 0; i < _this.getLength(); i += 1) { + for (var j = 0; j < e.getLength(); j += 1) { + num[i + j] ^= QRMath.gexp(QRMath.glog(_this.getAt(i) ) + QRMath.glog(e.getAt(j) ) ); + } + } + + return qrPolynomial(num, 0); + }; + + _this.mod = function(e) { + + if (_this.getLength() - e.getLength() < 0) { + return _this; + } + + var ratio = QRMath.glog(_this.getAt(0) ) - QRMath.glog(e.getAt(0) ); + + var num = new Array(_this.getLength() ); + for (var i = 0; i < _this.getLength(); i += 1) { + num[i] = _this.getAt(i); + } + + for (var i = 0; i < e.getLength(); i += 1) { + num[i] ^= QRMath.gexp(QRMath.glog(e.getAt(i) ) + ratio); + } + + // recursive call + return qrPolynomial(num, 0).mod(e); + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // QRRSBlock + //--------------------------------------------------------------------- + + var QRRSBlock = function() { + + var RS_BLOCK_TABLE = [ + + // L + // M + // Q + // H + + // 1 + [1, 26, 19], + [1, 26, 16], + [1, 26, 13], + [1, 26, 9], + + // 2 + [1, 44, 34], + [1, 44, 28], + [1, 44, 22], + [1, 44, 16], + + // 3 + [1, 70, 55], + [1, 70, 44], + [2, 35, 17], + [2, 35, 13], + + // 4 + [1, 100, 80], + [2, 50, 32], + [2, 50, 24], + [4, 25, 9], + + // 5 + [1, 134, 108], + [2, 67, 43], + [2, 33, 15, 2, 34, 16], + [2, 33, 11, 2, 34, 12], + + // 6 + [2, 86, 68], + [4, 43, 27], + [4, 43, 19], + [4, 43, 15], + + // 7 + [2, 98, 78], + [4, 49, 31], + [2, 32, 14, 4, 33, 15], + [4, 39, 13, 1, 40, 14], + + // 8 + [2, 121, 97], + [2, 60, 38, 2, 61, 39], + [4, 40, 18, 2, 41, 19], + [4, 40, 14, 2, 41, 15], + + // 9 + [2, 146, 116], + [3, 58, 36, 2, 59, 37], + [4, 36, 16, 4, 37, 17], + [4, 36, 12, 4, 37, 13], + + // 10 + [2, 86, 68, 2, 87, 69], + [4, 69, 43, 1, 70, 44], + [6, 43, 19, 2, 44, 20], + [6, 43, 15, 2, 44, 16], + + // 11 + [4, 101, 81], + [1, 80, 50, 4, 81, 51], + [4, 50, 22, 4, 51, 23], + [3, 36, 12, 8, 37, 13], + + // 12 + [2, 116, 92, 2, 117, 93], + [6, 58, 36, 2, 59, 37], + [4, 46, 20, 6, 47, 21], + [7, 42, 14, 4, 43, 15], + + // 13 + [4, 133, 107], + [8, 59, 37, 1, 60, 38], + [8, 44, 20, 4, 45, 21], + [12, 33, 11, 4, 34, 12], + + // 14 + [3, 145, 115, 1, 146, 116], + [4, 64, 40, 5, 65, 41], + [11, 36, 16, 5, 37, 17], + [11, 36, 12, 5, 37, 13], + + // 15 + [5, 109, 87, 1, 110, 88], + [5, 65, 41, 5, 66, 42], + [5, 54, 24, 7, 55, 25], + [11, 36, 12, 7, 37, 13], + + // 16 + [5, 122, 98, 1, 123, 99], + [7, 73, 45, 3, 74, 46], + [15, 43, 19, 2, 44, 20], + [3, 45, 15, 13, 46, 16], + + // 17 + [1, 135, 107, 5, 136, 108], + [10, 74, 46, 1, 75, 47], + [1, 50, 22, 15, 51, 23], + [2, 42, 14, 17, 43, 15], + + // 18 + [5, 150, 120, 1, 151, 121], + [9, 69, 43, 4, 70, 44], + [17, 50, 22, 1, 51, 23], + [2, 42, 14, 19, 43, 15], + + // 19 + [3, 141, 113, 4, 142, 114], + [3, 70, 44, 11, 71, 45], + [17, 47, 21, 4, 48, 22], + [9, 39, 13, 16, 40, 14], + + // 20 + [3, 135, 107, 5, 136, 108], + [3, 67, 41, 13, 68, 42], + [15, 54, 24, 5, 55, 25], + [15, 43, 15, 10, 44, 16], + + // 21 + [4, 144, 116, 4, 145, 117], + [17, 68, 42], + [17, 50, 22, 6, 51, 23], + [19, 46, 16, 6, 47, 17], + + // 22 + [2, 139, 111, 7, 140, 112], + [17, 74, 46], + [7, 54, 24, 16, 55, 25], + [34, 37, 13], + + // 23 + [4, 151, 121, 5, 152, 122], + [4, 75, 47, 14, 76, 48], + [11, 54, 24, 14, 55, 25], + [16, 45, 15, 14, 46, 16], + + // 24 + [6, 147, 117, 4, 148, 118], + [6, 73, 45, 14, 74, 46], + [11, 54, 24, 16, 55, 25], + [30, 46, 16, 2, 47, 17], + + // 25 + [8, 132, 106, 4, 133, 107], + [8, 75, 47, 13, 76, 48], + [7, 54, 24, 22, 55, 25], + [22, 45, 15, 13, 46, 16], + + // 26 + [10, 142, 114, 2, 143, 115], + [19, 74, 46, 4, 75, 47], + [28, 50, 22, 6, 51, 23], + [33, 46, 16, 4, 47, 17], + + // 27 + [8, 152, 122, 4, 153, 123], + [22, 73, 45, 3, 74, 46], + [8, 53, 23, 26, 54, 24], + [12, 45, 15, 28, 46, 16], + + // 28 + [3, 147, 117, 10, 148, 118], + [3, 73, 45, 23, 74, 46], + [4, 54, 24, 31, 55, 25], + [11, 45, 15, 31, 46, 16], + + // 29 + [7, 146, 116, 7, 147, 117], + [21, 73, 45, 7, 74, 46], + [1, 53, 23, 37, 54, 24], + [19, 45, 15, 26, 46, 16], + + // 30 + [5, 145, 115, 10, 146, 116], + [19, 75, 47, 10, 76, 48], + [15, 54, 24, 25, 55, 25], + [23, 45, 15, 25, 46, 16], + + // 31 + [13, 145, 115, 3, 146, 116], + [2, 74, 46, 29, 75, 47], + [42, 54, 24, 1, 55, 25], + [23, 45, 15, 28, 46, 16], + + // 32 + [17, 145, 115], + [10, 74, 46, 23, 75, 47], + [10, 54, 24, 35, 55, 25], + [19, 45, 15, 35, 46, 16], + + // 33 + [17, 145, 115, 1, 146, 116], + [14, 74, 46, 21, 75, 47], + [29, 54, 24, 19, 55, 25], + [11, 45, 15, 46, 46, 16], + + // 34 + [13, 145, 115, 6, 146, 116], + [14, 74, 46, 23, 75, 47], + [44, 54, 24, 7, 55, 25], + [59, 46, 16, 1, 47, 17], + + // 35 + [12, 151, 121, 7, 152, 122], + [12, 75, 47, 26, 76, 48], + [39, 54, 24, 14, 55, 25], + [22, 45, 15, 41, 46, 16], + + // 36 + [6, 151, 121, 14, 152, 122], + [6, 75, 47, 34, 76, 48], + [46, 54, 24, 10, 55, 25], + [2, 45, 15, 64, 46, 16], + + // 37 + [17, 152, 122, 4, 153, 123], + [29, 74, 46, 14, 75, 47], + [49, 54, 24, 10, 55, 25], + [24, 45, 15, 46, 46, 16], + + // 38 + [4, 152, 122, 18, 153, 123], + [13, 74, 46, 32, 75, 47], + [48, 54, 24, 14, 55, 25], + [42, 45, 15, 32, 46, 16], + + // 39 + [20, 147, 117, 4, 148, 118], + [40, 75, 47, 7, 76, 48], + [43, 54, 24, 22, 55, 25], + [10, 45, 15, 67, 46, 16], + + // 40 + [19, 148, 118, 6, 149, 119], + [18, 75, 47, 31, 76, 48], + [34, 54, 24, 34, 55, 25], + [20, 45, 15, 61, 46, 16] + ]; + + var qrRSBlock = function(totalCount, dataCount) { + var _this = {}; + _this.totalCount = totalCount; + _this.dataCount = dataCount; + return _this; + }; + + var _this = {}; + + var getRsBlockTable = function(typeNumber, errorCorrectionLevel) { + + switch(errorCorrectionLevel) { + case QRErrorCorrectionLevel.L : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0]; + case QRErrorCorrectionLevel.M : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1]; + case QRErrorCorrectionLevel.Q : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2]; + case QRErrorCorrectionLevel.H : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3]; + default : + return undefined; + } + }; + + _this.getRSBlocks = function(typeNumber, errorCorrectionLevel) { + + var rsBlock = getRsBlockTable(typeNumber, errorCorrectionLevel); + + if (typeof rsBlock == 'undefined') { + throw 'bad rs block @ typeNumber:' + typeNumber + + '/errorCorrectionLevel:' + errorCorrectionLevel; + } + + var length = rsBlock.length / 3; + + var list = []; + + for (var i = 0; i < length; i += 1) { + + var count = rsBlock[i * 3 + 0]; + var totalCount = rsBlock[i * 3 + 1]; + var dataCount = rsBlock[i * 3 + 2]; + + for (var j = 0; j < count; j += 1) { + list.push(qrRSBlock(totalCount, dataCount) ); + } + } + + return list; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrBitBuffer + //--------------------------------------------------------------------- + + var qrBitBuffer = function() { + + var _buffer = []; + var _length = 0; + + var _this = {}; + + _this.getBuffer = function() { + return _buffer; + }; + + _this.getAt = function(index) { + var bufIndex = Math.floor(index / 8); + return ( (_buffer[bufIndex] >>> (7 - index % 8) ) & 1) == 1; + }; + + _this.put = function(num, length) { + for (var i = 0; i < length; i += 1) { + _this.putBit( ( (num >>> (length - i - 1) ) & 1) == 1); + } + }; + + _this.getLengthInBits = function() { + return _length; + }; + + _this.putBit = function(bit) { + + var bufIndex = Math.floor(_length / 8); + if (_buffer.length <= bufIndex) { + _buffer.push(0); + } + + if (bit) { + _buffer[bufIndex] |= (0x80 >>> (_length % 8) ); + } + + _length += 1; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrNumber + //--------------------------------------------------------------------- + + var qrNumber = function(data) { + + var _mode = QRMode.MODE_NUMBER; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var data = _data; + + var i = 0; + + while (i + 2 < data.length) { + buffer.put(strToNum(data.substring(i, i + 3) ), 10); + i += 3; + } + + if (i < data.length) { + if (data.length - i == 1) { + buffer.put(strToNum(data.substring(i, i + 1) ), 4); + } else if (data.length - i == 2) { + buffer.put(strToNum(data.substring(i, i + 2) ), 7); + } + } + }; + + var strToNum = function(s) { + var num = 0; + for (var i = 0; i < s.length; i += 1) { + num = num * 10 + chatToNum(s.charAt(i) ); + } + return num; + }; + + var chatToNum = function(c) { + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } + throw 'illegal char :' + c; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrAlphaNum + //--------------------------------------------------------------------- + + var qrAlphaNum = function(data) { + + var _mode = QRMode.MODE_ALPHA_NUM; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var s = _data; + + var i = 0; + + while (i + 1 < s.length) { + buffer.put( + getCode(s.charAt(i) ) * 45 + + getCode(s.charAt(i + 1) ), 11); + i += 2; + } + + if (i < s.length) { + buffer.put(getCode(s.charAt(i) ), 6); + } + }; + + var getCode = function(c) { + + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } else if ('A' <= c && c <= 'Z') { + return c.charCodeAt(0) - 'A'.charCodeAt(0) + 10; + } else { + switch (c) { + case ' ' : return 36; + case '$' : return 37; + case '%' : return 38; + case '*' : return 39; + case '+' : return 40; + case '-' : return 41; + case '.' : return 42; + case '/' : return 43; + case ':' : return 44; + default : + throw 'illegal char :' + c; + } + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qr8BitByte + //--------------------------------------------------------------------- + + var qr8BitByte = function(data) { + + var _mode = QRMode.MODE_8BIT_BYTE; + var _data = data; + var _bytes = qrcode.stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _bytes.length; + }; + + _this.write = function(buffer) { + for (var i = 0; i < _bytes.length; i += 1) { + buffer.put(_bytes[i], 8); + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrKanji + //--------------------------------------------------------------------- + + var qrKanji = function(data) { + + var _mode = QRMode.MODE_KANJI; + var _data = data; + + var stringToBytes = qrcode.stringToBytesFuncs['SJIS']; + if (!stringToBytes) { + throw 'sjis not supported.'; + } + !function(c, code) { + // self test for sjis support. + var test = stringToBytes(c); + if (test.length != 2 || ( (test[0] << 8) | test[1]) != code) { + throw 'sjis not supported.'; + } + }('\u53cb', 0x9746); + + var _bytes = stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return ~~(_bytes.length / 2); + }; + + _this.write = function(buffer) { + + var data = _bytes; + + var i = 0; + + while (i + 1 < data.length) { + + var c = ( (0xff & data[i]) << 8) | (0xff & data[i + 1]); + + if (0x8140 <= c && c <= 0x9FFC) { + c -= 0x8140; + } else if (0xE040 <= c && c <= 0xEBBF) { + c -= 0xC140; + } else { + throw 'illegal char at ' + (i + 1) + '/' + c; + } + + c = ( (c >>> 8) & 0xff) * 0xC0 + (c & 0xff); + + buffer.put(c, 13); + + i += 2; + } + + if (i < data.length) { + throw 'illegal char at ' + (i + 1); + } + }; + + return _this; + }; + + //===================================================================== + // GIF Support etc. + // + + //--------------------------------------------------------------------- + // byteArrayOutputStream + //--------------------------------------------------------------------- + + var byteArrayOutputStream = function() { + + var _bytes = []; + + var _this = {}; + + _this.writeByte = function(b) { + _bytes.push(b & 0xff); + }; + + _this.writeShort = function(i) { + _this.writeByte(i); + _this.writeByte(i >>> 8); + }; + + _this.writeBytes = function(b, off, len) { + off = off || 0; + len = len || b.length; + for (var i = 0; i < len; i += 1) { + _this.writeByte(b[i + off]); + } + }; + + _this.writeString = function(s) { + for (var i = 0; i < s.length; i += 1) { + _this.writeByte(s.charCodeAt(i) ); + } + }; + + _this.toByteArray = function() { + return _bytes; + }; + + _this.toString = function() { + var s = ''; + s += '['; + for (var i = 0; i < _bytes.length; i += 1) { + if (i > 0) { + s += ','; + } + s += _bytes[i]; + } + s += ']'; + return s; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64EncodeOutputStream + //--------------------------------------------------------------------- + + var base64EncodeOutputStream = function() { + + var _buffer = 0; + var _buflen = 0; + var _length = 0; + var _base64 = ''; + + var _this = {}; + + var writeEncoded = function(b) { + _base64 += String.fromCharCode(encode(b & 0x3f) ); + }; + + var encode = function(n) { + if (n < 0) { + // error. + } else if (n < 26) { + return 0x41 + n; + } else if (n < 52) { + return 0x61 + (n - 26); + } else if (n < 62) { + return 0x30 + (n - 52); + } else if (n == 62) { + return 0x2b; + } else if (n == 63) { + return 0x2f; + } + throw 'n:' + n; + }; + + _this.writeByte = function(n) { + + _buffer = (_buffer << 8) | (n & 0xff); + _buflen += 8; + _length += 1; + + while (_buflen >= 6) { + writeEncoded(_buffer >>> (_buflen - 6) ); + _buflen -= 6; + } + }; + + _this.flush = function() { + + if (_buflen > 0) { + writeEncoded(_buffer << (6 - _buflen) ); + _buffer = 0; + _buflen = 0; + } + + if (_length % 3 != 0) { + // padding + var padlen = 3 - _length % 3; + for (var i = 0; i < padlen; i += 1) { + _base64 += '='; + } + } + }; + + _this.toString = function() { + return _base64; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64DecodeInputStream + //--------------------------------------------------------------------- + + var base64DecodeInputStream = function(str) { + + var _str = str; + var _pos = 0; + var _buffer = 0; + var _buflen = 0; + + var _this = {}; + + _this.read = function() { + + while (_buflen < 8) { + + if (_pos >= _str.length) { + if (_buflen == 0) { + return -1; + } + throw 'unexpected end of file./' + _buflen; + } + + var c = _str.charAt(_pos); + _pos += 1; + + if (c == '=') { + _buflen = 0; + return -1; + } else if (c.match(/^\s$/) ) { + // ignore if whitespace. + continue; + } + + _buffer = (_buffer << 6) | decode(c.charCodeAt(0) ); + _buflen += 6; + } + + var n = (_buffer >>> (_buflen - 8) ) & 0xff; + _buflen -= 8; + return n; + }; + + var decode = function(c) { + if (0x41 <= c && c <= 0x5a) { + return c - 0x41; + } else if (0x61 <= c && c <= 0x7a) { + return c - 0x61 + 26; + } else if (0x30 <= c && c <= 0x39) { + return c - 0x30 + 52; + } else if (c == 0x2b) { + return 62; + } else if (c == 0x2f) { + return 63; + } else { + throw 'c:' + c; + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // gifImage (B/W) + //--------------------------------------------------------------------- + + var gifImage = function(width, height) { + + var _width = width; + var _height = height; + var _data = new Array(width * height); + + var _this = {}; + + _this.setPixel = function(x, y, pixel) { + _data[y * _width + x] = pixel; + }; + + _this.write = function(out) { + + //--------------------------------- + // GIF Signature + + out.writeString('GIF87a'); + + //--------------------------------- + // Screen Descriptor + + out.writeShort(_width); + out.writeShort(_height); + + out.writeByte(0x80); // 2bit + out.writeByte(0); + out.writeByte(0); + + //--------------------------------- + // Global Color Map + + // black + out.writeByte(0x00); + out.writeByte(0x00); + out.writeByte(0x00); + + // white + out.writeByte(0xff); + out.writeByte(0xff); + out.writeByte(0xff); + + //--------------------------------- + // Image Descriptor + + out.writeString(','); + out.writeShort(0); + out.writeShort(0); + out.writeShort(_width); + out.writeShort(_height); + out.writeByte(0); + + //--------------------------------- + // Local Color Map + + //--------------------------------- + // Raster Data + + var lzwMinCodeSize = 2; + var raster = getLZWRaster(lzwMinCodeSize); + + out.writeByte(lzwMinCodeSize); + + var offset = 0; + + while (raster.length - offset > 255) { + out.writeByte(255); + out.writeBytes(raster, offset, 255); + offset += 255; + } + + out.writeByte(raster.length - offset); + out.writeBytes(raster, offset, raster.length - offset); + out.writeByte(0x00); + + //--------------------------------- + // GIF Terminator + out.writeString(';'); + }; + + var bitOutputStream = function(out) { + + var _out = out; + var _bitLength = 0; + var _bitBuffer = 0; + + var _this = {}; + + _this.write = function(data, length) { + + if ( (data >>> length) != 0) { + throw 'length over'; + } + + while (_bitLength + length >= 8) { + _out.writeByte(0xff & ( (data << _bitLength) | _bitBuffer) ); + length -= (8 - _bitLength); + data >>>= (8 - _bitLength); + _bitBuffer = 0; + _bitLength = 0; + } + + _bitBuffer = (data << _bitLength) | _bitBuffer; + _bitLength = _bitLength + length; + }; + + _this.flush = function() { + if (_bitLength > 0) { + _out.writeByte(_bitBuffer); + } + }; + + return _this; + }; + + var getLZWRaster = function(lzwMinCodeSize) { + + var clearCode = 1 << lzwMinCodeSize; + var endCode = (1 << lzwMinCodeSize) + 1; + var bitLength = lzwMinCodeSize + 1; + + // Setup LZWTable + var table = lzwTable(); + + for (var i = 0; i < clearCode; i += 1) { + table.add(String.fromCharCode(i) ); + } + table.add(String.fromCharCode(clearCode) ); + table.add(String.fromCharCode(endCode) ); + + var byteOut = byteArrayOutputStream(); + var bitOut = bitOutputStream(byteOut); + + // clear code + bitOut.write(clearCode, bitLength); + + var dataIndex = 0; + + var s = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + while (dataIndex < _data.length) { + + var c = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + if (table.contains(s + c) ) { + + s = s + c; + + } else { + + bitOut.write(table.indexOf(s), bitLength); + + if (table.size() < 0xfff) { + + if (table.size() == (1 << bitLength) ) { + bitLength += 1; + } + + table.add(s + c); + } + + s = c; + } + } + + bitOut.write(table.indexOf(s), bitLength); + + // end code + bitOut.write(endCode, bitLength); + + bitOut.flush(); + + return byteOut.toByteArray(); + }; + + var lzwTable = function() { + + var _map = {}; + var _size = 0; + + var _this = {}; + + _this.add = function(key) { + if (_this.contains(key) ) { + throw 'dup key:' + key; + } + _map[key] = _size; + _size += 1; + }; + + _this.size = function() { + return _size; + }; + + _this.indexOf = function(key) { + return _map[key]; + }; + + _this.contains = function(key) { + return typeof _map[key] != 'undefined'; + }; + + return _this; + }; + + return _this; + }; + + var createDataURL = function(width, height, getPixel) { + var gif = gifImage(width, height); + for (var y = 0; y < height; y += 1) { + for (var x = 0; x < width; x += 1) { + gif.setPixel(x, y, getPixel(x, y) ); + } + } + + var b = byteArrayOutputStream(); + gif.write(b); + + var base64 = base64EncodeOutputStream(); + var bytes = b.toByteArray(); + for (var i = 0; i < bytes.length; i += 1) { + base64.writeByte(bytes[i]); + } + base64.flush(); + + return 'data:image/gif;base64,' + base64; + }; + + //--------------------------------------------------------------------- + // returns qrcode function. + + return qrcode; +}(); + +// multibyte support +!function() { + + qrcode.stringToBytesFuncs['UTF-8'] = function(s) { + // http://stackoverflow.com/questions/18729405/how-to-convert-utf8-string-to-byte-array + function toUTF8Array(str) { + var utf8 = []; + for (var i=0; i < str.length; i++) { + var charcode = str.charCodeAt(i); + if (charcode < 0x80) utf8.push(charcode); + else if (charcode < 0x800) { + utf8.push(0xc0 | (charcode >> 6), + 0x80 | (charcode & 0x3f)); + } + else if (charcode < 0xd800 || charcode >= 0xe000) { + utf8.push(0xe0 | (charcode >> 12), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + // surrogate pair + else { + i++; + // UTF-16 encodes 0x10000-0x10FFFF by + // subtracting 0x10000 and splitting the + // 20 bits of 0x0-0xFFFFF into two halves + charcode = 0x10000 + (((charcode & 0x3ff)<<10) + | (str.charCodeAt(i) & 0x3ff)); + utf8.push(0xf0 | (charcode >>18), + 0x80 | ((charcode>>12) & 0x3f), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + } + return utf8; + } + return toUTF8Array(s); + }; + +}(); + +(function (factory) { + if (typeof define === 'function' && define.amd) { + define([], factory); + } else if (typeof exports === 'object') { + module.exports = factory(); + } +}(function () { + return qrcode; +})); diff --git a/docker/dojobay/content/about.md b/docker/dojobay/content/about.md new file mode 100644 index 00000000..035dc022 --- /dev/null +++ b/docker/dojobay/content/about.md @@ -0,0 +1,15 @@ +The Dojo Bay exists to give access to people who don't have a Dojo of their own. We encourage everyone to run their own node rather than rely on third parties, and we collect nothing about the people who connect through this directory. + +This site is run by a Dojo operator, and one or more of the nodes listed here are ours. We think that is the right arrangement: whoever maintains a directory of public Dojos should be exposed to the same costs and the same risks as everyone in it. It also means we are not a neutral party, which is precisely why nothing here asks you to take our word for anything. + +**Every listing carries a pairing payload signed by its operator.** That signature is made with the key behind their BIP47 payment code, over the exact onion address, API key and explorer you are about to use, and you can check it with your own wallet or an independent verifier without trusting this site at all. If we were compromised, or simply dishonest, we could not substitute our own onion address into someone else's listing without the signature failing. Because we are a federation of individuals in different jurisdictions we still cannot vouch for how any operator behaves once you connect, but you no longer have to assume the details we publish are the ones they gave us. + +We cannot control when a node goes down, as only its operator can restart it. We make an effort to keep the directory showing only running dojos and re-check every node on a 10-minute cycle, but please conduct your own due diligence. + +We are not affiliated with [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/), [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/) or Ronin Dojo, though we appreciate their efforts and contributions to the community. + +> **Get listed** +> +> If you would like your Dojo listed, there is no email and nothing to wait for: open **Manage my Dojo** in the header and sign in with your PayNym over Auth47. Signing the challenge in [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/) or [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/) proves you control the payment code without revealing any key, and you can then submit, edit or remove your listing yourself. Every submission must pass a live Tor connection check, a signature check over your pairing payload, and a maintainer review before it is published. +> +> `Manage my Dojo → Auth47 → sign → submit` diff --git a/docker/dojobay/content/faq.md b/docker/dojobay/content/faq.md new file mode 100644 index 00000000..0493976f --- /dev/null +++ b/docker/dojobay/content/faq.md @@ -0,0 +1,59 @@ +## For Dojo seekers + +> **Don't delete your wallet without your passphrase** +> +> Your [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/) or [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/) passphrase is shown only once, when the wallet is created, and is separate from the PIN you use to open the app; the two are not linked. To switch the Dojo your wallet connects to you must delete and re-create the wallet, so confirm you have the correct passphrase first. The passphrase cannot be recovered, and you need both the 12-word seed phrase and the passphrase to restore a wallet. To check a passphrase, go to **Settings → Wallet → Check BIP39 Passphrase**. +> +> 🔴 No passphrase: do not delete the wallet. Send the funds to a wallet you control instead. +> +> 🟢 Passphrase and 12 words: you can safely delete the wallet to change device or connect to another Dojo. +> +> If you have the passphrase but not the 12 words, you can still open the wallet by decrypting the backup file with the passphrase. If you lose the Dojo connection and don't have the passphrase, export the XPUB to Sparrow for a watch-only wallet and sign offline from [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/). + +### Who is responsible for the listed nodes? + +Not The Dojo Bay: this site is a **directory only**. We do not operate the nodes listed here, we cannot guarantee their uptime, honesty or safety, and we accept no responsibility for them or for any loss of funds or privacy. Status and reliability figures come from automated checks and can be wrong or out of date. Treat every listing as untrusted: verify the pairing details, prefer self-hosting, and connect at your own risk. + +### Are there privacy concerns for Dojo seekers? + +Yes. When you pair with a Dojo you share your extended public key (XPUB), and the operator can use it to view your past, present and future transactions. Only connect to a Dojo you consider reputable and trustworthy, and prefer your own node whenever possible. + +### How do I verify a listing? + +Every listing here is signed, so there is always something to check. Start with the PayNym: confirm it belongs to someone whose reputation you can check, whether stated in a social-media bio, on their own site, or mentioned publicly, and look it up in the [PayNym.rs](http://paynym25chftmsywv4v2r67agbrr62lcxagsf4tymbzpeeucucy2ivad.onion) directory to see its code. Then take the signed message from the listing to the [BIP47 Message Verifier](http://ab64uow264ohynkalvlyhdrduwwl75n4urvc2vrbo3xjd4jycygiirqd.onion/lab) and fill in the fields; a correct message returns "Message verified successfully". If verification fails there, use **Tools → Verify message** inside [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/) or [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/). + +What this proves is narrow and worth being precise about. It proves that whoever holds the key behind that payment code published these exact pairing details, so the onion address and API key you are about to use are the ones their operator put their name to and not something substituted afterwards. It does not prove they are honest, that the node is well run, or that the payment code belongs to the person you think it does. That last part is your job, and it is why the PayNym step comes first. + +### Why doesn't the site verify the signatures for me? + +Because a page that checks its own claims is asking to be trusted twice. If this instance were compromised it could show a green tick over a forged listing just as easily as a real one, so verification done here would be worth nothing at the exact moment you needed it. Doing it in your own wallet or in an independent verifier is the only version of the check that survives us being wrong or dishonest, so we make that as easy as we can and deliberately stop short of doing it for you. + +### Where do I learn to run my own Dojo? + +A Dojo can be installed several ways: [RoninDojo](https://ronindojo.io), a vanilla Dojo (instructions at [dojo-osp.org](https://dojo-osp.org)), or through the [Umbrel](https://apps.umbrel.com/app/samourai-server), [Nodl](https://nodl.eu) and [Start9](https://marketplace.start9.com) marketplaces. It runs on almost any Bitcoin node implementation, giving you full control of your [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/) / [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/) backend. Treat any public Dojo as strictly temporary or for testing: once your own node is running, migrate your funds to fresh addresses managed by your instance to avoid reusing previously exposed public keys. + +## For Dojo runners + +### Are there privacy concerns for Dojo runners? + +Not security concerns so much as exposure ones. By sharing a pairing payload you reveal your Dojo's onion address, which a malicious party could try to DDoS. You also risk a large number of wallets pairing to your Dojo, so size your hardware accordingly. Until API-key management is fully in place you cannot un-share your pairing details once published. + +### What do I have to sign, and when? + +Your pairing payload, at submission, and again whenever you change it. The signature covers the exact JSON you publish, so a new onion address or a rotated API key needs a new signature over the new details: the old one attests to what you are replacing and will be refused. Sign it with the same PayNym you sign in with, under **PayNym → Sign message** in [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/) or [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/), and paste the whole block including its headers. + +### Is there a minimum Dojo version? + +Yes, 1.27.0, judged on the version your node reports when we probe it rather than the one written into your pairing payload. If your node reports older than that, upgrade it before submitting. + +### Can I change the onion address if I'm being DDoSed? + +Yes, but you will have to re-pair every connected wallet, and update the listing here with a signed payload covering the new address (see above). Until you do, the directory keeps publishing the old one and your listing will show as down. + +### Can I see how many wallets are connected to my Dojo? + +No, and that will not be possible. + +### Can I cap the number if my hardware is limited? + +It isn't really about connections but about tracking a very large number of addresses, and that limit is high even on lower-grade devices. diff --git a/docker/dojobay/data-template/dojos.json b/docker/dojobay/data-template/dojos.json new file mode 100644 index 00000000..a51df831 --- /dev/null +++ b/docker/dojobay/data-template/dojos.json @@ -0,0 +1 @@ +{ "generated_at": null, "interval_minutes": 10, "nodes": [] } diff --git a/docker/dojobay/data-template/history-daily.json b/docker/dojobay/data-template/history-daily.json new file mode 100644 index 00000000..7dd11394 --- /dev/null +++ b/docker/dojobay/data-template/history-daily.json @@ -0,0 +1,4 @@ +{ + "retention_days": 90, + "nodes": {} +} diff --git a/docker/dojobay/data-template/history.json b/docker/dojobay/data-template/history.json new file mode 100644 index 00000000..61d5f6ad --- /dev/null +++ b/docker/dojobay/data-template/history.json @@ -0,0 +1,6 @@ +{ + "generated_at": null, + "interval_minutes": 10, + "window_checks": 72, + "nodes": {} +} diff --git a/docker/dojobay/data-template/paynym-codes.json b/docker/dojobay/data-template/paynym-codes.json new file mode 100644 index 00000000..dfc6ebca --- /dev/null +++ b/docker/dojobay/data-template/paynym-codes.json @@ -0,0 +1,5 @@ +{ + "generated_at": null, + "source": "https://paynym.rs/api/v1/nym", + "mapping": {} +} diff --git a/docker/dojobay/data-template/seed.json b/docker/dojobay/data-template/seed.json new file mode 100644 index 00000000..27d035ae --- /dev/null +++ b/docker/dojobay/data-template/seed.json @@ -0,0 +1,3 @@ +{ + "nodes": [] +} diff --git a/docker/dojobay/data-template/version.json b/docker/dojobay/data-template/version.json new file mode 100644 index 00000000..21882414 --- /dev/null +++ b/docker/dojobay/data-template/version.json @@ -0,0 +1,4 @@ +{ + "commit": "archipelago-app", + "built": null +} diff --git a/docker/dojobay/entrypoint.sh b/docker/dojobay/entrypoint.sh new file mode 100755 index 00000000..1854a85a --- /dev/null +++ b/docker/dojobay/entrypoint.sh @@ -0,0 +1,80 @@ +#!/bin/sh +# Dojo Bay container entrypoint: seeds first-run data, points the backend at +# Archipelago's Tor SOCKS proxy, runs the 10-minute prober on a loop (in place +# of the systemd timer the standalone deploy used), and supervises all three +# processes (node backend, prober loop, nginx) so a SIGTERM from tini/podman +# stops them all cleanly rather than leaving orphans for the hard-kill timeout. +set -eu + +# ---- first-run data seeding ------------------------------------------------- +# /app/data is a bind-mounted, host-persistent volume: empty on first install, +# and shadows whatever was baked into the image at that path. Populate it from +# the clean templates exactly once; a real seed.json/operator.json (once the +# claim wizard or "Manage my Dojo" writes one) is never overwritten. +for f in seed.json dojos.json history.json history-daily.json paynym-codes.json version.json; do + if [ ! -f "/app/data/$f" ]; then + cp "/app/data-template/$f" "/app/data/$f" + fi +done + +# ---- outbound Tor ----------------------------------------------------------- +# The manifest generates /app/data/tor-proxy.conf with the archy-net bridge +# gateway's SOCKS address (Archipelago's Tor binds a second SocksPort there +# specifically for containers) — see docs/app-developer-guide.md's +# {{NETWORK_GATEWAY}} placeholder. probe.mjs already reads TOR_SOCKS_HOST/PORT +# (used for PayNym lookups, DNS-over-HTTPS domain checks, and probing every +# listed Dojo), so no code change is needed, only wiring the env vars here. +if [ -f /app/data/tor-proxy.conf ]; then + TOR_PROXY_ADDR="$(cat /app/data/tor-proxy.conf)" + export TOR_SOCKS_HOST="${TOR_PROXY_ADDR%:*}" + export TOR_SOCKS_PORT="${TOR_PROXY_ADDR##*:}" +fi + +# ---- the backend ------------------------------------------------------------- +cd /app/server +node index.mjs & +NODE_PID=$! + +# ---- the 10-minute prober ---------------------------------------------------- +# Replaces dojobay-update.timer: the same script, invoked on a loop instead of +# by systemd. update.mjs itself is unchanged from upstream. Runs once shortly +# after start (dojobay-update.timer's OnBootSec=2min counterpart — a fresh +# install should not sit on an empty/stale list for a full ten minutes), then +# every 10 minutes; a few seconds of random jitter on each wait, same reasoning +# as the timer's RandomizedDelaySec (a fleet of instances should not all probe +# the same nodes on the same wall-clock tick). +( + sleep "$((25 + RANDOM % 30))" + while true; do + node /app/scripts/update.mjs || echo "[update] cycle failed, will retry in 10 minutes" >&2 + sleep "$((570 + RANDOM % 60))" + done +) & +UPDATE_LOOP_PID=$! + +# ---- the web server ----------------------------------------------------------- +# Backgrounded rather than exec'd: this script stays the live PID tini +# supervises, so the trap below can actually run when SIGTERM arrives and +# forward it to all three children. (exec'ing nginx here would replace this +# script's process image, and a trap registered by a process that no longer +# exists never fires — the other two would then only die on the container's +# hard-kill timeout instead of shutting down cleanly.) +# -e /dev/stderr: nginx's master process logs its very first startup lines +# (before it has even parsed nginx.conf's own error_log directive) to a +# compiled-in default path under /var/lib/nginx/logs — a symlink to +# /var/log/nginx, which is not one of the paths this app asks Archipelago to +# make writable under security.readonly_root. Overriding it here means +# nothing ever depends on /var/log/nginx existing or being writable at all, +# on this image or any other readonly-root host. +nginx -e /dev/stderr -g "daemon off;" & +NGINX_PID=$! + +cleanup() { + kill -TERM "$NGINX_PID" "$NODE_PID" "$UPDATE_LOOP_PID" 2>/dev/null || true + wait "$NGINX_PID" 2>/dev/null || true + exit 0 +} +trap cleanup TERM INT + +wait "$NGINX_PID" +kill "$NODE_PID" "$UPDATE_LOOP_PID" 2>/dev/null || true diff --git a/docker/dojobay/favicon.svg b/docker/dojobay/favicon.svg new file mode 100644 index 00000000..abba46dd --- /dev/null +++ b/docker/dojobay/favicon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/docker/dojobay/index.html b/docker/dojobay/index.html new file mode 100644 index 00000000..ef6e0b56 --- /dev/null +++ b/docker/dojobay/index.html @@ -0,0 +1,57 @@ + + + + + + + +The Dojo Bay — Public Dojo Directory + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + diff --git a/docker/dojobay/manifest.json b/docker/dojobay/manifest.json new file mode 100644 index 00000000..d9198853 --- /dev/null +++ b/docker/dojobay/manifest.json @@ -0,0 +1,30 @@ +{ + "name": "The Dojo Bay", + "short_name": "Dojo Bay", + "description": "A community directory of public Bitcoin Dojo nodes for Samourai, Ashigaru and Sentinel wallets. All nodes reachable over Tor.", + "start_url": "./", + "scope": "./", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#0a0a0a", + "theme_color": "#0a0a0a", + "icons": [ + { + "src": "assets/icons/192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "assets/icons/512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "favicon.svg", + "sizes": "any", + "type": "image/svg+xml" + } + ] +} \ No newline at end of file diff --git a/docker/dojobay/nginx.conf b/docker/dojobay/nginx.conf new file mode 100644 index 00000000..eba43c20 --- /dev/null +++ b/docker/dojobay/nginx.conf @@ -0,0 +1,72 @@ +# Dojo Bay, containerized for Archipelago. +# +# Adapted from the upstream project's deploy/nginx-onion.conf.example. The +# Tor hidden service, TLS-equivalent framing and moderation-queue trust +# decisions all belong to Archipelago's app gate now (it fronts every gated +# port with its own onion, strips clickjacking headers for iframe embedding, +# and enforces the manifest's auth policy) — this file keeps only what is +# still this app's own job: serving the static directory site and proxying +# its self-service API to the Node backend running in the same container. +worker_processes 1; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + sendfile on; + access_log /dev/stdout; + error_log /dev/stderr; + + gzip on; + gzip_types text/css text/javascript application/javascript application/json image/svg+xml text/markdown; + + server { + listen 8080; + server_name _; + root /app; + index index.html; + + # The directory data is rewritten every 10 minutes by scripts/update.mjs — + # keep it fresh rather than letting a browser cache it for a day like the + # other static assets below. + location /data/ { + add_header Cache-Control "max-age=60"; + default_type application/json; + } + + # Code and markup must revalidate so an image update shows up immediately. + location ~* \.(html|js|css|md)$ { + add_header Cache-Control "no-cache"; + } + + # Large, rarely-changing assets can be cached for a day. + location ~* \.(woff2|png|svg|ico)$ { + add_header Cache-Control "max-age=86400"; + } + + # --- self-service backend (Auth47 submission API) --- + location /api/ { + proxy_pass http://127.0.0.1:8787; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 45s; # the connection gate + PayNym lookup probe Tor + } + + # SECURITY: the backend's own source and store (sessions, payment codes, + # node API keys) live under server/ inside the web root. Never serve it. + location ^~ /server/ { return 404; } + + # Serve the SPA shell for the admin route (client-side view; auth is + # enforced by the backend, this only returns the same HTML/JS). + location = /admin { try_files /index.html =404; } + + location / { + try_files $uri $uri/ =404; + } + } +} diff --git a/docker/dojobay/scripts/bootstrap-import.mjs b/docker/dojobay/scripts/bootstrap-import.mjs new file mode 100644 index 00000000..8eb24fd5 --- /dev/null +++ b/docker/dojobay/scripts/bootstrap-import.mjs @@ -0,0 +1,317 @@ +#!/usr/bin/env node +// Bootstrap a new Dojo Bay from a TRUSTED existing instance, so a fresh +// directory is mature the moment it starts: its nodes become approved store +// records here and their reliability histories carry over. +// +// node scripts/bootstrap-import.mjs --onion <56-char>.onion \ +// --code PM8T... [--dry-run] +// +// Trust is verified before anything is imported: the remote instance's +// data/operator.json must bind that onion to exactly the payment code YOU +// typed in, under a valid wallet signature (server/crypto.ts). If the +// signature does not verify, or binds a different onion or code, nothing is +// fetched further. After that: dojos.json supplies the nodes, both history +// files supply the record, and each PayNym is resolved against paynym.rs +// (over Tor) for its full BIP47 code-variant set so imported operators can +// sign in here with either variant. Existing ids are never touched; history +// is only written for ids that have none. +import { readFile, writeFile, rename, mkdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { httpOverTor } from "./update.mjs"; +import { store, hasSignedBlock } from "../server/store.ts"; +import { verifySignedPayload, canonicalPairing } from "../server/crypto.ts"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const DATA_DIR = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"); + +const defaultCfg = () => ({ + proxyHost: process.env.TOR_SOCKS_HOST || "127.0.0.1", + proxyPort: +(process.env.TOR_SOCKS_PORT || 9050), +}); + +// GET a JSON document from the remote instance over Tor. +async function torFetchJSON(onionHost, urlPath, cfg, timeoutMs = 30000) { + const req = `GET ${urlPath} HTTP/1.0\r\nHost: ${onionHost}\r\nUser-Agent: dojobay-bootstrap\r\nConnection: close\r\n\r\n`; + const res = await httpOverTor(cfg, onionHost, 80, req, timeoutMs); + if (res.status !== 200) throw new Error(`${urlPath}: HTTP ${res.status || "no response"}`); + return JSON.parse(res.body); +} + +// A temporary name no other writer can take; see server/build-public.ts. The +// counter matters as well as the pid: one import writes the seed, both history +// files and the avatars in quick succession. +let tmpSeq = 0; +async function writeJSONAtomic(p, obj) { + await mkdir(path.dirname(p), { recursive: true }); + const tmp = `${p}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`; + await writeFile(tmp, JSON.stringify(obj, null, 2) + "\n"); + await rename(tmp, p); +} + +// fetchers are injectable for the self-test: fetchDoc(urlPath) -> object, +// fetchCodes(paynymOrCode) -> [{code, segwit}, ...] +/** + * @param {{ onionHost?: string, trustedCode?: string, dryRun?: boolean, dataDir?: string, + * log?: (...a: any[]) => void, fetchDoc?: any, fetchCodes?: any, + * status?: "approved" | "pending" }} [opts] + */ +export async function bootstrapImport({ + onionHost, trustedCode, dryRun = false, dataDir = DATA_DIR, log = console.error, + fetchDoc, fetchCodes, status = "approved", +} = {}) { + const cfg = defaultCfg(); + fetchDoc = fetchDoc || ((p) => torFetchJSON(onionHost, p, cfg)); + if (!fetchCodes) { + const { fetchNymCodes } = await import("../server/paynym.mjs"); + fetchCodes = (nym) => fetchNymCodes(nym); + } + + // 1) trust gate: the remote operator binding must verify for THIS onion and + // exactly the payment code the operator typed in. + const { verifyOperatorDoc } = await import("../server/crypto.ts"); + const opDoc = await fetchDoc("/data/operator.json"); + const v = verifyOperatorDoc(opDoc, { expectedOnion: `http://${onionHost}` }); + if (!v.ok) throw new Error(`refusing to import: remote operator binding does not verify (${v.error})`); + if (opDoc.paymentCode !== trustedCode) { + throw new Error("refusing to import: the remote instance is operated by a DIFFERENT payment code than the one you trusted"); + } + log(`trusted: ${onionHost} is signed by ${trustedCode.slice(0, 12)}… ✓`); + + // 2) data + const dojos = await fetchDoc("/data/dojos.json"); + const hist = await fetchDoc("/data/history.json").catch(() => ({ nodes: {} })); + const daily = await fetchDoc("/data/history-daily.json").catch(() => ({ nodes: {} })); + const nodes = (dojos.nodes || []).filter((n) => n.payload?.pairing?.url); + + // The pairing URL identifies a physical Dojo; an id does not. + // + // An operator installing a new instance names their own node in the anchor, + // then bootstraps from a directory that already lists it. The two ids differ, + // because each instance derives one from the name it was given, so the same + // machine arrived twice: once as the anchor and once as an import, with its + // reliability history split between them. What is actually the same thing is + // the onion address in the signed pairing payload, which is why matching on + // it is not a heuristic. Two listings cannot share one, and an operator + // cannot claim somebody else's without the signature failing. + // + // Compared as a whole URL rather than by host alone, because one machine may + // legitimately serve mainnet at /v2 and testnet at /test/v2, and those are + // two listings. Lower-cased and stripped of a trailing slash, since neither + // changes which endpoint is meant. + const pairingKey = (n) => { + const u = n?.payload?.pairing?.url; + if (typeof u !== "string" || !u) return null; + return u.trim().toLowerCase().replace(/\/+$/, ""); + }; + + // Everything this instance already lists, from the store AND from the seed + // anchor. The anchor is not a store record, which is exactly why it was + // invisible to this check and why the operator's own node was the one node + // guaranteed to duplicate. + const localByUrl = new Map(); + for (const r of await store.listSubmissions()) { + const k = pairingKey(r); + if (k) localByUrl.set(k, r.id); + } + try { + const seed = JSON.parse(await readFile(path.join(dataDir, "seed.json"), "utf8")); + for (const n of seed.nodes || []) { + const k = pairingKey(n); + if (k && !localByUrl.has(k)) localByUrl.set(k, n.id); + } + } catch { /* no anchor yet, which is normal on a bare install */ } + + // 3) plan records: skip existing ids; resolve full code sets per PayNym + const existingIds = new Set((await store.listSubmissions()).map((r) => r.id)); + const plan = []; + const codeCache = new Map(); + for (const n of nodes) { + if (existingIds.has(n.id)) { plan.push({ action: "skip", n }); continue; } + // Same machine under a different id. The record is not created, because a + // second listing for one Dojo is worse than a missing one, but the history + // is worth having: it is the same node's record of itself, and dropping it + // would restart an operator's reliability figures from nothing on a machine + // that has been up for months. Carried onto the id this instance uses. + const dupOf = localByUrl.get(pairingKey(n)); + if (dupOf) { plan.push({ action: "merge", n, dupOf }); continue; } + // A published node from another instance carries its signed block in + // dojos.json, so an unsigned one either predates the rule there or was + // published by an instance that does not enforce it. Either way it cannot + // enter this store, and saying so in the plan is better than a throw from + // putSubmission half way through the import. + if (!hasSignedBlock(n)) { plan.push({ action: "refuse", n, why: "no signed pairing block" }); continue; } + // And the block must actually verify, here, against the payload it claims + // to cover. + // + // hasSignedBlock only looks for the two header lines, and putSubmission + // enforces nothing more, so until this check an imported listing's + // signature was taken on the source instance's word: a directory that was + // careless or compromised could publish a well-formed block that verifies + // against nothing, and every instance bootstrapping from it would list the + // node. This is the same standard the domain badges above are already held + // to, and for the same reason: one compromised directory must not be able + // to place listings across a federation. + // + // Offline and self-contained. canonicalPairing derives the message from the + // payload being imported, so a payload altered in transit no longer matches + // what was signed, and the addresses come from the payment code named + // inside the block itself rather than from anything the source asserts. + const sig = verifySignedPayload({ + signedText: n.signed, + expectedMessage: canonicalPairing(n.payload), + network: n.network === "testnet" ? "testnet" : "bitcoin", + }); + if (!sig.ok) { plan.push({ action: "refuse", n, why: `signature does not verify (${sig.error})` }); continue; } + let codes = n.paymentCode ? [n.paymentCode] : []; + if (n.paynym) { + if (!codeCache.has(n.paynym)) codeCache.set(n.paynym, await fetchCodes(n.paynym).catch(() => [])); + const all = codeCache.get(n.paynym).map((c) => c.code); + if (all.length) codes = [...new Set([...all, ...codes])]; + } + if (!codes.length) { plan.push({ action: "refuse", n, why: "no BIP47 payment code" }); continue; } + plan.push({ action: "import", n, codes }); + } + + const now = new Date().toISOString(); + for (const { action, n, codes, why } of plan) { + log(` ${action.padEnd(6)} ${n.id.padEnd(28)} ${n.paynym || "(no PayNym)"} (${(codes || []).length} codes)${why ? " — " + why : ""}`); + } + const imports = plan.filter((p) => p.action === "import"); + const merges = plan.filter((p) => p.action === "merge"); + const refused = plan.filter((p) => p.action === "refuse"); + for (const m of merges) { + log(` merge ${m.n.id.padEnd(28)} same Dojo as ${m.dupOf}: history only, no second listing`); + } + if (refused.length) log(`refused ${refused.length} node(s) that cannot be listed here: ${refused.map((p) => p.n.id).join(", ")}`); + // The plan as data, not as log lines. The command line reads the log; the + // admin console has to render this and let an operator decide, and parsing + // the log back out would be inventing a format nobody agreed on. + const rows = plan.map(({ action, n, codes, dupOf, why }) => ({ + action, id: n.id, name: n.name || n.id, network: n.network || null, + paynym: n.paynym || null, url: n?.payload?.pairing?.url || null, + codes: (codes || []).length, dupOf: dupOf || null, why: why || null, + })); + if (dryRun) { + log(`dry run: ${imports.length} node(s) would be imported` + + (merges.length ? `, ${merges.length} recognised as already listed here` : "") + + ", nothing written."); + return { imported: 0, planned: imports.length, merged: merges.length, + refused: refused.length, plan: rows, status }; + } + + for (const { n, codes } of imports) { + await store.putSubmission({ + id: n.id, network: n.network, name: n.name || n.id, + paymentCodes: codes, paynym: n.paynym || null, + jurisdiction: n.jurisdiction || null, country: n.country || null, + hardware: n.hardware || null, payload: n.payload, + signed: n.signed || null, + // approved at install, because choosing to bootstrap from a directory IS + // the decision to trust its list. An import into a running instance + // arrives pending instead, so it lands in the moderation queue the + // operator already uses and nothing is published until they say so. + status, source: `bootstrap-import:${onionHost}`, + created_at: now, updated_at: now, + }); + } + + // 3b) verified operator domains. + // + // dojos.json publishes each badge's proof, and the signed statement is + // deliberately portable: it names the domain and the payment code, never the + // instance that verified it. So a claim travels intact — but it is NOT taken + // on the source's word. We re-verify the signature here, locally and offline, + // and store the claim UNVERIFIED so this instance's own sweep must see the TXT + // record with its own eyes before any badge appears. Importing a badge because + // another instance said so would make one compromised directory able to mint + // verified domains across a federation. + const claims = new Map(); + for (const n of dojos.nodes || []) { + const pf = n.operator_domain_proof; + if (!pf || !pf.domain || !pf.paymentCode || !pf.signed) continue; + if (claims.has(pf.paymentCode)) continue; + claims.set(pf.paymentCode, pf); + } + let domainsImported = 0, domainsRefused = 0; + if (claims.size) { + const { verifySignedUrlClaim } = await import("../server/crypto.ts"); + for (const [code, pf] of claims) { + if (await store.getDomain(code)) continue; // never overwrite a local claim + const v = verifySignedUrlClaim({ signed: pf.signed, expectedUrl: `https://${pf.domain}`, paymentCode: code }); + if (!v.ok) { + log(` domain ${pf.domain}: refused (${v.error})`); + domainsRefused++; + continue; + } + await store.putDomain({ + paymentCode: code, domain: pf.domain, signed: pf.signed, + verified: false, // this instance has not seen the DNS yet + verified_at: null, + last_check: null, // so the sweep picks it up immediately + last_result: `imported from ${onionHost}; awaiting our own DNS check`, + fail_since: null, created_at: now, + }); + log(` domain ${pf.domain}: signature verified, awaiting our own TXT lookup`); + domainsImported++; + } + } + + // 4) histories: only for ids we have no history for + for (const [file, remote] of [["history.json", hist], ["history-daily.json", daily]]) { + const p = path.join(dataDir, file); + let local; try { local = JSON.parse(await readFile(p, "utf8")); } catch { local = { nodes: {} } } + local.nodes = local.nodes || {}; + let added = 0; + for (const [id, entry] of Object.entries(remote.nodes || {})) { + if (!local.nodes[id] && imports.some((x) => x.n.id === id)) { local.nodes[id] = entry; added++; continue; } + // A duplicate contributes its history under the id this instance uses. + // + // The two series are combined rather than one replacing the other. An + // anchor installed an hour ago has a handful of checks of its own and the + // remote has months: overwriting throws away the local ones, skipping + // throws away the months, and neither is what an operator means by + // importing history. Combined, de-duplicated on the timestamp, sorted, + // and trimmed to the same window the updater keeps. + const merged = merges.find((x) => x.n.id === id); + if (!merged) continue; + const key = entry.checks ? "checks" : "days"; + const stamp = key === "checks" ? "t" : "d"; + const mine = (local.nodes[merged.dupOf] || {})[key] || []; + const theirs = entry[key] || []; + if (!theirs.length) continue; + const byStamp = new Map(); + // Local last, so a period this instance measured itself wins over the + // remote's account of the same period. + for (const row of [...theirs, ...mine]) if (row && row[stamp]) byStamp.set(row[stamp], row); + const all = [...byStamp.values()].sort((x, y) => String(x[stamp]).localeCompare(String(y[stamp]))); + const cap = key === "checks" ? (remote.window_checks || local.window_checks || 144) : 90; + local.nodes[merged.dupOf] = { [key]: all.slice(-cap) }; + added++; + } + if (added) { + if (remote.interval_minutes && !local.interval_minutes) local.interval_minutes = remote.interval_minutes; + if (remote.window_checks && !local.window_checks) local.window_checks = remote.window_checks; + await writeJSONAtomic(p, local); + log(` history: ${added} node(s) carried into ${file}`); + } + } + log(`imported ${imports.length} node(s) from ${onionHost}` + + (merges.length ? `, and recognised ${merges.length} as node(s) this instance already lists` : "") + + ". Now run: node server/build-public.mjs"); + return { imported: imports.length, planned: imports.length, merged: merges.length, + refused: refused.length, plan: rows, status, + domains_imported: domainsImported, domains_refused: domainsRefused }; +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) { + const arg = (k) => { const i = process.argv.indexOf(k); return i > 0 ? process.argv[i + 1] : null; }; + const onionHost = String(arg("--onion") || "").replace(/^https?:\/\//, "").replace(/\/.*$/, ""); + const trustedCode = arg("--code"); + if (!/^[a-z2-7]{56}\.onion$/.test(onionHost) || !trustedCode) { + console.error("usage: node scripts/bootstrap-import.mjs --onion <56-char>.onion --code PM8T... [--dry-run]"); + process.exit(1); + } + bootstrapImport({ onionHost, trustedCode, dryRun: process.argv.includes("--dry-run") }) + .catch((e) => { console.error("fatal:", e.message); process.exit(1); }); +} diff --git a/docker/dojobay/scripts/migrate-seed-to-store.mjs b/docker/dojobay/scripts/migrate-seed-to-store.mjs new file mode 100644 index 00000000..c6ddde5a --- /dev/null +++ b/docker/dojobay/scripts/migrate-seed-to-store.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node +// Move seed nodes into the operator-managed store, idempotently. +// +// node scripts/migrate-seed-to-store.mjs --dry-run print the plan, write nothing +// node scripts/migrate-seed-to-store.mjs apply it +// +// The seed's role is the instance ANCHOR: exactly one node, the instance +// operator's own Dojo (mainnet or testnet), carrying their PayNym and BIP47 +// payment code. Everything else belongs in the store, where operators manage +// their listings over Auth47. This script is the transition tool for an +// instance whose seed still carries an old-style curated list: +// +// - a seed node with a PayNym present in data/paynym-codes.json becomes an +// APPROVED store record owned by every BIP47 code variant of that PayNym +// - a seed node WITHOUT a PayNym is REFUSED. Every listing must carry a BIP47 +// payment code: it is the identity a listing is owned, edited, verified and +// recognised by. Code-less records were once adopted as admin-managed +// exceptions; that door is closed, and the store refuses to write one. +// - a seed node whose id already exists in the store is SKIPPED untouched, +// which is what makes re-runs no-ops and lets the anchor node coexist as +// both seed entry (bootstrap guarantee) and store record (Auth47-managed: +// the store record shadows the seed copy in the public list) +// +// The script never rewrites data/seed.json: slimming the seed down to the +// anchor is a deliberate, separate commit made AFTER the store records exist, +// because a deploy that removes a node's seed entry before its store record +// exists delists it (the history survives under the fourteen-day grace stamp, +// but there is no reason to invite the gap). +// +// Record ids are the original seed ids, so reliability history (keyed by id) +// carries over untouched. Afterwards run `node server/build-public.mjs`. +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { store, hasSignedBlock } from "../server/store.ts"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const DATA_DIR = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"); +const SEED_PATH = path.join(DATA_DIR, "seed.json"); +const CODES_PATH = path.join(DATA_DIR, "paynym-codes.json"); +const DRY = process.argv.includes("--dry-run"); + +async function readJSON(p, fallback) { + try { return JSON.parse(await readFile(p, "utf8")); } + catch (e) { if (fallback !== undefined) return fallback; throw e; } +} + +const slugOf = (v) => String(v || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + +// Name derivation for owned groups. Remainder = seed id minus `${network}-`. +// When one owner's several nodes share a first hyphen-token and stripping it +// leaves something for each, drop the shared token; and prefer the seed's +// display name whenever it slugs to the derived value, so capitalisation like +// "wanderinKing072" survives. +function deriveNames(nodes) { + const rem = nodes.map((n) => n.id.replace(new RegExp(`^${n.network}-`), "")); + let names = rem; + if (nodes.length > 1) { + const first = rem.map((r) => r.split("-")[0]); + if (first.every((t) => t === first[0]) && rem.every((r) => r.includes("-"))) { + names = rem.map((r) => r.split("-").slice(1).join("-")); + } + } + return nodes.map((n, i) => (n.name && slugOf(n.name) === names[i]) ? n.name : names[i]); +} + +function toRecord(n, name, codes, now) { + return { + id: n.id, network: n.network, name, + paymentCodes: codes, + paynym: n.paynym || null, + jurisdiction: n.jurisdiction || null, + country: n.country || null, + hardware: n.hardware || null, + payload: n.payload, + signed: n.signed || null, + status: "approved", + source: "seed-migration", + created_at: now, updated_at: now, + }; +} + +async function main() { + const seed = await readJSON(SEED_PATH); + const mapping = (await readJSON(CODES_PATH, { mapping: {} })).mapping || {}; + const existing = await store.listSubmissions(); + const nodes = seed.nodes || []; + + const owned = nodes.filter((n) => n.paynym); + const missing = owned.filter((n) => !mapping[n.paynym]); + if (missing.length) { + console.error("aborting: no payment codes in", path.relative(ROOT, CODES_PATH), "for:"); + for (const n of missing) console.error(" ", n.id, n.paynym); + process.exit(1); + } + + // Derive names per owner; code-less nodes keep their seed name (or the id + // remainder). Then refuse any per-network name collision against the plan + // itself or records already in the store under a DIFFERENT id. + const byOwner = new Map(); + for (const n of owned) (byOwner.get(n.paynym) || byOwner.set(n.paynym, []).get(n.paynym)).push(n); + const nameOf = new Map(); + for (const group of byOwner.values()) deriveNames(group).forEach((nm, i) => nameOf.set(group[i].id, nm)); + for (const n of nodes.filter((x) => !x.paynym)) { + const rem = n.id.replace(new RegExp(`^${n.network}-`), ""); + nameOf.set(n.id, (n.name && slugOf(n.name) === rem) ? n.name : (n.name || rem)); + } + const seen = new Set(); + for (const n of nodes) { + const key = `${n.network}:${slugOf(nameOf.get(n.id))}`; + if (seen.has(key)) { console.error("aborting: duplicate node name per network:", key); process.exit(1); } + seen.add(key); + } + for (const r of existing) { + for (const n of nodes) { + if (r.id !== n.id && r.network === n.network && slugOf(r.name) === slugOf(nameOf.get(n.id))) { + console.error(`aborting: seed node ${n.id} clashes with store record ${r.id} on name "${r.name}"`); + process.exit(1); + } + } + } + + const now = new Date().toISOString(); + const byId = new Map(existing.map((r) => [r.id, r])); + const plan = nodes.map((n) => { + if (byId.has(n.id)) return { action: "skip", why: "already in store (left untouched)", node: byId.get(n.id) }; + const codes = n.paynym ? mapping[n.paynym].codes.map((c) => c.code) : []; + // Two things make a node unmigratable, and both are the store's rules + // rather than this script's: no payment code means no owner, and no signed + // pairing block means nothing a visitor can check. Refusing here rather + // than letting putSubmission throw is what turns a stack trace part-way + // through a migration into a plan you can read before anything is written. + const node = toRecord(n, nameOf.get(n.id), codes, now); + if (!codes.length) return { action: "refuse", why: "no BIP47 payment code", node }; + if (!hasSignedBlock(node)) return { action: "refuse", why: "no signed pairing block", node }; + return { action: "create", node }; + }); + + console.log(`${DRY ? "DRY RUN — " : ""}migration plan (${nodes.length} seed nodes):`); + for (const { action, why, node } of plan) { + const owner = node.paynym || "(no PayNym)"; + console.log(` ${action.padEnd(6)} ${node.id.padEnd(26)} name=${String(node.name).padEnd(18)} ${owner} (${(node.paymentCodes || []).length} codes)${why ? " — " + why : ""}`); + if (action === "refuse") { + console.log(` REFUSED: ${node.id} ${why}, so it cannot be migrated.`); + console.log(` Give it a PayNym in data/paynym-codes.json and a signed pairing block, or drop it from the seed.`); + } + } + + const changes = plan.filter((p) => p.action === "create"); + const refused = plan.filter((p) => p.action === "refuse"); + const tail = refused.length ? ` ${refused.length} refused: ${refused.map((p) => p.node.id).join(", ")}.` : ""; + if (DRY) { console.log(`\ndry run: ${changes.length} change(s) would be made, nothing written.${tail}`); return; } + if (!changes.length) { console.log(`\nnothing to do: every seed node already has a store record.${tail}`); return; } + for (const { node } of changes) await store.putSubmission(node); + console.log(`\napplied ${changes.length} change(s).${tail} Now run: node server/build-public.mjs`); + console.log("Once the store records exist, slim data/seed.json to the anchor (your own node) in a separate commit."); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) { + main().catch((e) => { console.error("fatal:", e.message); process.exit(1); }); +} diff --git a/docker/dojobay/scripts/pack-source.mjs b/docker/dojobay/scripts/pack-source.mjs new file mode 100644 index 00000000..9fa020e6 --- /dev/null +++ b/docker/dojobay/scripts/pack-source.mjs @@ -0,0 +1,138 @@ +#!/usr/bin/env node +// Pack this instance's own codebase into data/dojobay-src.zip, so the running +// site is its own distribution point: visitors download exactly the code the +// instance runs (the footer's source icon), with no reliance on GitHub being +// reachable. Node builtins only -- the ZIP container is written by hand +// (deflate entries via zlib + a central directory), because a bare box has no +// `zip` binary and scripts/ must run everywhere. +// +// node scripts/pack-source.mjs write data/dojobay-src.zip +// +// What goes in is manifest-driven, and what stays out matters more than what +// goes in: NEVER the submission store (Dojo API keys, sessions), never the +// instance's generated data (dojos.json, history, avatars), and never its +// identity (seed.json anchor, operator.json binding, paynym-codes.json), so +// extracting the zip over an existing web root upgrades the CODE and touches +// nothing the instance owns. data/version.json IS included: it states which +// commit the code is, which is exactly what a downloader wants to know. +import { readFile, writeFile, rename, readdir, stat, mkdir } from "node:fs/promises"; +import { deflateRawSync } from "node:zlib"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const PREFIX = "dojobay/"; // extraction lands in one folder + +const INCLUDE_FILES = [ + "index.html", "manifest.json", "sw.js", "favicon.svg", "og-image.png", + // LICENSE travels with THIRD-PARTY-NOTICES.md: the archive is a distributed + // copy of the source, and the README it contains links to the notices. + // SECURITY.md travels for the same reason: a recipient who finds a + // vulnerability in this copy needs to be told where to send it. + "LICENSE", "THIRD-PARTY-NOTICES.md", "README.md", "CONTRIBUTING.md", "SECURITY.md", "package.json", + "tsconfig.json", "types.d.ts", + "install.sh", "uninstall.sh", + "data/version.json", +]; +// docs/ holds the reasoning: why things are shaped as they are and what was +// tried and rejected. It is the most useful thing in the tree to anyone +// changing the code, and this archive is how a peer instance receives the code. +const INCLUDE_DIRS = ["assets", "content", "deploy", "docs", "scripts", "server", ".github"]; +const DENY = [ + "server/data", "server/node_modules", "node_modules", ".git", + "data/dojos.json", "data/history.json", "data/history-daily.json", + "data/avatars", "data/seed.json", "data/operator.json", "data/paynym-codes.json", + "data/updates", "data/backups", +]; +const denied = (rel) => DENY.some((d) => rel === d || rel.startsWith(d + "/")) + || rel.endsWith(".zip") || path.basename(rel) === ".DS_Store"; + +async function collect(root) { + const out = []; + for (const f of INCLUDE_FILES) { + try { await stat(path.join(root, f)); out.push(f); } catch { /* absent on this instance */ } + } + async function walk(rel) { + for (const e of await readdir(path.join(root, rel), { withFileTypes: true })) { + const r = rel + "/" + e.name; + if (denied(r)) continue; + if (e.isDirectory()) await walk(r); + else if (e.isFile()) out.push(r); + } + } + for (const d of INCLUDE_DIRS) { + try { await stat(path.join(root, d)); await walk(d); } catch { /* absent */ } + } + return out.sort(); +} + +// ---- minimal ZIP writer (PKZIP appnote: local headers + central directory) -- +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); +const crc32 = (buf) => { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +}; +const dosTime = (d) => (((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xffff); +const dosDate = (d) => ((((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xffff); +const u16 = (n) => { const b = Buffer.alloc(2); b.writeUInt16LE(n & 0xffff); return b; }; +const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32LE(n >>> 0); return b; }; + +function buildZip(entries) { // entries: [{name, data, mtime, mode}] + const locals = [], centrals = []; + let offset = 0; + for (const { name, data, mtime, mode = 0o644 } of entries) { + const nameBuf = Buffer.from(name, "utf8"); + const deflated = deflateRawSync(data, { level: 9 }); + const stored = deflated.length < data.length; + const body = stored ? deflated : data; + const method = stored ? 8 : 0; + const crc = crc32(data); + const t = u16(dosTime(mtime)), dt = u16(dosDate(mtime)); + const common = Buffer.concat([ + u16(20), u16(0x0800 /* UTF-8 names */), u16(method), t, dt, + u32(crc), u32(body.length), u32(data.length), u16(nameBuf.length), u16(0), + ]); + locals.push(Buffer.concat([u32(0x04034b50), common, nameBuf, body])); + centrals.push(Buffer.concat([ + u32(0x02014b50), u16((3 << 8) | 20 /* unix */), common, u16(0), u16(0), u16(0), + u32(((0o100000 | mode) >>> 0) * 0x10000) /* unix mode in high word */, u32(offset), nameBuf, + ])); + offset += locals[locals.length - 1].length; + } + const cd = Buffer.concat(centrals); + const end = Buffer.concat([ + u32(0x06054b50), u16(0), u16(0), u16(entries.length), u16(entries.length), + u32(cd.length), u32(offset), u16(0), + ]); + return Buffer.concat([...locals, cd, end]); +} + +export async function packSource({ root = ROOT, outDir = path.join(ROOT, "data") } = {}) { + const files = await collect(root); + const entries = []; + for (const rel of files) { + const p = path.join(root, rel); + const [data, st] = [await readFile(p), await stat(p)]; + entries.push({ name: PREFIX + rel, data, mtime: st.mtime, mode: st.mode & 0o777 }); + } + const zip = buildZip(entries); + await mkdir(outDir, { recursive: true }); + const out = path.join(outDir, "dojobay-src.zip"); + await writeFile(out + ".tmp", zip); + await rename(out + ".tmp", out); + return { out, files: files.length, bytes: zip.length }; +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) { + packSource().then((r) => console.log(`wrote ${r.out}: ${r.files} files, ${(r.bytes / 1024).toFixed(0)} KiB`)) + .catch((e) => { console.error("fatal:", e.message); process.exit(1); }); +} diff --git a/docker/dojobay/scripts/update.mjs b/docker/dojobay/scripts/update.mjs new file mode 100644 index 00000000..c5f9d1f5 --- /dev/null +++ b/docker/dojobay/scripts/update.mjs @@ -0,0 +1,806 @@ +#!/usr/bin/env node +// ============================================================================= +// The Dojo Bay — directory updater +// +// Probes every node's .onion pairing endpoint over Tor and rewrites the two +// JSON databases the website reads: +// +// data/dojos.json current snapshot -> node.status + node.checked_at +// data/history.json rolling history -> one {t, up} per node, per run +// +// dojos.json is also the source of truth for the node LIST. To add or remove a +// node, edit dojos.json (name, paynym, payload, etc.); this script only fills +// in status/checked_at and appends to the history. New nodes get a fresh +// history series automatically; removed nodes are retired under a grace stamp +// and only pruned HISTORY_GRACE_DAYS (default 14) after leaving the list. +// +// Health is checked through Tor's SOCKS5 proxy (no external npm deps). For a +// node whose pairing payload carries an apikey, the check logs in to the Dojo +// API and reads info.latest_block.height from GET /v2/wallet: the node is +// "active" only if it returns a chain tip, which proves the whole stack (Tor, +// nginx, Dojo API, bitcoind) is serving block data, and the height is recorded +// on the node. Nodes without an apikey fall back to a plain HTTP reachability +// probe (active if the onion returns an HTTP response line). +// +// Every Dojo response carries its running version in the X-Dojo-Version header; +// the probe reads it and records node.detected_version, so a card can show the +// live version rather than the one frozen into the pairing payload at signing +// time. build-public.mjs decides the effective version an operator override +// still wins over it. +// +// Run once (intended to be driven by cron/systemd every 10 minutes): +// node scripts/update.mjs +// +// Config via environment variables (all optional): +// TOR_SOCKS_HOST default 127.0.0.1 +// TOR_SOCKS_PORT default 9050 +// DATA_DIR default /data +// TIMEOUT_MS default 45000 per-node Tor timeout +// CONCURRENCY default 3 simultaneous Tor circuits +// WINDOW_CHECKS default 144 history length kept per node (24h @ 10min) +// RETENTION_DAYS default 90 daily-rollup days kept per node (~3 months) +// CONNECT_ONLY default 0 "1" = treat a successful Tor connect as up +// without waiting for an HTTP response line +// DOJO_VERSION_HEADER default X-Dojo-Version response header carrying the +// node's running Dojo version +// ============================================================================= + +import net from "node:net"; +import { retireUnlisted } from "../server/build-public.ts"; +import { readFile, writeFile, rename, stat as fsStat, mkdir as fsMkdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Chosen for a home connection as much as a VPS, because the unit that would +// override them lives in /etc and no update can reach it. A node answering at +// 23 seconds was being recorded as down against a 30 second ceiling, and six +// circuits at once through one Tor client on a domestic line makes every probe +// slow together, which reads as every node being down. +export const DEFAULT_TIMEOUT_MS = 45000; +export const DEFAULT_CONCURRENCY = 3; + +const CFG = { + proxyHost: process.env.TOR_SOCKS_HOST || "127.0.0.1", + proxyPort: +(process.env.TOR_SOCKS_PORT || 9050), + dataDir: process.env.DATA_DIR || path.resolve(__dirname, "..", "data"), + timeoutMs: +(process.env.TIMEOUT_MS || DEFAULT_TIMEOUT_MS), + concurrency: +(process.env.CONCURRENCY || DEFAULT_CONCURRENCY), + windowChecks: +(process.env.WINDOW_CHECKS || 144), + retentionDays: +(process.env.RETENTION_DAYS || 90), + connectOnly: process.env.CONNECT_ONLY === "1", + // The Dojo API stamps its running version on every response via this header + // (Dojo's http-server appends X-Dojo-Version: as global + // middleware). Read it during the probe so a node's displayed version tracks + // what it is actually running, instead of the value frozen into its pairing + // payload at submission time. Overridable in case a fork renames the header. + dojoVersionHeader: (process.env.DOJO_VERSION_HEADER || "X-Dojo-Version").toLowerCase(), +}; + +// ---- SOCKS5 reply codes (RFC 1928 §6) --------------------------------------- +const SOCKS_ERR = { + 0x01: "general failure", + 0x02: "connection not allowed", + 0x03: "network unreachable", + 0x04: "host unreachable", // Tor: onion descriptor not found / service down + 0x05: "connection refused", + 0x06: "TTL expired", + 0x07: "command not supported", + 0x08: "address type not supported", +}; + +class SocksError extends Error { + constructor(code) { + super("SOCKS " + (SOCKS_ERR[code] || "error 0x" + code.toString(16))); + this.code = code; + } +} + +// ----------------------------------------------------------------------------- +// Open a TCP stream to host:port THROUGH a SOCKS5 proxy (Tor), using a remote +// hostname so the .onion is resolved by Tor, not locally. Resolves with a +// connected socket on success; rejects on any handshake/connect failure. +// ----------------------------------------------------------------------------- +export function socks5Connect(proxyHost, proxyPort, host, port, timeoutMs) { + return new Promise((resolve, reject) => { + const socket = net.connect(proxyPort, proxyHost); + let stage = "greet"; + let buf = Buffer.alloc(0); + let settled = false; + + const fail = (e) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + reject(e instanceof Error ? e : new Error(String(e))); + }; + const timer = setTimeout(() => fail(new Error("timeout")), timeoutMs); + + socket.once("connect", () => { + // greeting: VER=5, NMETHODS=1, METHOD=0 (no auth) + socket.write(Buffer.from([0x05, 0x01, 0x00])); + }); + socket.on("error", fail); + socket.on("close", () => fail(new Error("proxy closed"))); + + socket.on("data", (d) => { + buf = Buffer.concat([buf, d]); + + if (stage === "greet") { + if (buf.length < 2) return; + if (buf[0] !== 0x05 || buf[1] !== 0x00) return fail(new Error("proxy refused no-auth handshake")); + buf = buf.subarray(2); + stage = "reply"; + // CONNECT request with ATYP=3 (domain name), so Tor resolves the onion + const hb = Buffer.from(host, "utf8"); + socket.write(Buffer.concat([ + Buffer.from([0x05, 0x01, 0x00, 0x03, hb.length]), + hb, + Buffer.from([(port >> 8) & 0xff, port & 0xff]), + ])); + } + + if (stage === "reply") { + if (buf.length < 4) return; + if (buf[1] !== 0x00) return fail(new SocksError(buf[1])); + const atyp = buf[3]; + const addrLen = + atyp === 0x01 ? 4 : + atyp === 0x04 ? 16 : + atyp === 0x03 ? (buf.length >= 5 ? 1 + buf[4] : Infinity) : 0; + if (buf.length < 4 + addrLen + 2) return; // wait for the full bound-addr + // success: hand the live stream back to the caller + settled = true; + clearTimeout(timer); + socket.removeAllListeners("data"); + socket.removeAllListeners("error"); + socket.removeAllListeners("close"); + resolve(socket); + } + }); + }); +} + +// Well-formed dummy extended keys, used only to elicit info.latest_block from +// the Dojo /wallet endpoint. They are passed as `new` so the node performs no +// rescan or historical import; they derive from a throwaway seed and can never +// receive funds. One per network so the Dojo never rejects them on format. +const DUMMY_XPUB = "xpub661MyMwAqRbcFhv1kNXxwyGrJUVPrmiBNTVDYAtpzF5zu9ceuhn5yV6oaSdveis14LSeBLzpWb58pDNN6hC59TTDyiN7iJR7kUQgXNMfZCL"; +const DUMMY_TPUB = "tpubD6NzVbkrYhZ4XW6sCZX49tcDdbb3rADEv65WtiwyL9qteSHMyvdB7vmdpUiiBDpErEyYnvWh3guBWPryVZ3K2tuX3K7RPq5MLS16HN9awey"; + +// The most bytes a response may accumulate before the read is abandoned. +// +// Every caller of httpOverTor is talking to a machine somebody else controls: +// that is the point of the probe. Without a ceiling the reader accumulates +// whatever arrives until the socket closes or the timeout fires, so a listed +// node that simply never stops sending can push thirty seconds of Tor +// throughput into the heap, times CONCURRENCY parallel probes, on a VPS whose +// documented minimum is 1 GB. Nothing about that requires malice: a Dojo +// misconfigured to return a file rather than JSON does it by accident. +// +// 2 MiB is chosen against the largest legitimate response any probe path sees, +// which is a Dojo /wallet reply for two dummy xpubs, single-digit kilobytes. +// A PayNym avatar is a small PNG and sits under the same ceiling comfortably; +// it does not get a tighter limit of its own, because a second constant would +// have to be kept in a sensible relationship with this one, and 2 MiB already +// bounds the disk that syncAvatars can consume to a few tens of megabytes +// across every listed code. The one caller that legitimately needs more is +// self-update fetching a peer's source zip, and it passes its own value. +export const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; + +// The unauthenticated probe reads only until it recognises an HTTP status line, +// so it needs a far smaller ceiling than a full response: this bounds how long +// it will listen to something that is not speaking HTTP at all. +export const MAX_STATUS_LINE_BYTES = 64 * 1024; + +// Send one HTTP/1.0 request over a fresh Tor stream and read the whole reply +// (Connection: close means the server ends the body by closing). Resolves with +// { status, body } or rejects on connect failure, read timeout, or a reply that +// runs past maxBytes. +export function httpOverTor(cfg, host, port, rawRequest, timeoutMs, maxBytes = MAX_RESPONSE_BYTES) { + return new Promise(async (resolve, reject) => { + let socket; + try { + socket = await socks5Connect(cfg.proxyHost, cfg.proxyPort, host, port, timeoutMs); + } catch (e) { return reject(e); } + let buf = Buffer.alloc(0); + let settled = false; + const done = (fn, v) => { if (settled) return; settled = true; clearTimeout(timer); try { socket.destroy(); } catch {} fn(v); }; + const timer = setTimeout(() => done(reject, new Error("read-timeout")), timeoutMs); + socket.on("data", (d) => { + buf = Buffer.concat([buf, d]); + // Rejected the moment the ceiling is crossed rather than at close, so the + // socket is destroyed and the memory released now. Waiting would mean a + // node that never closes still occupies the full timeout while holding + // everything it has sent. done() destroys the socket, so no further data + // events arrive and the partial buffer goes out of scope with this call. + if (buf.length > maxBytes) { + done(reject, new Error(`response exceeded ${maxBytes} bytes`)); + } + }); + socket.on("error", (e) => done(reject, e)); + socket.on("close", () => { + const s = buf.toString("latin1"); + const m = s.match(/^HTTP\/1\.[01] (\d{3})/); + const i = s.indexOf("\r\n\r\n"); + done(resolve, { + status: m ? +m[1] : 0, + body: i >= 0 ? s.slice(i + 4) : "", + rawHead: i >= 0 ? s.slice(0, i + 2) : s, // headers incl. trailing CRLF + bodyBuf: i >= 0 ? buf.subarray(i + 4) : Buffer.alloc(0), // exact bytes for binary payloads + }); + }); + socket.write(rawRequest); + }); +} + +// ---- Dojo version from response headers ------------------------------------- +// The Dojo API sets its running version on every response (X-Dojo-Version). We +// read it opportunistically while probing so the card can show the live value. +// A node is only semi-trusted, so the value is validated and length-capped +// before it can reach a data file: a version looks like 1, 1.28, 1.28.0 or +// 1.28.0-rc1, with an optional leading v that we strip. Anything else -> null. +export function normaliseVersion(raw) { + if (typeof raw !== "string") return null; + const v = raw.trim().replace(/^v/i, "").trim(); + if (!v || v.length > 32) return null; + return /^\d+(\.\d+){0,3}([-+][0-9A-Za-z.]+)?$/.test(v) ? v : null; +} + +// Pull the version out of a raw header block (the CRLF-joined header lines from +// httpOverTor's rawHead, or the accumulated first bytes of a plain probe). +// Header names are case-insensitive; the first occurrence wins. +export function parseDojoVersion(rawHead, headerName = CFG.dojoVersionHeader) { + if (typeof rawHead !== "string" || !rawHead) return null; + const name = String(headerName).toLowerCase(); + for (const line of rawHead.split(/\r?\n/)) { + const idx = line.indexOf(":"); + if (idx < 0) continue; + if (line.slice(0, idx).trim().toLowerCase() !== name) continue; + return normaliseVersion(line.slice(idx + 1)); + } + return null; +} + +// ---- Electrum (indexer) endpoint from /support/services --------------------- +// Dojo v1.27.0 added GET /support/services (ordinary apikey auth, not admin), +// which returns { services: [ { type, kind, url }, … ] }. The "indexer" entry +// is the node's Electrum server, published by the Dojo as +// "://:" and present only when the operator exposes a +// local indexer. Older Dojos have no such route, so absence is normal and is +// reported as "not found" rather than an error. +export function parseIndexerUrl(body) { + let doc; + try { doc = JSON.parse(body); } catch { return null; } + const list = Array.isArray(doc?.services) ? doc.services : null; + if (!list) return null; + const hit = list.find((s) => s && s.type === "indexer" && typeof s.url === "string"); + return hit ? normaliseIndexerUrl(hit.url) : null; +} + +// A listed node is only semi-trusted, so the URL is validated and length-capped +// before it can reach a data file or be rendered as a copyable string. Same +// shape the card already accepts: tcp/ssl, v3 onion, explicit port. +export function normaliseIndexerUrl(raw) { + if (typeof raw !== "string") return null; + const u = raw.trim(); + if (!u || u.length > 120) return null; + return /^(tcp|ssl):\/\/[a-z2-7]{56}\.onion:\d{2,5}$/i.test(u) ? u : null; +} + +// ---- PayNym avatars --------------------------------------------------------- +// Cards embed each node's PayNym avatar in the centre of its pairing QR. The +// front end never fetches from third parties, so the avatar is mirrored here: +// downloaded over Tor from the paynym.rs onion and served locally from +// data/avatars/.png. Missing files are fetched every cycle (which +// also covers newly approved nodes within ten minutes) and existing ones are +// refreshed weekly. Only verified PNG bytes are written; anything else -- an +// error page, a redirect chain, an empty body -- is skipped without touching +// the file, and failures are logged, never fatal. +const PAYNYM_ONION = process.env.PAYNYM_ONION_HOST || "paynym25chftmsywv4v2r67agbrr62lcxagsf4tymbzpeeucucy2ivad.onion"; +const AVATAR_MAX_AGE_MS = 7 * 86400000; +const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + +/** + * @param {string} paymentCode + * @param {{ proxyHost?: string, proxyPort?: number, destDir?: string, + * timeoutMs?: number, host?: string, port?: number }} [opts] + */ +export async function fetchAvatar(paymentCode, { proxyHost, proxyPort, destDir, timeoutMs = 25000, host = PAYNYM_ONION, port = 80 } = {}) { + const cfg = { proxyHost, proxyPort }; + let pathPart = `/${encodeURIComponent(paymentCode)}/avatar`; + for (let hop = 0; hop < 2; hop++) { // follow at most one same-host redirect + const req = `GET ${pathPart} HTTP/1.0\r\nHost: ${host}\r\nUser-Agent: dojobay-checker\r\nConnection: close\r\n\r\n`; + const res = await httpOverTor(cfg, host, port, req, timeoutMs); + if ([301, 302, 307, 308].includes(res.status)) { + const m = res.rawHead && res.rawHead.match(/\r\nlocation:\s*([^\r\n]+)/i); + if (!m) throw new Error("redirect without location"); + const loc = m[1].trim(); + if (/^https?:\/\//i.test(loc)) { + const u = new URL(loc); + if (u.hostname !== host) throw new Error("cross-host redirect"); + pathPart = u.pathname + u.search; + } else pathPart = loc; + continue; + } + if (res.status !== 200) throw new Error(`HTTP ${res.status || "no-response"}`); + const bytes = res.bodyBuf || Buffer.from(res.body, "latin1"); + if (bytes.length < 8 || !bytes.subarray(0, 4).equals(PNG_MAGIC)) throw new Error("not a PNG"); + await fsMkdir(destDir, { recursive: true }); + const dest = path.join(destDir, `${paymentCode}.png`); + const atmp = tmpName(dest); + await writeFile(atmp, bytes); + await rename(atmp, dest); + return dest; + } + throw new Error("too many redirects"); +} + +// Ensure a local avatar exists (and is reasonably fresh) for every listed +// payment code. Small concurrency; per-code failures are logged and skipped. +async function syncAvatars(nodes, destDir) { + const codes = [...new Set(nodes.map((n) => n.paymentCode).filter(Boolean))]; + const wanted = []; + for (const code of codes) { + try { + const st = await fsStat(path.join(destDir, `${code}.png`)); + if (Date.now() - st.mtimeMs < AVATAR_MAX_AGE_MS) continue; + } catch { /* missing -> fetch */ } + wanted.push(code); + } + let i = 0; + const worker = async () => { + for (;;) { + const code = wanted[i++]; + if (!code) return; + try { + await fetchAvatar(code, { proxyHost: CFG.proxyHost, proxyPort: CFG.proxyPort, destDir }); + console.error(`[avatar] fetched ${code.slice(0, 12)}…`); + } catch (e) { + console.error(`[avatar] ${code.slice(0, 12)}…: ${e.message}`); + } + } + }; + await Promise.all(Array.from({ length: Math.min(3, wanted.length) }, worker)); +} + +// Authenticated health check: log in with the node's apikey, then read the +// chain tip from GET /v2/wallet. The Dojo stamps X-Dojo-Version on every +// response, so we harvest it from the first response that carries it (the login +// reply always does) even on an otherwise-down cycle. Returns +// { up, reason, ms, height?, blockTime?, detectedVersion? }. +async function probeHeight(url, cfg) { + const t0 = Date.now(); + const u = new URL(url); + const host = u.hostname; + const port = u.port ? +u.port : 80; + const base = (u.pathname || "/v2").replace(/\/+$/, "") || "/v2"; // e.g. /v2 + const dummy = cfg.network === "testnet" ? DUMMY_TPUB : DUMMY_XPUB; + let detectedVersion = null; + + // 1) login -> access token + let token; + try { + const body = `apikey=${encodeURIComponent(cfg.apikey)}`; + const req = + `POST ${base}/auth/login HTTP/1.0\r\nHost: ${host}\r\n` + + `Content-Type: application/x-www-form-urlencoded\r\nContent-Length: ${Buffer.byteLength(body)}\r\n` + + `User-Agent: dojobay-checker\r\nConnection: close\r\n\r\n${body}`; + const res = await httpOverTor(cfg, host, port, req, cfg.timeoutMs); + detectedVersion = parseDojoVersion(res.rawHead, cfg.dojoVersionHeader) || detectedVersion; + if (res.status !== 200) return { up: false, reason: `login HTTP ${res.status || "no-response"}`, ms: Date.now() - t0, detectedVersion }; + token = JSON.parse(res.body)?.authorizations?.access_token; + if (!token) return { up: false, reason: "login: no token", ms: Date.now() - t0, detectedVersion }; + } catch (e) { + return { up: false, reason: "login: " + e.message, ms: Date.now() - t0, detectedVersion }; + } + + // 2) wallet -> info.latest_block.height + try { + const q = `active=${dummy}&new=${dummy}`; + const req = + `GET ${base}/wallet?${q} HTTP/1.0\r\nHost: ${host}\r\n` + + `Authorization: Bearer ${token}\r\nUser-Agent: dojobay-checker\r\nConnection: close\r\n\r\n`; + const res = await httpOverTor(cfg, host, port, req, cfg.timeoutMs); + detectedVersion = detectedVersion || parseDojoVersion(res.rawHead, cfg.dojoVersionHeader); + if (res.status !== 200) return { up: false, reason: `wallet HTTP ${res.status || "no-response"}`, ms: Date.now() - t0, detectedVersion }; + const info = JSON.parse(res.body)?.info?.latest_block; + const height = info?.height; + if (typeof height !== "number") return { up: false, reason: "wallet: no block height", ms: Date.now() - t0, detectedVersion }; + + // 3) services -> Electrum (indexer) endpoint. Best-effort and strictly + // additive: the node is already known up, so a missing route (pre-1.27.0), + // a node that exposes no indexer, or any error here must never downgrade + // the result. Absence simply means the card shows N/A. + let detectedIndexer = null; + try { + const sreq = + `GET ${base}/support/services HTTP/1.0\r\nHost: ${host}\r\n` + + `Authorization: Bearer ${token}\r\nUser-Agent: dojobay-checker\r\nConnection: close\r\n\r\n`; + const sres = await httpOverTor(cfg, host, port, sreq, cfg.timeoutMs); + detectedVersion = detectedVersion || parseDojoVersion(sres.rawHead, cfg.dojoVersionHeader); + if (sres.status === 200) detectedIndexer = parseIndexerUrl(sres.body); + } catch { /* leave null */ } + + return { up: true, reason: "height", height, blockTime: info.time ?? null, ms: Date.now() - t0, detectedVersion, detectedIndexer }; + } catch (e) { + return { up: false, reason: "wallet: " + e.message, ms: Date.now() - t0, detectedVersion }; + } +} + +// ----------------------------------------------------------------------------- +// Probe a single onion URL. Returns { up, reason, ms }. +// up = Tor connected AND (CONNECT_ONLY, or an HTTP status line came back) +// ----------------------------------------------------------------------------- +// Fill in the transport settings a probe cannot work without. Callers pass a +// partial config (an apikey and a network, say) and it is easy to forget to +// spread PROBE_CFG or CFG alongside it; without these, net.connect is handed an +// undefined port and Node reports 'The "options" or "port" or "path" argument +// must be specified', which says nothing about the real mistake. The defaults +// are the same ones PROBE_CFG uses, so a partial config now behaves rather than +// failing obscurely. Explicitly supplied values always win. +/** + * @param {Partial} [cfg] + * @returns {import("../types.js").ProbeCfg} + */ +export function probeCfg(cfg = {}) { + return { + ...cfg, + proxyHost: cfg.proxyHost ?? (process.env.TOR_SOCKS_HOST || "127.0.0.1"), + proxyPort: cfg.proxyPort ?? +(process.env.TOR_SOCKS_PORT || 9050), + // Same default as CFG below, from one place. These were separate literals + // and had already diverged: the cron path waited 45 seconds while anything + // going through this helper waited 30, so the same node could be up for one + // caller and down for the other. + timeoutMs: cfg.timeoutMs ?? +(process.env.TIMEOUT_MS || DEFAULT_TIMEOUT_MS), + concurrency: cfg.concurrency ?? +(process.env.CONCURRENCY || DEFAULT_CONCURRENCY), + }; +} + +/** + * @param {string} url + * @param {Partial} [cfgIn] + * @returns {Promise} + */ +export async function probe(url, cfgIn = CFG) { + const cfg = probeCfg(cfgIn); + // Preferred path: authenticated chain-tip check when an apikey is available. + if (cfg.apikey) return probeHeight(url, cfg); + const u = new URL(url); + const host = u.hostname; + const port = u.port ? +u.port : (u.protocol === "https:" ? 443 : 80); + const reqPath = (u.pathname || "/") + (u.search || ""); + const t0 = Date.now(); + + let socket; + try { + socket = await socks5Connect(cfg.proxyHost, cfg.proxyPort, host, port, cfg.timeoutMs); + } catch (e) { + return { up: false, reason: e.message, ms: Date.now() - t0 }; + } + + // TLS onions or connect-only mode: a successful Tor stream is the signal. + if (cfg.connectOnly || u.protocol === "https:") { + socket.destroy(); + return { up: true, reason: u.protocol === "https:" ? "tls-connect" : "connect", ms: Date.now() - t0 }; + } + + // Otherwise confirm the Dojo HTTP server actually answers. + return await new Promise((resolve) => { + let got = ""; + let settled = false; + const finish = (up, reason) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + // A code-less node has no apikey, so this is the only chance to read its + // version; the header rides in the same first packet as the status line + // often enough to be worth a look. Absent -> null, harmless. + resolve({ up, reason, ms: Date.now() - t0, detectedVersion: parseDojoVersion(got, cfg.dojoVersionHeader) }); + }; + const timer = setTimeout(() => finish(got.length > 0, got ? "partial" : "read-timeout"), cfg.timeoutMs); + + socket.on("data", (d) => { + got += d.toString("latin1"); + if (/^HTTP\//i.test(got)) finish(true, "http"); + // The same unbounded accumulation httpOverTor had, reached by a different + // door. A well-behaved server puts its status line in the first packet + // and the test above ends the read immediately, but a node that sends + // anything NOT starting with "HTTP/" is never matched, so before this + // guard `got` grew until the timeout with no ceiling at all. A status + // line is a few dozen bytes; 64 KiB without one means this is not an HTTP + // server, which is the answer the probe wanted anyway. + else if (got.length > MAX_STATUS_LINE_BYTES) finish(false, "no-http-response"); + }); + socket.on("error", () => finish(got.length > 0, "socket-error")); + socket.on("close", () => finish(got.length > 0, "closed")); + + socket.write( + `HEAD ${reqPath} HTTP/1.0\r\nHost: ${host}\r\nUser-Agent: dojobay-checker\r\nConnection: close\r\n\r\n` + ); + }); +} + +// ---- date helpers (UTC, matching the formats already in the JSON) ----------- +const p2 = (n) => String(n).padStart(2, "0"); +function stamps(d = new Date()) { + const Y = d.getUTCFullYear(), M = p2(d.getUTCMonth() + 1), D = p2(d.getUTCDate()); + const h = p2(d.getUTCHours()), m = p2(d.getUTCMinutes()), s = p2(d.getUTCSeconds()); + return { + isoSec: `${Y}-${M}-${D}T${h}:${m}:${s}Z`, // generated_at + isoMin: `${Y}-${M}-${D}T${h}:${m}Z`, // history check timestamp + dateTime: `${Y}-${M}-${D} ${h}:${m}:${s}`, // node.checked_at + }; +} + +// ---- small concurrency pool ------------------------------------------------- +async function pool(items, limit, fn) { + const out = new Array(items.length); + let i = 0; + const worker = async () => { + while (i < items.length) { + const idx = i++; + out[idx] = await fn(items[idx], idx); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return out; +} + +async function readJSON(file, fallback) { + try { return JSON.parse(await readFile(file, "utf8")); } + catch (e) { if (e.code === "ENOENT" && fallback !== undefined) return fallback; throw e; } +} + +// A temporary name no other writer can take. +// +// Every atomic write here was `.tmp`, which is not atomic between +// processes: two writers produce the same path, the first rename consumes it, +// and the second fails with ENOENT on a file it had just written. That is not +// hypothetical. The installer enables the update timer and then runs its own +// first probe cycle, and once the timer gained a calendar schedule with +// Persistent=true, enabling it fired a catch-up run immediately rather than +// after two minutes. Two updaters wrote data/dojos.json.tmp at once and the +// install ended by announcing failures on a directory that was already +// updating. +// +// The pid and a counter are enough: the collision is between processes on one +// machine, and the rename is what makes the swap atomic for readers. +function tmpName(file) { + return `${file}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`; +} +let tmpSeq = 0; + +// Write atomically: a reader (the website) never sees a half-written file. +async function writeJSONAtomic(file, obj) { + const tmp = tmpName(file); + await writeFile(tmp, JSON.stringify(obj, null, 2) + "\n"); + await rename(tmp, file); +} + +// Merge seed + approved submissions into the public list (delegates to +// server/build-public.mjs, which preserves live statuses and histories). +// Exported so the self-test can drive it against isolated data directories. +export async function reconcilePublicList() { + if (!process.env.PUBLIC_DATA_DIR) process.env.PUBLIC_DATA_DIR = CFG.dataDir; + const { rebuild } = await import("../server/build-public.ts"); + return rebuild(); +} + +// ----------------------------------------------------------------------------- +async function main() { + const dojosPath = path.join(CFG.dataDir, "dojos.json"); + // Reconcile FIRST: fold the curated seed and every APPROVED submission into + // dojos.json before this cycle reads it. The admin approve does its own + // rebuild, but that write is lost if it lands while a probe cycle (minutes + // long over Tor) is in flight, because the cycle writes back the node list + // it read at the start. Rebuilding here means an approved node can be absent + // for at most one cycle, never indefinitely. + try { + const r = await reconcilePublicList(); + console.error(`[reconcile] ${r.msg}`); + } catch (e) { + console.error(`[reconcile] skipped: ${e.message}`); + } + const historyPath = path.join(CFG.dataDir, "history.json"); + + const dojos = await readJSON(dojosPath); + if (!dojos || !Array.isArray(dojos.nodes)) throw new Error(`bad or missing ${dojosPath}`); + // Keep the self-hosted source download current: regenerate the zip when it + // is missing or older than data/version.json (i.e. after any code deploy). + try { + const zipPath = path.join(CFG.dataDir, "dojobay-src.zip"); + const verPath = path.join(CFG.dataDir, "version.json"); + const zipSt = await fsStat(zipPath).catch(() => null); + const verSt = await fsStat(verPath).catch(() => null); + if (!zipSt || (verSt && verSt.mtimeMs > zipSt.mtimeMs)) { + const { packSource } = await import("./pack-source.mjs"); + const r = await packSource({ outDir: CFG.dataDir }); + console.error(`[src-zip] repacked: ${r.files} files, ${(r.bytes / 1024).toFixed(0)} KiB`); + } + } catch (e) { console.error(`[src-zip] skipped: ${e.message}`); } + + // Mirror PayNym avatars for every listed code (non-blocking for the probes). + const operatorDoc = await readJSON(path.join(CFG.dataDir, "operator.json")).catch(() => null) ?? {}; + const avatarSubjects = dojos.nodes.concat(operatorDoc.paymentCode ? [{ paymentCode: operatorDoc.paymentCode }] : []); + const avatarsDone = syncAvatars(avatarSubjects, path.join(CFG.dataDir, "avatars")).catch((e) => console.error("[avatar]", e.message)); + const history = await readJSON(historyPath, { interval_minutes: 10, window_checks: CFG.windowChecks, nodes: {} }); + const window = history.window_checks || CFG.windowChecks; + + const now = new Date(); + const ts = stamps(now); + console.error(`[${ts.isoSec}] probing ${dojos.nodes.length} nodes via socks5h://${CFG.proxyHost}:${CFG.proxyPort} (timeout ${CFG.timeoutMs}ms, concurrency ${CFG.concurrency})`); + + const results = await pool(dojos.nodes, CFG.concurrency, async (n) => { + const url = n?.payload?.pairing?.url; + if (!url) return { up: false, reason: "no pairing url", ms: 0 }; + return probe(url, { ...CFG, apikey: n?.payload?.pairing?.apikey, network: n.network }); + }); + + // ---- did this cycle learn anything? ---- + // + // Fifteen independently operated nodes on different continents do not fail in + // the same ten-minute window. When every one of them fails, the cause is here: + // Tor rebuilding circuits after a suspend, a home connection renegotiating, + // the daemon restarted underneath us. Recording that would write a DOWN check + // against every operator in the directory and pull down reliability figures + // this instance publishes about other people's machines, for a fault of its + // own. So it is not recorded. + // + // The threshold is zero rather than a proportion. A cycle where some nodes + // answer proves the local path works, and the ones that did not answer really + // did not; only a clean sweep is evidence about this machine instead of about + // them. A directory with one listing would trip this on a genuine outage, and + // that is the right trade: withholding one node's bad cycle costs far less + // than publishing a false one against everybody. + const allFailed = dojos.nodes.length > 0 && results.every((r) => !r.up); + + // ---- update current snapshot ---- + let up = 0; + dojos.nodes.forEach((n, i) => { + const r = results[i]; + if (r.up) up++; + n.status = r.up ? "active" : "inactive"; + n.checked_at = ts.dateTime; + // Record the tip height when we read one; keep the last known height on a + // down cycle so the card can still show where the node last was. + if (typeof r.height === "number") n.block_height = r.height; + else if (!("block_height" in n)) n.block_height = null; + // Same sticky rule for the version read from X-Dojo-Version: update it when + // this cycle saw one, otherwise leave the last known value in place. The + // effective card version (operator override > detected > pairing default) + // is computed by build-public.mjs, which carries this field across the + // reconcile rebuild that opens every cycle. + if (r.detectedVersion) n.detected_version = r.detectedVersion; + else if (!("detected_version" in n)) n.detected_version = null; + // Same sticky rule for the Electrum endpoint read from /support/services: + // keep the last known value when a cycle didn't read one, so a node that is + // merely down for a cycle doesn't flip its card to N/A. build-public.mjs + // computes the published value and carries this field across the rebuild. + if (r.detectedIndexer) n.detected_indexer = r.detectedIndexer; + else if (!("detected_indexer" in n)) n.detected_indexer = null; + }); + dojos.interval_minutes = dojos.interval_minutes || 10; + + if (allFailed) { + // Publish the fault and nothing else. Statuses, heights and checked_at stay + // as the last cycle that actually reached something left them, and + // generated_at is deliberately not advanced, so the staleness banner keeps + // measuring the age of real data rather than the age of a failure. + const fresh = await readJSON(dojosPath, null); + if (fresh) { + fresh.probe_fault = { at: ts.isoSec, nodes: dojos.nodes.length }; + await writeJSONAtomic(dojosPath, fresh); + } + console.error(`[${ts.isoSec}] every one of ${dojos.nodes.length} nodes failed, which is` + + " almost certainly a fault here rather than all of them at once."); + console.error(" Nothing was recorded: no statuses changed and no history written."); + console.error(" Check Tor on this machine (systemctl status tor@default), and the clock."); + return; + } + dojos.generated_at = ts.isoSec; + delete dojos.probe_fault; + + // ---- update rolling history (append + trim, retire stale ids) ---- + const listed = new Set(dojos.nodes.map((n) => n.id)); + const histNodes = {}; + dojos.nodes.forEach((n, i) => { + const prev = (history.nodes?.[n.id]?.checks) || []; + const checks = prev.concat([{ t: ts.isoMin, up: results[i].up }]); + if (checks.length > window) checks.splice(0, checks.length - window); + histNodes[n.id] = { checks }; + }); + // Unlisted ids are kept under a `retired` stamp for HISTORY_GRACE_DAYS (same + // rule as build-public.mjs), so a bad or transient node list cannot destroy + // accumulated history; a resurrected id resumes where it left off. + for (const id of Object.keys(history.nodes || {})) if (!histNodes[id]) histNodes[id] = history.nodes[id]; + retireUnlisted(histNodes, (id) => listed.has(id), ts.isoSec); + + await writeJSONAtomic(dojosPath, dojos); + await writeJSONAtomic(historyPath, { + generated_at: ts.isoSec, + interval_minutes: history.interval_minutes || 10, + window_checks: window, + nodes: histNodes, + }); + + // ---- update 90-day daily rollup (per-day uptime + closing block height) ---- + // One record per node per UTC day; `close` is the last height read that day, + // so at day's end it holds the closing height. Retained RETENTION_DAYS days. + const dailyPath = path.join(CFG.dataDir, "history-daily.json"); + const daily = await readJSON(dailyPath, { retention_days: CFG.retentionDays, nodes: {} }); + const today = ts.dateTime.slice(0, 10); // YYYY-MM-DD (UTC) + const dailyNodes = {}; + dojos.nodes.forEach((n, i) => { + const r = results[i]; + const days = ((daily.nodes?.[n.id]?.days) || []).map((d) => ({ ...d })); + let rec = days.length && days[days.length - 1].d === today ? days[days.length - 1] : null; + if (!rec) { rec = { d: today, up: 0, total: 0, pct: 0, close: null }; days.push(rec); } + rec.total += 1; + if (r.up) rec.up += 1; + rec.pct = Math.round((rec.up / rec.total) * 1000) / 10; + if (typeof r.height === "number") rec.close = r.height; + if (days.length > CFG.retentionDays) days.splice(0, days.length - CFG.retentionDays); + dailyNodes[n.id] = { days }; + }); + for (const id of Object.keys(daily.nodes || {})) if (!dailyNodes[id]) dailyNodes[id] = daily.nodes[id]; + retireUnlisted(dailyNodes, (id) => listed.has(id), ts.isoSec); + await writeJSONAtomic(dailyPath, { + generated_at: ts.isoSec, + retention_days: CFG.retentionDays, + nodes: dailyNodes, + }); + + // ---- probe PENDING submissions so the operator sees uptime before approving + // Results are written server-side only (server/data/pending-probe.json), never + // to the public data/, so an unapproved submission is not exposed over Tor. + try { + const { store } = await import("../server/store.ts"); + const serverDataDir = process.env.SERVER_DATA_DIR + || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "server", "data"); + const pendingPath = path.join(serverDataDir, "pending-probe.json"); + const subs = (await store.listSubmissions()).filter((s) => s.status === "pending"); + if (subs.length) { + const prevDoc = await readJSON(pendingPath, { window_checks: window, nodes: {} }); + const presults = await pool(subs, CFG.concurrency, async (s) => { + const url = s?.payload?.pairing?.url; + if (!url) return { up: false, reason: "no pairing url", ms: 0 }; + return probe(url, { ...CFG, apikey: s?.payload?.pairing?.apikey, network: s.network }); + }); + const pnodes = {}; + subs.forEach((s, i) => { + const r = presults[i]; + const prev = (prevDoc.nodes?.[s.id]?.checks) || []; + const checks = prev.concat([{ t: ts.isoMin, up: r.up }]); + if (checks.length > window) checks.splice(0, checks.length - window); + pnodes[s.id] = { + status: r.up ? "active" : "inactive", + checked_at: ts.dateTime, + block_height: typeof r.height === "number" ? r.height + : (prevDoc.nodes?.[s.id]?.block_height ?? null), + detected_version: r.detectedVersion || (prevDoc.nodes?.[s.id]?.detected_version ?? null), + detected_indexer: r.detectedIndexer || (prevDoc.nodes?.[s.id]?.detected_indexer ?? null), + checks, + }; + }); + await writeJSONAtomic(pendingPath, { generated_at: ts.isoSec, window_checks: window, nodes: pnodes }); + console.error(`[${ts.isoSec}] probed ${subs.length} pending submission(s)`); + } + } catch (e) { + console.error(`[${ts.isoSec}] pending probe skipped: ${e.message}`); + } + + console.error(`[${ts.isoSec}] done: ${up}/${dojos.nodes.length} active`); + for (const [i, n] of dojos.nodes.entries()) { + const r = results[i]; + console.error(` ${r.up ? "UP " : "DOWN"} ${n.id.padEnd(28)} ${String(r.ms).padStart(6)}ms ${r.reason || ""}`); + } + await avatarsDone; // let in-flight avatar mirrors finish before the timer unit exits +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) { + main().catch((e) => { console.error("fatal:", e.message); process.exit(1); }); +} diff --git a/docker/dojobay/server/admin.mjs b/docker/dojobay/server/admin.mjs new file mode 100644 index 00000000..2714d6a5 --- /dev/null +++ b/docker/dojobay/server/admin.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +// Maintainer moderation CLI (run on the server by a maintainer over SSH). +// node admin.mjs list show pending/approved/rejected +// node admin.mjs approve [paynym] approve a submission (optionally set its PayNym) +// node admin.mjs reject reject a submission +// node admin.mjs remove delete a submission outright +// After approving/rejecting, run build-public.mjs to regenerate the public list. +import { store } from "./store.ts"; +import { resolvePayNym } from "./paynym.mjs"; + +const [cmd, id, extra] = process.argv.slice(2); + +function line(r) { + return `${r.status.padEnd(8)} ${r.id.padEnd(26)} ${r.network.padEnd(7)} ${(r.paynym || "-").padEnd(18)} ${(r.name || "-").padEnd(18)} ${r.payload?.pairing?.url || ""}`; +} + +const cmds = { + async list() { + const subs = await store.listSubmissions(); + if (!subs.length) return console.log("(no submissions)"); + for (const r of subs.sort((a, b) => (a.status).localeCompare(b.status))) console.log(line(r)); + }, + async approve() { + const r = await store.getSubmission(id); + if (!r) return console.error("no such submission:", id); + r.status = "approved"; + if (extra) { + r.paynym = extra.startsWith("+") ? extra : "+" + extra; // maintainer override + } else if (!r.paynym) { + const resolved = await resolvePayNym((r.paymentCodes || [])[0]).catch(() => null); + if (resolved) r.paynym = resolved; + } + r.updated_at = new Date().toISOString(); + await store.putSubmission(r); + console.log("approved:", id, "paynym:", r.paynym || "(none set — pass one as the 3rd arg)"); + console.log("now run: node build-public.mjs"); + }, + async reject() { + const r = await store.getSubmission(id); + if (!r) return console.error("no such submission:", id); + r.status = "rejected"; r.updated_at = new Date().toISOString(); + await store.putSubmission(r); + console.log("rejected:", id, "(run build-public.mjs to drop it from the public list)"); + }, + async remove() { + await store.deleteSubmission(id); + console.log("removed:", id); + }, +}; + +(cmds[cmd] || (async () => { console.log("usage: node admin.mjs [list|approve [paynym]|reject |remove ]"); }))() + .then(() => process.exit(0)) + .catch((e) => { console.error("error:", e.message); process.exit(1); }); diff --git a/docker/dojobay/server/apply-signed-payload.ts b/docker/dojobay/server/apply-signed-payload.ts new file mode 100644 index 00000000..550d43d7 --- /dev/null +++ b/docker/dojobay/server/apply-signed-payload.ts @@ -0,0 +1,181 @@ +#!/usr/bin/env node +// ============================================================================= +// The Dojo Bay — apply operator-signed pairing payload updates. +// +// Takes signed blocks an operator has sent out of band (a re-signed pairing +// payload, a new apikey, a moved onion) and applies them to the store, doing +// exactly what the submission gate would have done had they gone through the +// site: +// +// 1. the block must parse, and its signature must be valid over its own text; +// 2. the BIP47 code inside the signed text must derive the signing address; +// 3. that code must already own a record here, which is how the update is +// matched to a listing; +// 4. the payload written is the one INSIDE the signed block, so what is +// published is exactly what the operator attested to. +// +// The record's id is never changed, so its reliability history survives. Status +// is left alone: an approved listing stays approved, a pending one stays pending. +// +// Usage, on the box: +// cd /var/www/dojobay/server +// node apply-signed-payload.ts blocks/*.txt # dry run +// sudo systemctl stop dojobay-server.service +// node apply-signed-payload.ts --apply blocks/*.txt +// sudo systemctl start dojobay-server.service +// node audit-signed.mjs +// +// Each file holds one signed block. `--id ` pins the target when a +// payment code owns more than one listing. As with fix-payload-version, --apply +// refuses to run while the service is up, because store.ts holds the store in +// memory as a single writer and would overwrite the edit. +// ============================================================================= +import { readFile, writeFile, rename, copyFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +// canonicalPairing is imported, never reimplemented: this tool must accept +// exactly what the submission gate accepts, and a second definition of the +// canonical message would diverge silently. server/selftest.mjs enforces it. +import { parseSignedBlock, verifySignedPayload, notificationAddresses, repairSignedBlock, canonicalPairing } from "./crypto.ts"; +import type { StoreRecord } from "../types.js"; + +const argv = process.argv.slice(2); +const APPLY = argv.includes("--apply"); +const FORCE = argv.includes("--force"); +const idFlag = argv.indexOf("--id"); +const PINNED_ID = idFlag >= 0 ? argv[idFlag + 1] : null; +const FILES = argv.filter((a, i) => + !a.startsWith("--") && !(idFlag >= 0 && i === idFlag + 1)); + +const DIR = process.env.SERVER_DATA_DIR + || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "data"); +const FILE = path.join(DIR, "store.json"); + +if (!FILES.length) { + console.error("Usage: node apply-signed-payload.ts [--apply] [--id ] …\n" + + "Each file contains one BEGIN BITCOIN SIGNED MESSAGE block."); + process.exit(2); +} + +if (APPLY && !FORCE) { + let active = ""; + try { active = execFileSync("systemctl", ["is-active", "dojobay-server.service"], { encoding: "utf8" }).trim(); } + catch (e: any) { active = (e.stdout || "").trim(); } + if (active === "active") { + console.error("REFUSING: dojobay-server.service is running.\n" + + "The store is held in memory by the server and would overwrite this edit.\n" + + " sudo systemctl stop dojobay-server.service\n" + + " node apply-signed-payload.ts --apply \n" + + " sudo systemctl start dojobay-server.service"); + process.exit(2); + } +} + +const doc = JSON.parse(await readFile(FILE, "utf8")); +const records: StoreRecord[] = Object.values(doc.submissions || {}); + +interface Planned { file: string; rec: StoreRecord; payload: any; signed: string; before: string; after: string; note: string | null } +const planned: Planned[] = []; +const refused: [string, string][] = []; + +for (const file of FILES) { + let signed: string; + try { signed = await readFile(file, "utf8"); } + catch (e: any) { refused.push([file, "cannot read: " + e.message]); continue; } + + // Copying a block through chat, a form or a mail client routinely eats the + // blank line before the BIP47 line, which the signature covers. Repair it if + // a reconstruction verifies cryptographically; nothing is taken on trust. + let note: string | null = null; + const repaired = repairSignedBlock(signed); + if (repaired) { signed = repaired.block; note = repaired.note; } + + const parsed = parseSignedBlock(signed); + if (!parsed) { refused.push([file, "not a recognisable signed block"]); continue; } + if (!parsed.paymentCode) { refused.push([file, "the signed text has no BIP47 line, so it cannot be matched to an operator"]); continue; } + + // The payload published is the one inside the signed block, never a + // hand-copied version of it. + let payload: any; + try { payload = JSON.parse(parsed.pairingText); } + catch { refused.push([file, "the signed text is not a bare pairing JSON"]); continue; } + if (!payload?.pairing?.url || !payload?.pairing?.type) { + refused.push([file, "the signed payload has no pairing.url/type"]); continue; + } + + const addrs = notificationAddresses(parsed.paymentCode); + const v = verifySignedPayload({ + signedText: signed, + expectedMessage: canonicalPairing(payload), + expectedAddress: addrs, + }); + if (!v.ok) { refused.push([file, v.error]); continue; } + + const owned = records.filter((r) => (r.paymentCodes || []).includes(parsed.paymentCode!)); + const target = PINNED_ID ? owned.find((r) => r.id === PINNED_ID) : (owned.length === 1 ? owned[0] : undefined); + if (!owned.length) { + refused.push([file, `signature is valid, but ${parsed.paymentCode.slice(0, 12)}… owns no record here`]); continue; + } + if (!target) { + refused.push([file, `that code owns ${owned.length} records (${owned.map((r) => r.id).join(", ")}); re-run with --id`]); continue; + } + + planned.push({ + file, rec: target, payload, signed: signed.trim(), note, + before: target.payload?.pairing?.url || "(none)", + after: payload.pairing.url, + }); +} + +console.log(`Store: ${FILE}`); +console.log(`Blocks read: ${FILES.length}\n`); + +if (planned.length) { + console.log(`Will update (${planned.length}):`); + for (const p of planned) { + console.log(` ${p.rec.id} (${p.rec.status}) from ${path.basename(p.file)}`); + console.log(` url ${p.before}`); + console.log(` -> ${p.after}`); + const bv = p.rec.payload?.pairing?.version, av = p.payload.pairing.version; + if (bv !== av) console.log(` version ${bv || "(none)"} -> ${av || "(none)"}`); + if (!p.rec.signed) console.log(" (record was UNSIGNED; it gains a verified signature)"); + if (p.note) console.log(` note: ${p.note}, and the repaired block verifies`); + } + console.log(""); +} +if (refused.length) { + console.log(`Refused (${refused.length}):`); + for (const [f, why] of refused) console.log(` ${path.basename(f)}: ${why}`); + console.log(""); +} +if (!planned.length) { console.log("Nothing to apply."); process.exit(refused.length ? 1 : 0); } + +if (!APPLY) { + console.log("DRY RUN — nothing written. Re-run with --apply (service stopped) to make these changes."); + process.exit(0); +} + +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); +const backup = `${FILE}.bak-${stamp}`; +await copyFile(FILE, backup); +const nowIso = new Date().toISOString(); +for (const p of planned) { + const rec = doc.submissions[p.rec.id]; + rec.payload = p.payload; // exactly what was signed + rec.signed = p.signed; + rec.updated_at = nowIso; +} +// A temporary name no other writer can take; see build-public.ts. This tool +// refuses to run while the service holds the store, so a collision needs two +// maintenance tools at once, which is exactly the case nobody plans for. +const tmp = `${FILE}.${process.pid}.tmp`; +await writeFile(tmp, JSON.stringify(doc, null, 2) + "\n"); +await rename(tmp, FILE); + +console.log(`Backup written: ${backup}`); +console.log(`Applied ${planned.length} update(s).`); +console.log("Start the service again, then run audit-signed.mjs; each updated record\n" + + "should now read VERIFIED. The published dojos.json follows on the next\n" + + "updater cycle, or immediately if you run build-public.mjs."); +process.exit(refused.length ? 1 : 0); diff --git a/docker/dojobay/server/audit-signed.mjs b/docker/dojobay/server/audit-signed.mjs new file mode 100644 index 00000000..807175a0 --- /dev/null +++ b/docker/dojobay/server/audit-signed.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +// ============================================================================= +// The Dojo Bay — audit stored signed pairing blocks. +// +// READ-ONLY. Walks every record in the submission store and re-checks its +// stored `signed` block with exactly the gate the submit endpoint uses +// (verifySignedPayload over canonicalPairing(payload), against the notification +// address of the record's own payment code). Nothing is written, no network is +// touched, and the store is only ever read. +// +// Why this exists: records approved before the signed-message parser was fixed +// were checked by a parser that excised the BIP47 tail before verifying, so the +// verdict they received then is not the verdict they would receive now. This +// tells you whether anything was left behind. +// +// Run on the box as the deploy user: +// cd /var/www/dojobay/server && node audit-signed.mjs +// SERVER_DATA_DIR defaults to ./data, the same path the server uses; set it +// only if your store lives elsewhere. +// +// Buckets: +// VERIFIED the stored signature is valid for one of the record's codes +// FAILED a signature is present but verifies for none of them +// UNSIGNED no signature stored (pre-gate migration, or a code-less record) +// ERROR the record could not be evaluated at all +// Exits non-zero if anything is FAILED, ERROR or UNSIGNED, so it can back a +// cron check. UNSIGNED counted as a failure since the signature became a +// structural requirement: the store refuses to write such a record and the +// rebuild withholds it, so one showing up here is not awaiting a decision. +// ============================================================================= +import { store } from "./store.ts"; +import { verifySignedPayload, notificationAddresses, canonicalPairing } from "./crypto.ts"; + +const networkOf = (rec) => (rec.network === "testnet" ? "testnet" : "bitcoin"); + +// Exported so the test suite can assert this reproduces the gate's verdict. +// This MUST mirror server/index.mjs's signature gate exactly: same canonical +// message, and the same set of acceptable signing addresses. An earlier version +// derived the notification address for the record's own network, which meant +// every testnet listing was reported as failing even though the gate accepted +// it, because a PayNym signs from its mainnet address whatever the node is. +export function auditRecord(rec) { + const codes = Array.isArray(rec.paymentCodes) ? rec.paymentCodes : []; + if (!rec.signed) { + return { bucket: "UNSIGNED", detail: codes.length ? "record has a payment code but no signed block" : "no signed block and no payment code" }; + } + const net = networkOf(rec); + const expectedMessage = canonicalPairing(rec.payload); + const tried = []; + // A PayNym may have signed with either BIP47 variant, so every code on the + // record is a legitimate candidate; the first that verifies wins. + for (const code of codes) { + const addrs = notificationAddresses(code); + if (!addrs.length) { tried.push(`${code.slice(0, 12)}…: undecodable code`); continue; } + const r = verifySignedPayload({ signedText: rec.signed, expectedMessage, expectedAddress: addrs, network: net }); + if (r.ok) return { bucket: "VERIFIED", detail: `${code.slice(0, 12)}… → ${addrs[0]}` }; + tried.push(`${code.slice(0, 12)}… (${addrs.join(" / ")}): ${r.error}`); + } + if (!codes.length) { + const r = verifySignedPayload({ signedText: rec.signed, expectedMessage, network: net }); + return r.ok + ? { bucket: "FAILED", detail: "signature is internally valid but the record carries no payment code to bind it to" } + : { bucket: "FAILED", detail: r.error }; + } + return { bucket: "FAILED", detail: tried.join("\n ") }; +} + +// ---- CLI --------------------------------------------------------------- +// Only runs when executed directly, so tests can import auditRecord. +const isMain = process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href; +if (!isMain) { /* imported for testing */ } else { + +const recs = (await store.listSubmissions()) + .sort((a, b) => (a.network + a.name).localeCompare(b.network + b.name)); + +const buckets = { VERIFIED: [], FAILED: [], UNSIGNED: [], ERROR: [] }; +for (const rec of recs) { + let res; + try { res = auditRecord(rec); } catch (e) { res = { bucket: "ERROR", detail: e.message }; } + buckets[res.bucket].push({ rec, detail: res.detail }); +} + +console.log(`Audited ${recs.length} record(s) in the store.\n`); +for (const b of ["FAILED", "ERROR", "UNSIGNED", "VERIFIED"]) { + if (!buckets[b].length) continue; + console.log(`${b}: ${buckets[b].length}`); + for (const { rec, detail } of buckets[b]) { + // Show the name as well as the id. Ids are immutable (reliability history + // keys on them), so a record created before operator naming keeps its + // payment-code-derived id even after its operator sets a name, and the id + // alone is then unrecognisable. + const label = rec.name && `${rec.network}-${rec.name}` !== rec.id ? `${rec.id} ("${rec.name}")` : rec.id; + console.log(` [${b}] ${label} (${rec.status})${detail ? "\n " + detail : ""}`); + } + console.log(""); +} + +// An UNSIGNED record is now a failure, not a decision. Until the signature rule +// existed there was a legitimate answer to "this record predates the gate" and +// the audit deliberately left the judgement to a maintainer. The store now +// refuses to write such a record and the rebuild withholds it, so one appearing +// here means something got in around those rules or predates them, and either +// way it is not being published and needs dealing with. +const bad = buckets.FAILED.length + buckets.ERROR.length + buckets.UNSIGNED.length; +console.log( + `Summary: ${buckets.VERIFIED.length} verified, ${buckets.FAILED.length} failed, ` + + `${buckets.UNSIGNED.length} unsigned, ${buckets.ERROR.length} error.` + + (buckets.UNSIGNED.length ? "\nUNSIGNED records are withheld from the public list. Ask the operator to sign their\npairing payload and resubmit, or remove the listing with server/remove-listing.ts." : "") + + (bad ? `\nNON-ZERO EXIT: ${bad} record(s) need attention.` : "\nEvery record carries a signature and every signature verifies under the current gate.")); +process.exit(bad ? 1 : 0); +} diff --git a/docker/dojobay/server/build-public.mjs b/docker/dojobay/server/build-public.mjs new file mode 100644 index 00000000..c836c45e --- /dev/null +++ b/docker/dojobay/server/build-public.mjs @@ -0,0 +1,39 @@ +// Launcher for the public-list rebuild, which lives in build-public.ts. +// +// Kept as plain JavaScript, and kept under this name, for the same reasons as +// index.mjs: +// +// 1. It parses on any Node, so an operator on an older runtime gets the +// message below rather than a syntax error from a file their Node cannot +// execute. The check must precede the import, hence the dynamic import. +// 2. A lot of things outside this file invoke it by name: the deploy workflow, +// `npm run build-public`, scripts/install.mjs, and — importantly — +// scripts/apply-update.mjs, which spawns it during a self-update. That +// helper is the OLD copy still running while new files are swapped in, so +// an instance updating ACROSS a rename would spawn a file that no longer +// exists and its rebuild would fail. +// +// New in-process callers should import ./build-public.ts directly. +const major = Number(process.versions.node.split(".")[0]); +if (Number.isNaN(major) || major < 24) { + console.error( + `The Dojo Bay rebuild needs Node 24 or newer (found ${process.versions.node}).\n` + + "It runs TypeScript directly, which relies on type stripping added in Node 24.\n" + + "Upgrade Node, then re-run the rebuild."); + process.exit(1); +} + +const mod = await import("./build-public.ts"); +export const rebuild = mod.rebuild; +export const displayPaymentCode = mod.displayPaymentCode; +export const effectiveVersion = mod.effectiveVersion; +export const effectiveIndexer = mod.effectiveIndexer; +export const retireUnlisted = mod.retireUnlisted; + +// Run the rebuild when invoked directly (the .ts module's own check does not +// fire in that case, because argv[1] is this launcher). +import { pathToFileURL } from "node:url"; +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) { + const r = await mod.rebuild(); + console.log(r.msg); +} diff --git a/docker/dojobay/server/build-public.ts b/docker/dojobay/server/build-public.ts new file mode 100644 index 00000000..f4fa7d25 --- /dev/null +++ b/docker/dojobay/server/build-public.ts @@ -0,0 +1,379 @@ +#!/usr/bin/env node +// Merge the curated seed list with APPROVED self-service submissions into the +// public data/dojos.json that the front-end and the 10-minute updater consume. +// The seed list (data/seed.json) stays under maintainer control; only approved +// submissions are added. A newly-approved node inherits the status, block +// height and reliability history the updater already recorded for it while it +// was pending (see scripts/update.mjs and server/data/pending-probe.json), so +// it appears active with its uptime intact the moment it is published. +// +// Exposes rebuild() for in-process use by the admin API; runs it when invoked +// directly from the CLI. +import { readFile, writeFile, rename, mkdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { pathToFileURL } from "node:url"; +import { store, hasSignedBlock } from "./store.ts"; +import { urlOnDomain } from "./domains.ts"; +import type { PublicNode, PairingPayload, StoreRecord } from "../types.js"; + +/** The generated data/dojos.json. */ +interface PublicDoc { + generated_at?: string; + interval_minutes?: number; + nodes: PublicNode[]; +} +/** A history file: per-node check lists or daily rollups, keyed by record id. */ +type HistoryMap = Record; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +async function readJSON(p: string, fallback: T): Promise { + try { return JSON.parse(await readFile(p, "utf8")); } + catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") return fallback; throw e; } +} +// A temporary name no other writer can take. `.tmp` is not atomic +// between processes: two writers produce the same path, the first rename +// consumes it, and the second fails with ENOENT on a file it had just written. +// See scripts/update.mjs for the install that did exactly that. +let tmpSeq = 0; +async function writeAtomic(p, obj) { + await mkdir(path.dirname(p), { recursive: true }); + const tmp = `${p}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`; + await writeFile(tmp, JSON.stringify(obj, null, 2) + "\n"); + await rename(tmp, p); +} + +// The payment code shown on a card. A PayNym commonly has two BIP47 variants +// and records store every variant; the canonical one people share (and the one +// shown on paynym.rs profiles) is the NON-segwit code, so prefer that when the +// paynym-codes mapping can identify it, falling back to the record's first. +// Exported for the self-test. +/** Only the two fields it actually reads, so callers need not build a whole + * record to ask which variant to display. */ +type CodeBearing = { paymentCodes?: string[] | null; paynym?: string | null }; + +export function displayPaymentCode(sub: CodeBearing, mapping: any): string | null { + const codes = Array.isArray(sub.paymentCodes) ? sub.paymentCodes : []; + if (!codes.length) return null; + const entry = sub.paynym && mapping && mapping[sub.paynym]; + const legacy = entry && (entry.codes || []).find((c) => !c.segwit && codes.includes(c.code)); + return (legacy && legacy.code) || codes[0]; +} + +// The version shown on a card is derived entirely from the node's API, never +// set by an operator. In priority order: +// 1. the version the updater last read live from the node's X-Dojo-Version +// response header (detected_version, carried in dojos.json), +// 2. the version in the pairing payload, used only as a bootstrap fallback +// until the first probe reads a live header (and for older nodes that do +// not emit the header). It is itself an API value, captured from the +// Dojo's pairing output at submission time. +// There is deliberately no operator override: the version always reflects what +// the node reports. To show nothing until a live header is read, drop the +// pairing fallback. +export function effectiveVersion(detected: string | null | undefined, pairing: string | null | undefined): string | null { + return detected || pairing || null; +} + +// The Electrum endpoint shown on a card. Only what the node reported about +// itself: the updater reads it from the Dojo's /support/services each cycle, +// over the API onion the operator's signature fixes. +// +// A URL declared in a submitted payload is NOT a fallback and must not become +// one: nothing signs it, and a node that is healthy but exposes no indexer +// never acquires a detected value, so a declared URL would be published for +// good. docs/decisions.md, entry 00d07ae, has the reasoning. +// +// Null means the card shows N/A, which is a real answer (no exposed indexer) +// rather than an omission, and is now reachable for every node. +export function effectiveIndexer(detected: string | null | undefined): string | null { + return detected || null; +} + +// Every key the published dojos.json may contain for a node. Exported so the +// suite can assert on it rather than restating it, and so that adding a field +// to toPublicNode without adding it here fails the gate: publishing a new field +// should be a decision somebody makes, not a consequence of editing a record +// shape somewhere else. +export const PUBLIC_NODE_KEYS = Object.freeze([ + "id", "network", "name", "status", "paynym", "paymentCode", + "jurisdiction", "country", "hardware", "version", "detected_version", + "detected_indexer", "operator_domain", "operator_domain_proof", + "block_height", "indexer_url", "checked_at", "payload", "signed", +]); + +// The allowlist itself, and the only producer of a published node. +// +// It names every field rather than deleting the ones it does not want, which is +// the distinction that matters: a redaction list is wrong by default and has to +// be updated whenever the store gains a field, whereas this is right by default +// and has to be updated whenever the PUBLIC shape should change. The store +// holds things that must never be published (moderation status, the owning +// payment codes, submission timestamps, the probe result recorded at +// submission, import provenance) and it will hold more in future. +// +// One field is copied wholesale rather than picked apart: `payload`. That is +// deliberate, since the pairing payload including its API key is the entire +// point of a listing and a visitor needs it byte for byte to pair. It does mean +// the allowlist has a nested edge: anything added inside payload is published. +// The store gate is what keeps that honest, since payload is what the operator +// signed and the signature covers its exact contents. +function toPublicNode(sub: StoreRecord, paymentCode: string | null): PublicNode { + return { + id: sub.id, + network: sub.network, + name: sub.name || sub.paynym || sub.id, + status: "inactive", + paynym: sub.paynym || null, + paymentCode: paymentCode || null, + jurisdiction: sub.jurisdiction || null, + country: sub.country || null, + hardware: sub.hardware || null, + // Initial version is the pairing-payload fallback; rebuild() recomputes it + // via effectiveVersion once the live-detected value is known. + version: sub.payload?.pairing?.version || null, + detected_version: null, + detected_indexer: null, + operator_domain: null, + operator_domain_proof: null, + block_height: null, + indexer_url: null, + checked_at: null, + payload: sub.payload, + signed: sub.signed || null, + }; +} + +// Grace-period retirement for history entries. Deleting history the instant an +// id leaves the node list turned a transient list mistake into permanent data +// loss (the seed-migration deploy wiped every migrated node's history seconds +// after rsync, via the post-deploy rebuild, before the migration could run on +// the box). Instead: an unlisted id is STAMPED `retired` and kept; it is only +// deleted after HISTORY_GRACE_DAYS (default 14); if the id is listed again +// within the window, the stamp is cleared and its history resumes untouched. +// Exported because scripts/update.mjs rewrites the same two files every cycle +// and must apply identical rules. +export function retireUnlisted(nodesMap: HistoryMap, isListed: (id: string) => boolean, + nowIso: string, graceDays: number = Number(process.env.HISTORY_GRACE_DAYS || 14)): boolean { + let touched = false; + const cutoffMs = Date.parse(nowIso) - graceDays * 86400000; + for (const id of Object.keys(nodesMap)) { + const entry = nodesMap[id]; + if (isListed(id)) { + if (entry.retired) { delete entry.retired; touched = true; } + } else if (!entry.retired) { + entry.retired = nowIso; touched = true; + } else if (Date.parse(entry.retired) < cutoffMs) { + delete nodesMap[id]; touched = true; + } + } + return touched; +} + +export async function rebuild(): Promise<{ nodes: number; approved: number; msg: string }> { + const DATA_DIR = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"); + const SERVER_DATA = process.env.SERVER_DATA_DIR || path.join(ROOT, "server", "data"); + const SEED = path.join(DATA_DIR, "seed.json"); + const OUT = path.join(DATA_DIR, "dojos.json"); + const HIST = path.join(DATA_DIR, "history.json"); + const DAILY = path.join(DATA_DIR, "history-daily.json"); + const PENDING_PROBE = path.join(SERVER_DATA, "pending-probe.json"); + + const seed = await readJSON(SEED, { nodes: [] }); + // Optional: identifies each PayNym's non-segwit code variant for display. + const codesDoc = await readJSON(path.join(DATA_DIR, "paynym-codes.json"), { mapping: {} }); + // The operator binding is REQUIRED: an instance must prove who runs it. + // Warn (unmissably) rather than fail, so a malformed signature nags the + // operator without taking the directory down for its visitors. The crypto + // import is lazy so the dependency-free scripts/ chain can still import + // this module on a box where server/node_modules is not installed yet. + try { + const opDoc = await readJSON(path.join(DATA_DIR, "operator.json"), null); + if (!opDoc) { + console.error("[rebuild] REQUIRED: data/operator.json is missing. Sign your onion URL with your wallet and install the binding (the installer does this); see README."); + } else { + try { + const { verifyOperatorDoc } = await import("./crypto.ts"); + const v = verifyOperatorDoc(opDoc); + if (!v.ok) console.error(`[rebuild] REQUIRED: data/operator.json does not verify: ${v.error}`); + } catch { console.error("[rebuild] note: cannot verify operator.json (server dependencies not installed)."); } + } + } catch (e) { console.error(`[rebuild] operator.json check skipped: ${e.message}`); } + + // Anchor-model checks (warnings, never fatal: a fresh instance mid-setup or + // mid-transition should build, just noisily). The seed should hold exactly + // one node -- the instance operator's own, carrying their payment code -- + // and every listed node should carry a BIP47 code; code-less records are + // grandfathered exceptions managed from /admin. + if ((seed.nodes || []).length !== 1) { + console.error(`[rebuild] note: seed carries ${(seed.nodes || []).length} node(s); the anchor model expects exactly one (the instance operator's own node).`); + } else if (!seed.nodes[0].paymentCode) { + console.error(`[rebuild] REFUSING to publish the anchor seed node ${seed.nodes[0].id}: it has no BIP47 payment code.`); + } + // A record with no payment code and no signed pairing block is not published. + // The store refuses to write either, so this only fires for something that + // predates those rules or was edited by hand — and in that case it is + // withheld rather than shown, because a listing nobody can be held to, or + // whose details nobody has attested to, is exactly what this directory must + // not carry. Withheld, not deleted: the record stays for a maintainer to look + // at. The two are reported separately because the remedies differ: a missing + // code cannot be supplied by anyone but the operator, while a missing + // signature usually means asking them to sign what they already gave us. + const allApproved = (await store.listSubmissions()).filter((s) => s.status === "approved"); + const codeless = allApproved.filter((s) => !(s.paymentCodes || []).length); + if (codeless.length) { + console.error(`[rebuild] REFUSING to publish ${codeless.length} listing(s) with no BIP47 payment code: ${codeless.map((s) => s.id).join(", ")}. A listing must carry a payment code; remove it with server/remove-listing.ts, or give it one.`); + } + const unsigned = allApproved.filter((s) => (s.paymentCodes || []).length && !hasSignedBlock(s)); + if (unsigned.length) { + console.error(`[rebuild] REFUSING to publish ${unsigned.length} listing(s) with no signed pairing block: ${unsigned.map((s) => s.id).join(", ")}. Ask the operator to sign their pairing payload and resubmit, or remove the listing with server/remove-listing.ts.`); + } + const approvedSubs = allApproved.filter((s) => (s.paymentCodes || []).length && hasSignedBlock(s)); + const approved = approvedSubs.map((s) => toPublicNode(s, displayPaymentCode(s, codesDoc.mapping))); + const approvedIds = new Set(approved.map((n) => n.id)); + + const byId = new Map(); + // The seed anchor is held to the same rules as any other listing. + const seedNodes = (seed.nodes || []).filter((n) => { + if (!n || !n.paymentCode) { + console.error(`[rebuild] withholding seed node ${n?.id}: no BIP47 payment code.`); + return false; + } + if (!hasSignedBlock(n)) { + console.error(`[rebuild] withholding seed node ${n?.id}: no signed pairing block.`); + return false; + } + return true; + }); + // Seed nodes go through the SAME allowlist as store records. They used to be + // published as they sit in data/seed.json, which meant the public file had two + // producers and only one of them filtered anything. Nothing has ever leaked + // that way, because seed.json is written by the installer and its fields + // happen to be a subset of what toPublicNode emits, but "happens to be a + // subset" is not a property anybody was maintaining: seed.json is + // instance-owned and documented as hand-editable, so a field added there went + // straight to the published file unread. One producer, one allowlist. + // + // The cast is safe because toPublicNode reads only fields a seed node has; + // the owning code is passed as an argument rather than read from the record, + // which is why a seed node's singular paymentCode needs no reshaping. + for (const n of seedNodes) byId.set(n.id, toPublicNode(n as unknown as StoreRecord, n.paymentCode || null)); + for (const n of approved) byId.set(n.id, n); + const nodes = [...byId.values()]; + + // Per-id pairing version, the bootstrap fallback used until a live version is + // detected. The card version is never operator-set (see effectiveVersion). + const pairingById = new Map(); + for (const n of seedNodes) pairingById.set(n.id, n.payload?.pairing?.version || null); + for (const s of approvedSubs) pairingById.set(s.id, s.payload?.pairing?.version || null); + + // Owner payment codes per node, for the verified-domain lookup below. The seed + // anchor carries a single paymentCode; store records carry paymentCodes[]. + const ownerCodesById = new Map(); + for (const n of seedNodes) ownerCodesById.set(n.id, [n.paymentCode]); + for (const sub of approvedSubs) ownerCodesById.set(sub.id, sub.paymentCodes || []); + + // Carry over the live status the updater last wrote, so a rebuild does not + // blank a node for a probe cycle. + const prior = await readJSON(OUT, { nodes: [] }); + const priorById = new Map((prior.nodes || []).map((n) => [n.id, n])); + // Pending-probe results (updater-owned): seed a just-approved node's status + // and height from what was observed while it was pending. + const pending = await readJSON(PENDING_PROBE, { nodes: {} }); + // Verified operator domains: published per node so the card can show the badge + // without another lookup, and used to filter the card-title link. A link that + // is not on the operator's verified domain is withheld rather than deleted, so + // an operator who verifies later gets their link back untouched. + const domainByCode = await store.verifiedDomainMap(); + // The proof is published alongside the badge so a reader can check it with + // their own tools instead of taking our tick on trust: the TXT record proves + // the domain names the payment code, and the signed statement proves the code + // names the domain. Everything here is already public (the payment code is on + // the card, the domain is the claim), so publishing it discloses nothing new. + const claimByCode = new Map(); + for (const c of await store.listDomains()) { + if (c?.verified && c.domain) claimByCode.set(c.paymentCode, { signed: c.signed, verified_at: c.verified_at ?? null }); + } + for (const n of nodes) { + const codes = ownerCodesById.get(n.id) || []; + const code = codes.find((c) => domainByCode.get(c)) || null; + const domain = code ? domainByCode.get(code) || null : null; + n.operator_domain = domain; + const claim = code ? claimByCode.get(code) : null; + n.operator_domain_proof = domain && claim ? { + domain, + paymentCode: code, + txt_name: `_dojobay.${domain}`, + txt_value: `dojobay-domain-v1 pm=${code}`, + signed: claim.signed, + verified_at: claim.verified_at, + } : null; + } + + for (const n of nodes) { + const p = priorById.get(n.id); + const pr = (!p && approvedIds.has(n.id)) ? pending.nodes?.[n.id] : null; + if (p) { + n.status = p.status ?? n.status; + n.checked_at = p.checked_at ?? n.checked_at; + if (p.block_height != null) n.block_height = p.block_height; + } else if (pr) { + n.status = pr.status ?? n.status; + n.checked_at = pr.checked_at ?? n.checked_at; + if (pr.block_height != null) n.block_height = pr.block_height; + } + // Carry the live-detected version (prior snapshot, then a just-approved + // node's pending probe) and fold it into the effective card version. The + // updater writes detected_version each cycle; a rebuild must preserve it, + // exactly as it preserves status and block height. + const detected = (p && p.detected_version) || (pr && pr.detected_version) || null; + n.detected_version = detected; + n.version = effectiveVersion(detected, pairingById.get(n.id)); + // Same treatment for the Electrum endpoint: carry what the updater read and + // publish it as indexer_url, which the card renders (N/A when null). + const detectedIdx = (p && p.detected_indexer) || (pr && pr.detected_indexer) || null; + n.detected_indexer = detectedIdx; + n.indexer_url = effectiveIndexer(detectedIdx); + } + + await writeAtomic(OUT, { + generated_at: new Date().toISOString().replace(/\.\d+Z$/, "Z"), + interval_minutes: 10, + nodes, + }); + + // Reliability history: ensure a bucket per node, seed a newly-approved node's + // history from its pending history, and retire (grace period) unlisted ids. + const hist = await readJSON(HIST, { interval_minutes: 10, window_checks: 144, nodes: {} }); + let touched = false; + for (const n of nodes) { + if (!hist.nodes[n.id]) { + const seedChecks = (approvedIds.has(n.id) && pending.nodes?.[n.id]?.checks) || []; + hist.nodes[n.id] = { checks: seedChecks.slice() }; + touched = true; + } + } + const nowIso = new Date().toISOString(); + touched = retireUnlisted(hist.nodes, (id) => byId.has(id), nowIso) || touched; + if (touched) { (hist as any).generated_at = (hist as any).generated_at || null; await writeAtomic(HIST, hist); } + + // 90-day daily rollup membership. + const dailyDoc = await readJSON(DAILY, { retention_days: 90, nodes: {} }); + let dailyTouched = false; + for (const n of nodes) if (!dailyDoc.nodes[n.id]) { + dailyDoc.nodes[n.id] = { days: (approvedIds.has(n.id) && pending.nodes?.[n.id]?.days) ? pending.nodes[n.id].days.slice() : [] }; + dailyTouched = true; + } + dailyTouched = retireUnlisted(dailyDoc.nodes, (id) => byId.has(id), nowIso) || dailyTouched; + if (dailyTouched) await writeAtomic(DAILY, dailyDoc); + + const msg = `public list rebuilt: ${nodes.length} nodes (${approved.length} approved submissions).`; + return { nodes: nodes.length, approved: approved.length, msg }; +} + +// Run when invoked directly. +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) { + const r = await rebuild(); + console.log(r.msg); +} diff --git a/docker/dojobay/server/check-resources.ts b/docker/dojobay/server/check-resources.ts new file mode 100644 index 00000000..f6c15524 --- /dev/null +++ b/docker/dojobay/server/check-resources.ts @@ -0,0 +1,235 @@ +#!/usr/bin/env node +// ============================================================================= +// The Dojo Bay — resource diagnostic. +// +// READ-ONLY. Measures what this instance actually uses, rather than guessing, +// so an operator can size a VPS from evidence and this project can document a +// requirement it has tested. +// +// What it looks at, and why each matters for THIS workload: +// +// memory the backend is a small long-running Node process; the updater is +// a second one every ten minutes; tor and nginx sit alongside. +// Peak matters more than current, because `npm ci` during a deploy +// and the unzip during a self-update are the two spikes. +// disk node_modules, the published data, and — the one that grows +// without limit — data/backups, a full copy of the code kept by +// every self-update. +// cpu idle almost always, with a burst each probe cycle: one Tor +// circuit per listed node, plus secp256k1 verification. +// strain swap in use, OOM kills and load average are the evidence that a +// box is actually too small, as opposed to merely modest. +// +// NO PATH FROM THE ENVIRONMENT REACHES A SUBPROCESS. WEB_ROOT and +// PUBLIC_DATA_DIR are operator-set, and this file used to hand them to `df` and +// `du`, which CodeQL flagged (js/shell-command-injection-from-environment) and +// which is a real if narrow bug: a value beginning with a hyphen is read by +// those tools as an option, not a path, so `WEB_ROOT=-x` silently measures +// something other than what was asked for. Both are now answered by Node +// itself, statfs() and a walk, which removes the class rather than escaping +// around it. The two subprocesses that remain (systemctl, journalctl) exist +// because nothing in Node can answer what they answer, and both take arguments +// written here. Keep it that way: see sh() below. +// +// Usage, on the box: +// cd /var/www/dojobay/server && node check-resources.ts +// ============================================================================= +import { readFile, stat, readdir, lstat, statfs } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const exec = promisify(execFile); +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const WEB_ROOT = process.env.WEB_ROOT || path.resolve(HERE, ".."); +const PUBLIC_DIR = process.env.PUBLIC_DATA_DIR || path.join(WEB_ROOT, "data"); + +const MB = 1024 * 1024; +const mb = (bytes: number) => { + if (bytes < 1024) return bytes + " B"; + if (bytes < MB) return (bytes / 1024).toFixed(0) + " KB"; + return (bytes / MB).toFixed(bytes < 10 * MB ? 1 : 0) + " MB"; +}; +const gb = (bytes: number) => (bytes / (1024 * MB)).toFixed(1) + " GB"; +const read = async (p: string) => { try { return await readFile(p, "utf8"); } catch { return null; } }; + +// Every call site passes a command and an argument list written in this file, +// never a path, a name or anything else derived from the environment. The one +// exception is UNITS below, which the suite checks directly. A future edit that +// interpolates a variable in here fails the gate rather than shipping. +const sh = async (cmd: string, args: string[]) => { + try { return (await exec(cmd, args)).stdout.trim(); } catch { return null; } +}; + +// The only values this file passes to a subprocess that are not written inline +// at the call site. They are exported so the suite can assert on the array +// itself rather than reading this source and guessing: an assertion about what +// a program does is worth more than one about how it is spelled. +export const UNITS = [ + "dojobay-server.service", + "dojobay-update.service", + "tor.service", + "nginx.service", +]; + +// Replaces `df`. statfs reports the filesystem holding the path, and the +// arithmetic matches what df prints: used counts the blocks the filesystem +// considers occupied, while available excludes the root reserve, so used plus +// available is legitimately less than the total. +export const diskUsage = async (p: string) => { + try { + const fs = await statfs(p); + const block = Number(fs.bsize); + return { + size: Number(fs.blocks) * block, + used: (Number(fs.blocks) - Number(fs.bfree)) * block, + avail: Number(fs.bavail) * block, + }; + } catch { return null; } +}; + +// Replaces `du -sb`: apparent size of a tree, symlinks counted but never +// followed, unreadable entries skipped rather than fatal, and directory inodes +// excluded, which is what `du -sb` does and is why this agrees with it to the +// byte on a real node_modules. Counting the directories instead would add 4 KB +// per directory of filesystem bookkeeping to a figure meant to describe +// content. One difference remains: du counts a hard-linked file once, this +// counts it once per link, which node_modules does not contain and which would +// overstate rather than hide. It also walks in JavaScript, so a populated +// node_modules takes a second or so rather than being instant, which is nothing +// for a diagnostic run by hand a few times a year. +export const dirSize = async (p: string): Promise => { + const root = await lstat(p).catch(() => null); + if (!root) return null; + if (!root.isDirectory()) return root.size; + let total = 0; + const walk = async (dir: string) => { + const entries = await readdir(dir, { withFileTypes: true }).catch(() => null); + if (!entries) return; + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { await walk(full); continue; } + const s = await lstat(full).catch(() => null); + if (s) total += s.size; + } + }; + await walk(p); + return total; +}; + +const report = async () => { + console.log("The Dojo Bay — what this instance actually uses\n"); + + // ---- the machine ---------------------------------------------------------- + const meminfo = (await read("/proc/meminfo")) || ""; + const kb = (key: string) => { + const m = meminfo.match(new RegExp("^" + key + ":\\s+(\\d+) kB", "m")); + return m ? Number(m[1]) * 1024 : null; + }; + const memTotal = kb("MemTotal"), memAvail = kb("MemAvailable"); + const swapTotal = kb("SwapTotal"), swapFree = kb("SwapFree"); + const swapUsed = swapTotal != null && swapFree != null ? swapTotal - swapFree : null; + const cpus = os.cpus(); + + console.log("MACHINE"); + console.log(` cpu ${cpus.length} × ${cpus[0]?.model?.trim() || "unknown"}`); + console.log(` memory ${memTotal ? gb(memTotal) : "?"} total, ${memAvail ? gb(memAvail) : "?"} available`); + console.log(` swap ${swapTotal ? gb(swapTotal) + " total, " + mb(swapUsed || 0) + " in use" : "none configured"}`); + const la = os.loadavg(); + console.log(` load average ${la.map((n) => n.toFixed(2)).join(" ")} (1, 5, 15 min; ${cpus.length} core${cpus.length === 1 ? "" : "s"})`); + + const disk = await diskUsage(WEB_ROOT); + const diskFree = disk ? disk.avail : null; + if (disk) console.log(` disk ${gb(disk.size)} total, ${gb(disk.used)} used, ${gb(disk.avail)} free`); + + // ---- what our services use ------------------------------------------------ + console.log("\nSERVICES (current / peak since boot)"); + let ourPeak = 0; + for (const unit of UNITS) { + const base = `/sys/fs/cgroup/system.slice/${unit}`; + const cur = Number((await read(`${base}/memory.current`)) || 0); + const peak = Number((await read(`${base}/memory.peak`)) || 0); + const active = await sh("systemctl", ["is-active", unit]); + if (!cur && active !== "active") { console.log(` ${unit.padEnd(24)} not running`); continue; } + if (unit.startsWith("dojobay")) ourPeak += peak || cur; + console.log(` ${unit.padEnd(24)} ${cur ? mb(cur) : "—"}${peak ? " / " + mb(peak) : ""}`); + } + + // ---- disk, broken down ---------------------------------------------------- + console.log("\nDISK USED BY THIS INSTALLATION"); + const parts: [string, string][] = [ + ["everything", WEB_ROOT], + [" server/node_modules", path.join(WEB_ROOT, "server", "node_modules")], + [" data (published)", PUBLIC_DIR], + [" data/avatars", path.join(PUBLIC_DIR, "avatars")], + [" data/backups", path.join(PUBLIC_DIR, "backups")], + [" data/updates", path.join(PUBLIC_DIR, "updates")], + ]; + let backupsBytes = 0, backupCount = 0; + for (const [label, p] of parts) { + const bytes = await dirSize(p); + if (bytes == null) { console.log(` ${label.padEnd(24)} —`); continue; } + if (label.includes("backups")) { + backupsBytes = bytes; + try { backupCount = (await readdir(p)).length; } catch { /* none */ } + } + console.log(` ${label.padEnd(24)} ${mb(bytes)}${label.includes("backups") && backupCount ? ` (${backupCount} kept)` : ""}`); + } + + // ---- the workload --------------------------------------------------------- + console.log("\nWORKLOAD"); + let nodeCount = 0, intervalMin = 10; + try { + const dojos = JSON.parse((await read(path.join(PUBLIC_DIR, "dojos.json"))) || "{}"); + nodeCount = (dojos.nodes || []).length; + intervalMin = Number(dojos.interval_minutes) || 10; + } catch { /* not built yet */ } + const concurrency = Number(process.env.CONCURRENCY || 4); + console.log(` listed nodes ${nodeCount}`); + console.log(` probe cycle every ${intervalMin} min, up to ${concurrency} Tor circuits at once`); + for (const f of ["dojos.json", "history.json", "history-daily.json"]) { + const s = await stat(path.join(PUBLIC_DIR, f)).catch(() => null); + if (s) console.log(` ${f.padEnd(22)} ${mb(s.size)}`); + } + + // ---- evidence of strain --------------------------------------------------- + // journalctl does its own matching, so there is no pipeline and no shell: the + // filter is an argument, the output is one line per matching entry, and a + // journalctl that cannot answer leaves this null exactly as an absent one did. + console.log("\nSIGNS OF STRAIN"); + const oom = await sh("journalctl", ["-k", "--no-pager", "--case-sensitive=false", + "--grep=out of memory", "--output=cat"]); + const oomCount = oom ? oom.split("\n").filter((l) => l.trim()).length : 0; + const findings: string[] = []; + if (oomCount > 0) findings.push(`${oomCount} out-of-memory event(s) in the kernel log — the box IS too small`); + if (swapUsed && swapUsed > 64 * MB) findings.push(`${mb(swapUsed)} of swap in use — memory pressure, though not fatal`); + if (memAvail && memTotal && memAvail < memTotal * 0.15) findings.push("under 15% of memory available right now"); + if (la[2] > cpus.length) findings.push(`15-minute load ${la[2].toFixed(2)} exceeds ${cpus.length} core(s)`); + if (diskFree != null && diskFree < 2 * 1024 * MB) findings.push(`only ${gb(diskFree)} of disk free`); + if (backupCount > 3) findings.push(`${backupCount} self-update backups kept (${mb(backupsBytes)}); nothing prunes these`); + if (!findings.length) console.log(" none. Nothing here suggests this machine is short of anything."); + else for (const f of findings) console.log(` · ${f}`); + + // ---- what to tell other operators ----------------------------------------- + console.log("\nWHAT THIS SUGGESTS FOR A MINIMUM SPEC"); + const ourMb = ourPeak / MB; + if (ourPeak > 0) { + console.log(` This instance's own services peaked at about ${mb(ourPeak)}, carrying ${nodeCount} node(s).`); + console.log(" Add tor, nginx and the operating system, and headroom for `npm ci`"); + console.log(" during a deploy, which is the largest transient by some way."); + } else { + console.log(" The services are not running here, so nothing was measured. Run this ON"); + console.log(" the instance, with the backend up, for numbers that mean anything."); + } + console.log(""); + console.log(` Suggested minimum: 1 vCPU, ${ourPeak > 0 && ourMb < 200 ? "1 GB" : "2 GB"} RAM, 20 GB disk, plus swap.`); + console.log(" The work is almost entirely waiting on Tor, so cores buy little; memory"); + console.log(" and a little disk headroom are what matter. Run this again after a"); + console.log(" deploy and after a self-update to catch the peaks rather than the calm."); +}; + +// Run when invoked, importable when tested. The suite exercises dirSize and +// diskUsage directly; printing a report on import would make that impossible. +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) await report(); diff --git a/docker/dojobay/server/check-versions.ts b/docker/dojobay/server/check-versions.ts new file mode 100644 index 00000000..473f4690 --- /dev/null +++ b/docker/dojobay/server/check-versions.ts @@ -0,0 +1,82 @@ +#!/usr/bin/env node +// ============================================================================= +// The Dojo Bay — report the Dojo version of every listing. +// +// READ-ONLY. Nothing is written and no network is touched: it reads what the +// updater has already recorded. +// +// Two versions per node, and the difference matters when choosing a minimum: +// +// detected from the node's own X-Dojo-Version header, read on every probe. +// This is what it is actually running. +// declared the version inside the pairing payload. Frozen when that payload +// was generated and signed, so it can be years out of date while +// the node itself is current. At least one listing here declares +// 1.4.5 for exactly that reason. +// +// A minimum-version rule should therefore judge the DETECTED version. This +// report shows both, so a threshold can be chosen against the real spread. +// +// Usage, on the box: +// cd /var/www/dojobay/server +// node check-versions.ts # against the configured minimum +// node check-versions.ts 1.27.0 # against a threshold you are weighing +// ============================================================================= +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { store } from "./store.ts"; +import { MIN_DOJO_VERSION, judgeVersion, compareVersions } from "./dojo-version.ts"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PUBLIC_DIR = process.env.PUBLIC_DATA_DIR || path.join(HERE, "..", "data"); +const minimum = (process.argv.find((a) => /^\d/.test(a)) || MIN_DOJO_VERSION || "1.27.0").trim(); + +const dojos = await readFile(path.join(PUBLIC_DIR, "dojos.json"), "utf8") + .then((t) => JSON.parse(t)).catch(() => ({ nodes: [] })); +const published = new Map((dojos.nodes || []).map((n: any) => [n.id, n])); +const records = (await store.listSubmissions()) + .filter((r) => r.status === "approved") + .sort((a, b) => (a.network + a.name).localeCompare(b.network + b.name)); + +const rows = records.map((r) => { + const pub: any = published.get(r.id) || {}; + const detected = pub.detected_version || null; + const declared = r.payload?.pairing?.version || null; + const verdict = judgeVersion(detected, declared, minimum); + return { id: r.id, name: r.name || r.id, detected, declared, verdict, status: pub.status || "?" }; +}); + +const pad = (s: string, n: number) => (s || "").padEnd(n); +console.log(`Minimum being applied: ${minimum}\n`); +console.log(pad("RECORD", 30) + pad("DETECTED", 12) + pad("DECLARED", 12) + pad("NODE", 10) + "VERDICT"); +console.log("-".repeat(78)); +for (const r of rows) { + const v = r.verdict.ok ? "ok" : (r.verdict.version ? "BELOW MINIMUM" : "no version reported"); + console.log(pad(r.id, 30) + pad(r.detected || "—", 12) + pad(r.declared || "—", 12) + pad(r.status, 10) + v); +} + +const below = rows.filter((r) => !r.verdict.ok && r.verdict.version); +const unknown = rows.filter((r) => !r.verdict.ok && !r.verdict.version); +const ok = rows.length - below.length - unknown.length; + +console.log(`\n${ok} at or above ${minimum}, ${below.length} below, ${unknown.length} with no version reported.`); +if (below.length) { + console.log("\nBelow the minimum:"); + for (const r of below) console.log(` ${r.id}: ${r.verdict.version} (${r.verdict.source})`); +} +if (unknown.length) { + console.log("\nNo version reported. A node that has never been probed successfully shows nothing here,"); + console.log("so check whether these are down rather than old before reading anything into it:"); + for (const r of unknown) console.log(` ${r.id} (node currently ${r.status})`); +} + +// The spread, which is what a threshold should actually be chosen against. +const seen = rows.map((r) => r.detected).filter(Boolean) as string[]; +if (seen.length) { + const uniq = [...new Set(seen)].sort(compareVersions); + console.log(`\nDetected versions in use: ${uniq.join(", ")}`); + console.log(`Oldest running: ${uniq[0]}. A minimum above that would refuse a node currently listed,`); + console.log("though existing listings are never re-judged — the check applies to new submissions."); +} +process.exit(below.length || unknown.length ? 1 : 0); diff --git a/docker/dojobay/server/crypto.ts b/docker/dojobay/server/crypto.ts new file mode 100644 index 00000000..4968ce94 --- /dev/null +++ b/docker/dojobay/server/crypto.ts @@ -0,0 +1,429 @@ +// Auth47 login and BIP47 signed-payload verification for The Dojo Bay backend. +// Thin wrappers over the audited Samourai libraries; the exact call shapes here +// were verified against the libraries end to end (see selftest.mjs). +import { Auth47Verifier } from "@dojo-tools/auth47"; +import { BIP47Factory } from "@dojo-tools/bip47"; +import { bitcoinMessageFactory } from "@dojo-tools/bitcoinjs-message"; +import * as bip47utils from "@dojo-tools/bip47/utils"; +import ecc from "@bitcoinerlab/secp256k1"; + +/** Outcome of a signature check: either accepted, or refused with a reason an + * operator can act on. */ +export type VerifyResult = + | { ok: true; error?: undefined; address?: string; paymentCode?: string | null } + | { ok: false; error: string; address?: undefined; paymentCode?: undefined }; + +/** The parts of a wallet-exported signed block. */ +export interface ParsedBlock { + /** Everything the signature covers, including the BIP47 tail. */ + message: string; + /** The pairing JSON alone. */ + pairingText: string; + /** The payment code inside the signed text, when present. */ + paymentCode: string | null; + address: string; + signature: string; +} + +const bip47 = BIP47Factory(ecc); +const message = bitcoinMessageFactory(ecc); + +// ---- Auth47 ---------------------------------------------------------------- +// The verifier needs to know its own callback URL. We build it from the site's +// base URL (the .onion origin) at construction time. +export function makeAuth47(baseUrl) { + const callback = new URL("/api/auth47/callback", baseUrl).toString(); + const verifier = new Auth47Verifier(ecc, callback); + + // Full challenge URI shown to the wallet (includes the callback `c`). + function challengeURI(nonce, expires, resource) { + return verifier.generateURI({ nonce, expires, resource }); + } + + // Per the spec, the wallet signs the challenge WITHOUT the callback param. + // Given the full URI we generated, produce the value the proof must contain. + function signedForm(fullUri) { + const u = new URL(fullUri); + u.searchParams.delete("c"); + return decodeURIComponent(u.toString()); + } + + // Two URLs naming the same resource. Compared as parsed URLs rather than as + // strings, so a trailing slash or a difference in host case is not treated as + // a different site, while a different origin or path is. Anything that does + // not parse is not equal to anything. + function sameResource(a: string, b: string): boolean { + try { + const norm = (u: string) => { + const x = new URL(u); + return x.origin.toLowerCase() + x.pathname.replace(/\/+$/, "") + x.search; + }; + return norm(a) === norm(b); + } catch { return false; } + } + + // Verify a posted proof. Returns { ok, paymentCode } or { ok:false, error }. + // + // expectedResource is REQUIRED, and the shape is the point. A signature is + // only ever evidence of what it was made over, so a verifier that takes only + // the thing being verified can answer "is this signed?" but never "is this + // signed FOR ME?". The other three verifiers in this file all take an + // expectation for that reason: verifySignedPayload takes expectedMessage and + // expectedAddress, verifySignedUrlClaim takes expectedUrl, verifyOperatorDoc + // takes expectedOnion. This one did not, and the missing binding was + // invisible rather than a missing argument. + // + // What it prevents: the library checks that the challenge's r parameter is a + // well-formed http(s) URL, but it cannot know which URL is ours. Without this + // comparison an attacker could take a live nonce from this instance, show a + // victim the same challenge with r rewritten to their own site, and relay the + // resulting proof back here. The victim's wallet would display the attacker's + // site, the signature would verify, and a session would be minted here in the + // victim's name. The r parameter exists so a person can see what they are + // signing into, and this check is what makes that display mean anything. + function verify(proof: unknown, { expectedResource }: { expectedResource?: string } = {}): VerifyResult { + // Fail closed rather than throwing: a caller who forgot this is a bug, but + // a 500 from an auth endpoint is a worse way to find out than a refusal + // that names the omission. + if (!expectedResource) { + return { ok: false, error: "internal: no expected resource supplied, refusing to verify an unbound proof" }; + } + const res = verifier.verifyProof(proof); + if (res.result !== "ok") return { ok: false, error: res.error }; + // Read the resource from the challenge the signature actually covers, not + // from anything the caller passed alongside it. + const challenge = (proof as { challenge?: unknown }).challenge; + let resource: string | null = null; + try { resource = new URL(String(challenge)).searchParams.get("r"); } catch { /* unparseable */ } + if (!resource || !sameResource(resource, expectedResource)) { + return { ok: false, error: `proof was signed for a different site (${resource || "no resource"}), not this one` }; + } + // Auth47 defines two proof shapes: a nym proof carrying a payment code, and + // an address proof carrying a plain address. Only the former identifies an + // operator here, and reading .nym off the wrong one would bind a session to + // undefined, so require it explicitly rather than assuming. + const nym = (res.data as { nym?: string }).nym; + if (typeof nym !== "string" || !nym) { + return { ok: false, error: "proof does not carry a payment code (an address proof cannot identify an operator)" }; + } + return { ok: true, paymentCode: nym }; + } + + return { challengeURI, signedForm, verify, callback }; +} + +// ---- payment code -> notification address ---------------------------------- +export function notificationAddress(paymentCode: string, network: string = "bitcoin"): string { + const net = bip47utils.networks[network]; + return bip47.fromBase58(paymentCode, net).getNotificationAddress(); +} + +// The exact text an operator signs to attest to a pairing payload, and the +// exact text every gate checks a signature against. +// +// It lives here because it had grown two copies, in the submission gate and in +// audit-signed.mjs, the second carrying a comment warning that it MUST mirror +// the first. A canonical message that exists twice is a canonical message +// waiting to disagree with itself, and the failure would be quiet in the worst +// direction: signatures accepted at submission and reported as invalid by a +// later audit, or the reverse. The installer needs it too, which would have +// made three. +export function canonicalPairing(payload: { pairing?: unknown; explorer?: unknown } | null | undefined): string { + return JSON.stringify({ pairing: payload?.pairing, explorer: payload?.explorer }); +} + +// Every address a given payment code could legitimately have signed from. +// A PayNym is a MAINNET identity: an operator listing a testnet node still +// signs with their mainnet notification address, because that is the only key +// their wallet holds for that code. Deriving on testnet yields an "m…" address +// that can never match, which silently made every testnet listing unverifiable. +// Both derivations come from the same code, so accepting either is no weaker. +export function notificationAddresses(paymentCode: string): string[] { + const out: string[] = []; + for (const net of ["bitcoin", "testnet"]) { + try { const a = notificationAddress(paymentCode, net); if (!out.includes(a)) out.push(a); } catch { /* skip */ } + } + return out; +} + +// ---- lab-style signed pairing payload verification ------------------------- +// The submitted `signed` blob is a BIP-signed message. We require it to be +// signed by the notification address of the operator's authenticated payment +// code, over the exact pairing JSON they are submitting. This is the same +// verify() the paymentcode.io lab uses. +// +// The signed message format Samourai/Ashigaru export wraps the payload between +// BEGIN/END markers. CRITICAL, verified against a real wallet export: the text +// the wallet signs is EVERYTHING between the markers, i.e. the pairing JSON +// PLUS the trailing "BIP47:" line and payment code (no trailing newline). An +// earlier revision excised the BIP47 tail before verifying, which made every +// genuine wallet signature fail as "invalid signature"; the selftest did not +// catch it because it constructed its own blocks under the same assumption. +// Because the BIP47 line is inside the signed text, the payment code is +// covered by the signature and can itself be verified against the signing +// address (see verifySignedPayload). +// Repair a signed block whose whitespace was mangled in transit. +// +// The signature covers the exact bytes between the markers, and the blank line +// before the "BIP47:" line is part of them. Copying a block through a chat +// window, a web form or a mail client routinely collapses that blank line, at +// which point a perfectly good signature stops verifying and the operator is +// told their signature is invalid, which is both wrong and unhelpful. +// +// This is safe rather than a fudge: a candidate is accepted ONLY if it verifies +// cryptographically against an address the declared payment code derives, so +// nothing is taken on trust. The repaired block is what gets stored, so later +// audits verify too. Returns null when no candidate verifies. +export function repairSignedBlock(text: unknown): { block: string; note: string | null } | null { + const raw = String(text || "").replace(/\r\n/g, "\n"); + const addrM = raw.match(/Address:\s*(\S+)/); + const sigM = raw.match(/\n([A-Za-z0-9+\/=]{80,})\n*-----END BITCOIN SIGNATURE/); + const innerM = raw.match(/SIGNED MESSAGE-----[ \t]*\n([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/); + if (!addrM || !sigM || !innerM) return null; + const address = addrM[1].trim(), signature = sigM[1].trim(); + const inner = innerM[1]; + const codeM = inner.match(/BIP47:\s*(PM8T[1-9A-HJ-NP-Za-km-z]+)/); + const json = inner.replace(/\n*[ \t]*BIP47:[\s\S]*$/, "").replace(/\n+$/, ""); + const code = codeM ? codeM[1] : null; + + const candidates: [string, string][] = [["", inner]]; + if (code) { + candidates.push( + ["a blank line before the BIP47 line was restored", `${json}\n\nBIP47: ${code}`], + ["a blank line before the BIP47 line was restored", `${json}\n\nBIP47:\n${code}`], + ); + } + const accept = code ? notificationAddresses(code) : []; + if (code && !accept.includes(address)) return null; // the code does not own this address + const net = bip47utils.networks.bitcoin; + for (const [note, candidate] of candidates) { + let ok = false; + try { ok = message.verify(candidate, address, signature, net.messagePrefix); } catch { ok = false; } + if (!ok) continue; + const block = `-----BEGIN BITCOIN SIGNED MESSAGE-----\n${candidate}\n` + + `-----BEGIN BITCOIN SIGNATURE-----\nVersion: Bitcoin-qt (1.0)\nAddress: ${address}\n\n${signature}\n` + + `-----END BITCOIN SIGNATURE-----`; + return { block, note: note || null }; + } + return null; +} + +export function parseSignedBlock(text: unknown): ParsedBlock | null { + if (!text || typeof text !== "string") return null; + const t = text.replace(/\r\n/g, "\n"); + const msgM = t.match(/BEGIN BITCOIN SIGNED MESSAGE-----\n([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/); + const addrM = t.match(/Address:\s*(\S+)/); + const sigM = t.match(/\n([A-Za-z0-9+/=]{80,})\n-----END BITCOIN SIGNATURE/); + if (!msgM || !addrM || !sigM) return null; + const message = msgM[1].trim(); // the full signed text + const tail = message.match(/^([\s\S]*?)\n\s*BIP47:\s*\n?(\S+)$/); + return { + message, // what the signature covers + pairingText: tail ? tail[1].trim() : message, // the pairing JSON alone + paymentCode: tail ? tail[2] : null, // code inside the signed text + address: addrM[1].trim(), + signature: sigM[1].trim(), + }; +} + +// Verify a signed pairing block. Checks, in order, with distinct errors: +// 1. the block parses at all; +// 2. the pairing JSON inside it matches the payload being submitted; +// 3. the signature is cryptographically valid over the FULL signed text; +// 4. (signature now known valid) the BIP47 payment code inside the signed +// text is a valid code whose notification address IS the signing address; +// 5. the signing address matches the authenticated payment code's +// notification address (the session binding the API supplies). +// Does the signed pairing text describe the same payload being submitted? +// +// Wallets and admin panels serialise this JSON differently: pretty-printed with +// newlines and indentation, or with the object keys in another order. All of +// those are the SAME payload, and a byte-exact comparison against our own +// re-serialisation rejects them, which is what made genuine, correctly signed +// listings fail the gate. So compare the parsed structures instead: identical +// keys and identical values, order-insensitive, at every level. Anything that +// is not valid JSON, or that differs in any value or key, still fails. +export function sameSignedPayload(signedText: string, expected: string): boolean { + const a = String(signedText).trim(), b = String(expected).trim(); + if (a === b) return true; + let pa, pb; + try { pa = JSON.parse(a); pb = JSON.parse(b); } catch { return false; } + return stableStringify(pa) === stableStringify(pb); +} + +function stableStringify(v: unknown): string { + if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]"; + if (v && typeof v === "object") { + const o = v as Record; + return "{" + Object.keys(o).sort().map((k) => JSON.stringify(k) + ":" + stableStringify(o[k])).join(",") + "}"; + } + return JSON.stringify(v) ?? "null"; +} + +export function verifySignedPayload({ signedText, expectedMessage, expectedAddress, network = "bitcoin" }: { + signedText: string; + expectedMessage?: string | null; + expectedAddress?: string | string[] | null; + network?: string; +}): VerifyResult { + const parsed = parseSignedBlock(signedText); + if (!parsed) return { ok: false, error: "unrecognised signed message format" }; + if (expectedMessage != null && !sameSignedPayload(parsed.pairingText, expectedMessage)) { + return { ok: false, error: "signed message does not match the submitted pairing code" }; + } + const net = bip47utils.networks[network]; + let verified = false; + try { + verified = message.verify(parsed.message, parsed.address, parsed.signature, net.messagePrefix); + } catch (e) { + return { ok: false, error: "signature could not be verified (" + e.message + ")" }; + } + if (!verified) return { ok: false, error: "invalid signature" }; + if (parsed.paymentCode) { + const derived = notificationAddresses(parsed.paymentCode); + if (!derived.length) { + return { ok: false, error: "signature is valid, but the BIP47 line inside the signed message is not a valid payment code" }; + } + if (!derived.includes(parsed.address)) { + return { ok: false, error: "signature is valid, but the signing address is not the notification address of the payment code inside the message" }; + } + } + // expectedAddress may be a single address or every address the authenticated + // code could have signed from (see notificationAddresses). + const accept = expectedAddress == null ? null : (Array.isArray(expectedAddress) ? expectedAddress : [expectedAddress]); + if (accept && !accept.includes(parsed.address)) { + return { ok: false, error: "signed by a different address than the authenticated payment code" }; + } + return { ok: true, address: parsed.address, paymentCode: parsed.paymentCode }; +} + +// ---- operator binding (data/operator.json) ---------------------------------- +// A Dojo Bay instance MUST prove who runs it: operator.json binds the onion +// address to the operator's payment code via a wallet signature over the text +// +// http:/// +// +// BIP47: +// +// (unlike pairing blocks, the BIP47 line here is INSIDE the signed message: +// the operator pastes the whole text into the wallet's Sign tool). Verified at +// install, at bootstrap import before trusting a remote instance's data, and +// on every rebuild. +// ---- signed URL claims ----------------------------------------------------- +// A verified operator domain is proven the same way the instance's own onion is: +// the operator signs the URL, a blank line, then "BIP47: ". Same +// shape, same wallet procedure (PayNym → Sign message), so nothing new to learn +// and no new crypto. This is deliberately a separate field from the pairing +// payload: the pairing block attests to pairing data only, and operators +// stuffing identity material into it is exactly what this feature replaces. +export function claimText(url: string, paymentCode: string): string { + return `${String(url).replace(/\/+$/, "")}/\n\nBIP47: ${paymentCode}`; +} + +// Verify a signed claim over `expectedUrl` by `paymentCode`. Returns +// { ok } or { ok: false, error } with errors an operator can act on. +export function verifySignedUrlClaim({ signed, expectedUrl, paymentCode }: { + signed: string; + expectedUrl: string; + paymentCode: string; +}): VerifyResult { + if (!signed) return { ok: false, error: "no signed block supplied" }; + if (!paymentCode) return { ok: false, error: "no payment code supplied" }; + const t = String(signed).replace(/\r\n/g, "\n"); + const msgM = t.match(/BEGIN BITCOIN SIGNED MESSAGE-----[ \t]*\n?([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/); + const addrM = t.match(/Address:\s*(\S+)/); + const sigM = t.match(/\n([A-Za-z0-9+\/=]{80,})\n*-----END BITCOIN SIGNATURE/); + if (!msgM || !addrM || !sigM) { + const missing = [ + !msgM && "the BEGIN BITCOIN SIGNED MESSAGE section", + !addrM && "the Address: line", + !sigM && "the signature line before END BITCOIN SIGNATURE", + ].filter(Boolean).join(", "); + return { ok: false, error: `not a recognisable signed block (missing ${missing}) — the paste may have been truncated` }; + } + const signedMessage = msgM[1].replace(/\n+$/, ""); + const norm = (u) => String(u || "").trim().replace(/\/+$/, "").toLowerCase(); + const firstLine = signedMessage.split("\n")[0].trim(); + if (norm(firstLine) !== norm(expectedUrl)) { + return { ok: false, error: `the signed message starts with ${firstLine || "(nothing)"}, but this claim is for ${expectedUrl}` }; + } + const bipM = signedMessage.match(/BIP47:\s*(PM8T[1-9A-HJ-NP-Za-km-z]+)/); + if (!bipM) return { ok: false, error: "the signed message has no BIP47: line" }; + if (bipM[1] !== paymentCode) { + return { ok: false, error: "the BIP47 line inside the signed message is a different payment code from the one you are signed in with" }; + } + const accept = notificationAddresses(paymentCode); + if (!accept.includes(addrM[1].trim())) { + return { ok: false, error: `signed by ${addrM[1].trim()}, but your payment code's notification address is ${accept[0]} — sign under PayNym → Sign message, which uses your PayNym's notification address` }; + } + const net = bip47utils.networks.bitcoin; + try { + if (!message.verify(signedMessage, addrM[1].trim(), sigM[1].trim(), net.messagePrefix)) { + return { ok: false, error: "invalid signature" }; + } + } catch (e) { + return { ok: false, error: "signature could not be verified (" + e.message + ")" }; + } + return { ok: true, address: addrM[1].trim() }; +} + +export function verifyOperatorDoc(doc: any, { expectedOnion }: { expectedOnion?: string } = {}): VerifyResult { + if (!doc || typeof doc !== "object") return { ok: false, error: "operator.json missing or unreadable" }; + if (!doc.paymentCode) return { ok: false, error: "operator.json has no paymentCode" }; + if (!doc.verifySigned) return { ok: false, error: "operator.json has no verifySigned block" }; + const t = String(doc.verifySigned).replace(/\r\n/g, "\n"); + // The newline after the BEGIN marker is optional: some terminals swallow it + // when a block is pasted. It is not part of the signed text either way, so + // tolerating it recovers the correct message rather than changing it. + const msgM = t.match(/BEGIN BITCOIN SIGNED MESSAGE-----[ \t]*\n?([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/); + const addrM = t.match(/Address:\s*(\S+)/); + const sigM = t.match(/\n([A-Za-z0-9+\/=]{80,})\n*-----END BITCOIN SIGNATURE/); + if (!msgM || !addrM || !sigM) { + // Name what is missing: a truncated or line-dropped paste is by far the + // most common cause, and "not recognisable" alone sends people hunting + // for a problem with their wallet instead of re-pasting. + const missing = [ + !msgM && "the BEGIN BITCOIN SIGNED MESSAGE section", + !addrM && "the Address: line", + !sigM && "the signature line before END BITCOIN SIGNATURE", + ].filter(Boolean).join(", "); + return { ok: false, error: `verifySigned is not a recognisable signed block (missing ${missing}) — the paste may have been truncated; paste the whole block again` }; + } + const signedMessage = msgM[1].replace(/\n+$/, ""); + const norm = (u) => String(u || "").trim().replace(/\/+$/, ""); + const firstLine = signedMessage.split("\n")[0].trim(); + if (norm(firstLine) !== norm(doc.onion)) return { ok: false, error: "signed message does not match the declared onion" }; + if (expectedOnion && norm(doc.onion) !== norm(expectedOnion)) { + return { ok: false, error: "declared onion does not match the address this document was fetched from" }; + } + const bipM = signedMessage.match(/BIP47:\s*(PM8T[1-9A-HJ-NP-Za-km-z]+)/); + if (!bipM || bipM[1] !== doc.paymentCode) { + return { ok: false, error: "the BIP47 line inside the signed message does not match the declared payment code" }; + } + // Accept either derivation of the notification address. + // + // A PayNym is a mainnet identity, but a wallet running on testnet derives the + // notification address for THAT network, so the same payment code signs from + // a different address depending on which mode the operator's wallet is in. + // Insisting on the mainnet form refused perfectly good bindings from anyone + // running a testnet wallet — the same defect fixed for listing signatures, + // which this path missed. + const accept = notificationAddresses(doc.paymentCode); + const signer = addrM[1].trim(); + if (!accept.includes(signer)) { + // Naming the addresses matters: the usual cause is signing from a different + // account than the payment code entered, and the operator can only spot + // that if they can see which address their wallet actually used. + const expected = accept.length > 1 + ? `${accept[0]} on mainnet, or ${accept[1]} from a testnet wallet` + : accept[0] || "(the code could not be decoded)"; + return { ok: false, error: `signed by ${signer}, but the payment code's notification address is ${expected} — sign under PayNym → Sign message, which uses your PayNym's notification address` }; + } + const net = bip47utils.networks.bitcoin; // the message prefix is the same on both + try { + if (!message.verify(signedMessage, signer, sigM[1].trim(), net.messagePrefix)) { + return { ok: false, error: "invalid signature" }; + } + } catch (e) { return { ok: false, error: "signature could not be verified (" + e.message + ")" }; } + return { ok: true, address: signer }; +} diff --git a/docker/dojobay/server/diagnose-signed.mjs b/docker/dojobay/server/diagnose-signed.mjs new file mode 100644 index 00000000..1cf6dbd9 --- /dev/null +++ b/docker/dojobay/server/diagnose-signed.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +// ============================================================================= +// The Dojo Bay — diagnose signed-block mismatches. +// +// READ-ONLY. For every record whose stored block does not pass the gate, this +// answers the question the audit cannot: is the SIGNATURE bad, or is the stored +// payload merely a different representation of the same signed text? +// +// For each record it reports, in order: +// 1. INTERNAL VALIDITY - does the signature verify over the block's own text, +// and does the BIP47 code inside that text derive the signing address? +// If yes, the block is a genuine wallet export and nothing is forged. +// 2. MESSAGE MATCH - does the pairing JSON inside the block equal +// canonicalPairing(stored payload) byte for byte? If not, it shows the +// first differing offset with a window either side, and whether the two +// are the same DATA in a different serialisation (key order, spacing) or +// genuinely different values. +// +// Run on the box: cd /var/www/dojobay/server && node diagnose-signed.mjs +// Add --all to include records that already pass. +// ============================================================================= +import { store } from "./store.ts"; +// canonicalPairing is imported, never reimplemented: a diagnostic that computes +// the canonical message its own way can only ever report on itself. +// server/selftest.mjs enforces the single definition. +import { parseSignedBlock, verifySignedPayload, notificationAddresses, canonicalPairing } from "./crypto.ts"; +import { bitcoinMessageFactory } from "@dojo-tools/bitcoinjs-message"; +import * as bip47utils from "@dojo-tools/bip47/utils"; +import ecc from "@bitcoinerlab/secp256k1"; + +const message = bitcoinMessageFactory(ecc); +const ALL = process.argv.includes("--all"); +const netOf = (rec) => (rec.network === "testnet" ? "testnet" : "bitcoin"); + +// Same data, different serialisation? Compare parsed structures, not strings. +const deepEq = (a, b) => { + try { return JSON.stringify(sortDeep(a)) === JSON.stringify(sortDeep(b)); } catch { return false; } +}; +const sortDeep = (v) => { + if (Array.isArray(v)) return v.map(sortDeep); + if (v && typeof v === "object") { + return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortDeep(v[k])])); + } + return v; +}; + +function firstDiff(a, b) { + const n = Math.min(a.length, b.length); + for (let i = 0; i < n; i++) if (a[i] !== b[i]) return i; + return a.length === b.length ? -1 : n; +} +const window_ = (s, i) => JSON.stringify(s.slice(Math.max(0, i - 30), i + 30)); + +const recs = (await store.listSubmissions()) + .sort((a, b) => (a.network + a.name).localeCompare(b.network + b.name)); + +let genuine = 0, forged = 0, drifted = 0, reorder = 0; + +for (const rec of recs) { + if (!rec.signed) continue; + const net = netOf(rec); + const codes = Array.isArray(rec.paymentCodes) ? rec.paymentCodes : []; + const canon = canonicalPairing(rec.payload); + const passes = codes.some((c) => { + try { return verifySignedPayload({ signedText: rec.signed, expectedMessage: canon, expectedAddress: notificationAddresses(c), network: net }).ok; } + catch { return false; } + }); + if (passes && !ALL) continue; + + const label = rec.name && `${rec.network}-${rec.name}` !== rec.id ? `${rec.id} ("${rec.name}")` : rec.id; + console.log(`\n=== ${label} (${rec.status})${passes ? " [currently passes]" : ""}`); + const p = parseSignedBlock(rec.signed); + if (!p) { console.log(" block does not parse at all"); continue; } + + // 1. internal validity + let sigOk = false; + try { sigOk = message.verify(p.message, p.address, p.signature, bip47utils.networks[net].messagePrefix); } catch (e) { console.log(" verify threw:", e.message); } + // A PayNym signs from its mainnet notification address whatever network the + // node is on, so both derivations are legitimate. + let derived = null; + try { derived = p.paymentCode ? notificationAddresses(p.paymentCode) : null; } catch { derived = null; } + const bound = Array.isArray(derived) && derived.includes(p.address); + const derivedTxt = Array.isArray(derived) ? derived.join(" / ") : "(undecodable)"; + console.log(` signature over the block's own text : ${sigOk ? "VALID" : "INVALID"}`); + console.log(` signing address : ${p.address}`); + console.log(` BIP47 code inside the signed text : ${p.paymentCode ? p.paymentCode.slice(0, 12) + "…" : "(none)"} -> ${p.paymentCode ? derivedTxt : "n/a"} ${p.paymentCode ? (bound ? "(binds)" : "(DOES NOT BIND)") : ""}`); + console.log(` record's payment code(s) : ${codes.map((c) => c.slice(0, 12) + "…").join(", ") || "(none)"}`); + if (sigOk && bound) genuine++; else forged++; + + // 2. message match + if (p.pairingText === canon) { console.log(" pairing text matches the stored payload exactly"); continue; } + const i = firstDiff(p.pairingText, canon); + let signedObj, storedObj; + try { signedObj = JSON.parse(p.pairingText); } catch {} + try { storedObj = JSON.parse(canon); } catch {} + const same = signedObj && storedObj && deepEq(signedObj, storedObj); + if (same) reorder++; else drifted++; + console.log(` pairing text DIFFERS from the stored payload`); + console.log(` same data, different serialisation? ${same ? "YES - key order/spacing only" : "NO - the values themselves differ"}`); + console.log(` lengths: signed ${p.pairingText.length}, stored ${canon.length}; first difference at offset ${i}`); + console.log(` signed: ${window_(p.pairingText, i)}`); + console.log(` stored: ${window_(canon, i)}`); + if (!same && signedObj && storedObj) { + const keys = new Set([...Object.keys(signedObj), ...Object.keys(storedObj)]); + for (const k of keys) { + if (JSON.stringify(sortDeep(signedObj[k])) !== JSON.stringify(sortDeep(storedObj[k]))) { + console.log(` top-level key "${k}" differs:`); + console.log(` signed: ${JSON.stringify(signedObj[k])}`); + console.log(` stored: ${JSON.stringify(storedObj[k])}`); + } + } + } +} + +console.log(`\nSummary of blocks examined: ${genuine} genuine (valid signature, code binds), ${forged} not genuine.`); +console.log(`Mismatches: ${reorder} serialisation-only, ${drifted} with genuinely different values.`); +console.log(genuine && !forged + ? "\nEvery block examined is a real wallet export; the failures are a stored-payload representation problem, not a trust problem." + : ""); diff --git a/docker/dojobay/server/dns.ts b/docker/dojobay/server/dns.ts new file mode 100644 index 00000000..f9891327 --- /dev/null +++ b/docker/dojobay/server/dns.ts @@ -0,0 +1,182 @@ +// ============================================================================= +// TXT record lookups over Tor, for verified operator domains. +// +// A Tor-only instance has no ordinary path to a TXT record: Tor's SOCKS +// interface resolves names but cannot fetch arbitrary record types. So we ask +// public DNS-over-HTTPS resolvers, tunnelling the HTTPS through the same SOCKS +// proxy the probes use. +// +// Two deliberate choices, because a resolver's answer decides whether a +// verified badge appears and a lying resolver could mint one: +// +// 1. Several independent resolvers are queried and a fixed number must agree +// before a domain is treated as verified (DOH_AGREEMENT, default 2). +// 2. "Could not reach enough resolvers" is reported as INCONCLUSIVE, never as +// a failure, so a Tor hiccup cannot strip a badge from an honest operator. +// +// The HTTPS-over-Tor fetch here duplicates a little of updates.mjs on purpose: +// that module is on the self-update path, which has never been exercised on real +// hardware, and refactoring it to share code is not a risk worth taking for a +// feature that only reads DNS. +// ============================================================================= +import tls from "node:tls"; +import { socks5Connect } from "../scripts/update.mjs"; +import type { ProbeCfg } from "../types.js"; + +/** Transport settings a lookup needs; the caller may supply a subset. */ +type LookupCfg = Partial & { + /** + * An extra certificate authority to trust for this lookup, and nothing else. + * + * Exists so the self-test can run the whole path — SOCKS, TLS, HTTP, DoH JSON + * — against a mock resolver holding a self-signed certificate, WITHOUT + * reaching for NODE_TLS_REJECT_UNAUTHORIZED, which switches validation off + * for the entire process and every other connection made while it is set. + * Certificate validation stays on here; the test simply supplies the anchor + * that makes its own certificate valid. + */ + tlsCa?: string | Buffer | Array; +}; + +interface ResolverAnswer { host: string; records: string[] } +export interface TxtLookup { + records: string[]; + answered: number; + byResolver: ResolverAnswer[]; + errors: string[]; +} +export interface TxtAgreement { + ok: boolean; + /** True when too few resolvers replied to draw any conclusion. */ + inconclusive: boolean; + /** Absent when the lookup threw before any resolver could be counted. */ + answered?: number; + agreed?: number; + error?: string; +} + +// Resolvers use different JSON paths but the same response shape. +const RESOLVERS: { host: string; path: string }[] = [ + { host: "cloudflare-dns.com", path: "/dns-query" }, + { host: "dns.quad9.net", path: "/dns-query" }, + { host: "dns.google", path: "/resolve" }, +]; + +export const DOH_AGREEMENT = Math.max(1, +(process.env.DOH_AGREEMENT || 2)); +const MAX_BODY = 64 * 1024; // a TXT answer is tiny; cap the read + +function resolverList(): { host: string; path: string }[] { + const only = (process.env.DOH_RESOLVERS || "").trim(); + if (!only) return RESOLVERS; + const wanted = only.split(",").map((s) => s.trim()).filter(Boolean); + return RESOLVERS.filter((r) => wanted.includes(r.host)); +} + +// One HTTPS GET through the Tor SOCKS proxy, returning the response body as +// text. Deliberately minimal: no redirects (a resolver that redirects is not +// one we want), and a hard body cap. +async function httpsGetOverTor(host: string, path: string, + { proxyHost, proxyPort, timeoutMs = 20000, tlsCa }: LookupCfg): Promise { + const raw = await socks5Connect(proxyHost, proxyPort, host, 443, timeoutMs); + return new Promise((resolve, reject) => { + let done = false; + const finish = (fn: (a?: any) => void, arg?: any) => { if (!done) { done = true; clearTimeout(timer); try { socket.destroy(); } catch {} fn(arg); } }; + const timer = setTimeout(() => finish(reject, new Error("timeout")), timeoutMs); + const socket = tls.connect({ socket: raw, servername: host, ...(tlsCa ? { ca: tlsCa } : {}) }, () => { + socket.write( + `GET ${path} HTTP/1.1\r\nHost: ${host}\r\nUser-Agent: dojobay-domain-check\r\n` + + `Accept: application/dns-json\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n`); + }); + const chunks: Buffer[] = []; + let size = 0; + socket.on("data", (d: Buffer) => { + size += d.length; + if (size > MAX_BODY) return finish(reject, new Error("response too large")); + chunks.push(d); + }); + socket.on("error", (e: Error) => finish(reject, e)); + socket.on("close", () => { + if (done) return; + try { + const all = Buffer.concat(chunks); + const headEnd = all.indexOf("\r\n\r\n"); + if (headEnd < 0) return finish(reject, new Error("malformed reply")); + const headText = all.subarray(0, headEnd).toString("latin1"); + const m = headText.match(/^HTTP\/1\.[01] (\d{3})/); + if (!m) return finish(reject, new Error("malformed reply")); + if (+m[1] !== 200) return finish(reject, new Error("HTTP " + m[1])); + let body = all.subarray(headEnd + 4); + if (/transfer-encoding:\s*chunked/i.test(headText)) { + const parts: Buffer[] = []; let p = 0; + for (;;) { + const nl = body.indexOf("\r\n", p); + if (nl < 0) break; + const n = parseInt(body.subarray(p, nl).toString("latin1"), 16); + if (!n) break; + parts.push(body.subarray(nl + 2, nl + 2 + n)); + p = nl + 2 + n + 2; + } + body = Buffer.concat(parts); + } + finish(resolve, body.toString("utf8")); + } catch (e) { finish(reject, e); } + }); + }); +} + +// A DoH JSON answer gives TXT data as a quoted string, and a long record as +// several quoted strings that must be concatenated. Normalise both to one line. +export function parseTxtAnswer(json: string): string[] | null { + let doc: any; + try { doc = JSON.parse(json); } catch { return null; } + if (typeof doc !== "object" || doc === null) return null; + if (doc.Status === 3) return []; // NXDOMAIN: no records + if (doc.Status !== 0) return null; // SERVFAIL etc: no answer + const answers = Array.isArray(doc.Answer) ? doc.Answer : []; + return answers + .filter((a: any) => a && (a.type === 16 || a.type === undefined)) + .map((a: any) => String(a.data || "")) + .map((d: string) => (d.includes('"') ? (d.match(/"([^"]*)"/g) || []).map((s) => s.slice(1, -1)).join("") : d)) + .map((d: string) => d.trim()) + .filter(Boolean); +} + +// Look up TXT records for `name` across the resolvers. Returns +// { records, answered, byResolver, errors } where `records` is the set of +// records seen and `answered` counts resolvers that gave a usable answer. +export async function lookupTxt(name: string, cfg: LookupCfg = {}): Promise { + const resolvers = resolverList(); + const q = `?name=${encodeURIComponent(name)}&type=TXT`; + const results = await Promise.allSettled(resolvers.map(async (r) => { + const body = await httpsGetOverTor(r.host, r.path + q, cfg); + const recs = parseTxtAnswer(body); + if (recs === null) throw new Error("resolver returned no usable answer"); + return { host: r.host, records: recs }; + })); + const byResolver: ResolverAnswer[] = []; + const errors: string[] = []; + for (let i = 0; i < results.length; i++) { + const r = results[i]; // a local, so the union narrows + if (r.status === "fulfilled") byResolver.push(r.value); + else errors.push(`${resolvers[i].host}: ${r.reason?.message || "failed"}`); + } + const records = [...new Set(byResolver.flatMap((r) => r.records))]; + return { records, answered: byResolver.length, byResolver, errors }; +} + +// Do at least DOH_AGREEMENT resolvers see a record satisfying `predicate`? +// Distinguishes "not there" from "we could not tell". +export async function txtRecordAgreed(name: string, predicate: (r: string) => boolean, + cfg: LookupCfg = {}): Promise { + const { answered, byResolver, errors, records } = await lookupTxt(name, cfg); + if (answered < DOH_AGREEMENT) { + return { ok: false, inconclusive: true, answered, agreed: 0, + error: `only ${answered} of ${DOH_AGREEMENT} required resolvers answered (${errors.join("; ") || "no detail"})` }; + } + const agreed = byResolver.filter((r) => r.records.some(predicate)).length; + if (agreed >= DOH_AGREEMENT) return { ok: true, inconclusive: false, answered, agreed }; + return { ok: false, inconclusive: false, answered, agreed, + error: records.length + ? `${agreed} of ${DOH_AGREEMENT} required resolvers saw a matching record; ${records.length} TXT record(s) present but not matching` + : `no TXT record found at ${name}` }; +} diff --git a/docker/dojobay/server/dojo-version.ts b/docker/dojobay/server/dojo-version.ts new file mode 100644 index 00000000..397d7846 --- /dev/null +++ b/docker/dojobay/server/dojo-version.ts @@ -0,0 +1,162 @@ +// ============================================================================= +// Dojo version comparison, and the minimum this directory will accept. +// +// A version reaches us two ways, and they are not equally trustworthy: +// +// detected read from the node's own X-Dojo-Version response header during a +// probe. This is what the node is actually running. +// declared the `version` inside the pairing payload. Informational, frozen +// when the payload was generated, and often stale — one listing +// here declares 1.4.5 while running something far newer, because +// the payload was restored to match the signature that covers it. +// +// So anything deciding on a version prefers the detected one, and falls back to +// the declared one only when the node did not report a header at all. +// ============================================================================= + +// A country code inferred from whatever an operator wrote about where they are, +// or nothing at all. +// +// The point is flags where we can manage them and no obligation anywhere else. +// An operator is asked one free-text question and may answer "Finland", "FI", +// "Central America", "Europe" or "Ancapistan"; the first two get a flag and the +// rest do not, and none of them is an error. Nothing is enforced and nothing is +// refused, because a directory of onion services has no business insisting that +// somebody name a state. +// +// The names come from the runtime rather than a table in this repository. +// Intl.DisplayNames knows 280 region codes and their English names, so the +// lookup is current with the platform's ICU data instead of decaying in a file +// nobody revisits. That also means an unassigned pair like XX yields nothing: +// the runtime does not recognise it, so it cannot be a flag, and letterboxes on +// a card read as a broken listing rather than a missing flag. +const REGION_NAMES = new Intl.DisplayNames(["en"], { type: "region" }); + +/** lowercased name -> code, built once from whatever the runtime knows. */ +const NAME_TO_CODE: Map = (() => { + const m = new Map(); + for (let a = 65; a < 91; a++) { + for (let b = 65; b < 91; b++) { + const cc = String.fromCharCode(a, b); + let name: string | undefined; + try { name = REGION_NAMES.of(cc); } catch { continue; } + if (name && name !== cc) m.set(name.toLowerCase(), cc); + } + } + // The handful the runtime will not answer to, because people do not write + // country names the way the standard does. UK is the one that matters: it is + // not a code, and typed as one it renders as two letterboxes. + for (const [alias, cc] of [ + ["uk", "GB"], ["united kingdom", "GB"], ["great britain", "GB"], ["britain", "GB"], + ["england", "GB"], ["scotland", "GB"], ["wales", "GB"], ["northern ireland", "GB"], + ["usa", "US"], ["u.s.a.", "US"], ["u.s.", "US"], ["america", "US"], + ["holland", "NL"], ["czech republic", "CZ"], ["south korea", "KR"], ["north korea", "KP"], + ["russia", "RU"], ["uae", "AE"], ["eu", "EU"], ["european union", "EU"], + ]) m.set(alias, cc); + return m; +})(); + +export function countryFor(text: unknown): string | null { + const raw = String(text ?? "").trim(); + if (!raw) return null; + // Segments, so "Helsinki, Finland" and "Europe (Finland)" both find something. + // Longest first: "United States" should win over a stray "US" elsewhere in + // the same answer. + const parts = raw.split(/[,;/()\u2013\u2014|]+/).map((x) => x.trim()).filter(Boolean); + for (const part of [raw, ...parts].sort((a, b) => b.length - a.length)) { + const key = part.toLowerCase().replace(/\.$/, ""); + const named = NAME_TO_CODE.get(key); + if (named) return named; + if (/^[a-z]{2}$/i.test(part)) { + const cc = part.toUpperCase(); + // Only if the runtime recognises it: an unassigned pair has no flag, and + // two letterboxes look like a fault rather than an absence. + try { if (REGION_NAMES.of(cc) !== cc) return cc; } catch { /* not a region */ } + } + } + return null; +} + +// Which network a pairing URL is for, read from the URL itself. +// +// A Dojo serves its testnet API under a `test` path segment and its mainnet API +// without one: http:///test/v2 against http:///v2. That makes the +// operator's declared network checkable against the endpoint they gave, and it +// is worth checking, because a crossed pair is wrong in a way nothing +// downstream catches. A testnet node listed as mainnet answers, reports a +// height and probes green indefinitely; the only symptom is a block height a +// few hundred thousand adrift, which reads as nothing at all, and anyone +// pairing with it is sent to a chain they did not ask for. +// +// A whole path SEGMENT, never a substring: an onion address is base32 and can +// carry those four letters in a row by chance, and /v2/testing is not a testnet +// endpoint either. +// +// It lives here rather than in the installer because the same judgement belongs +// at the submission gate, and this module is already where a node's declared +// properties are judged against what it actually is. +export function pairingNetwork(url: string): "mainnet" | "testnet" | null { + try { + return new URL(url).pathname.split("/").some((seg) => seg.toLowerCase() === "test") + ? "testnet" : "mainnet"; + } catch { return null; } +} + +/** The lowest Dojo this directory will accept for a NEW listing. Set to "" or + * "0" to disable the check entirely. Existing listings are never re-judged. */ +export const MIN_DOJO_VERSION = (process.env.MIN_DOJO_VERSION ?? "1.27.0").trim(); + +/** "v1.27.0-rc1" -> [1, 27, 0]. Null when there is no version in there at all. */ +export function parseVersion(v: unknown): number[] | null { + if (typeof v !== "string") return null; + const m = v.trim().replace(/^v/i, "").match(/^(\d+(?:\.\d+)*)/); + if (!m) return null; + const parts = m[1].split(".").map((n) => Number(n)); + return parts.every((n) => Number.isFinite(n)) ? parts : null; +} + +/** -1, 0 or 1. Missing components count as zero, so 1.27 equals 1.27.0. */ +export function compareVersions(a: unknown, b: unknown): number { + const x = parseVersion(a) || [], y = parseVersion(b) || []; + for (let i = 0; i < Math.max(x.length, y.length); i++) { + const d = (x[i] || 0) - (y[i] || 0); + if (d) return d > 0 ? 1 : -1; + } + return 0; +} + +export function meetsMinimum(version: unknown, minimum: string = MIN_DOJO_VERSION): boolean { + if (!minimum || compareVersions(minimum, "0") === 0) return true; // check disabled + return compareVersions(version, minimum) >= 0; +} + +/** + * Judge a node's version for the submission gates. + * + * `unknown` is deliberately its own outcome rather than a silent pass or a + * silent refusal: a node that reports no version at all is almost certainly too + * old to carry the endpoints this directory reads, but saying so plainly is + * more useful to an operator than either guessing. + */ +export function judgeVersion( + detected: unknown, declared: unknown, minimum: string = MIN_DOJO_VERSION, +): { ok: boolean; version: string | null; source: "detected" | "declared" | null; reason?: string } { + if (!minimum || compareVersions(minimum, "0") === 0) { + return { ok: true, version: (detected as string) || (declared as string) || null, + source: detected ? "detected" : declared ? "declared" : null }; + } + const version = (parseVersion(detected) ? detected : parseVersion(declared) ? declared : null) as string | null; + const source = parseVersion(detected) ? "detected" as const : parseVersion(declared) ? "declared" as const : null; + if (!version) { + return { ok: false, version: null, source: null, + reason: `this Dojo did not report a version, so it cannot be checked against the minimum of ${minimum}. ` + + "Dojo has sent an X-Dojo-Version header on every response since well before that, so a node that " + + "sends none is almost certainly older. Upgrade, then submit again." }; + } + if (!meetsMinimum(version, minimum)) { + return { ok: false, version, source, + reason: `this Dojo reports version ${version}, and this directory requires ${minimum} or newer. ` + + "Earlier versions do not serve the endpoints listings are checked against. Upgrade, then submit again." }; + } + return { ok: true, version, source }; +} diff --git a/docker/dojobay/server/domains.ts b/docker/dojobay/server/domains.ts new file mode 100644 index 00000000..d97f2c7c --- /dev/null +++ b/docker/dojobay/server/domains.ts @@ -0,0 +1,160 @@ +// ============================================================================= +// Verified operator domains. +// +// One domain per operator, bound to their BIP47 payment code and proven in both +// directions, so neither half alone is enough: +// +// the domain asserts the code a TXT record at _dojobay. naming the +// payment code; publishing it needs control of +// the domain +// the code asserts the domain a wallet-signed statement naming the domain; +// producing it needs the PayNym's notification +// key +// +// The signature is permanent and the TXT record is the revocable half. Remove +// the record and the next sweep fails; after a grace period the badge drops, +// while the claim is kept so restoring the record restores the badge without +// re-signing. A domain that changes hands therefore stops being claimable by +// its old owner without anyone having to notice. +// +// The signed statement deliberately omits this instance's onion, so a proof is +// portable: a bootstrap import or peer sync carries it intact. +// +// A verified badge attests to CONTROL of a domain, not to trustworthiness: a +// lookalike domain verifies exactly as easily as a real one. Hence admin +// revocation, and punycode display for anything non-ASCII. +// ============================================================================= +import { claimText, verifySignedUrlClaim } from "./crypto.ts"; +import { txtRecordAgreed } from "./dns.ts"; +import type { TxtAgreement } from "./dns.ts"; +import type { DomainClaim, ProbeCfg } from "../types.js"; + +type LookupCfg = Partial; + +/** A normalised domain, or the reason the input could not be one. */ +export type NormalisedDomain = + | { ok: true; domain: string; punycode: boolean; error?: undefined } + | { ok: false; error: string; domain?: undefined; punycode?: undefined }; + +export interface ClaimVerification { + ok: boolean; + stage?: "signature" | "dns"; + error?: string; + hint?: string | null; + inconclusive?: boolean; + agreed?: number; + answered?: number; +} + +export const TXT_PREFIX = "_dojobay"; +export const CLAIM_VERSION = "dojobay-domain-v1"; +export const RECHECK_MS = +(process.env.DOMAIN_RECHECK_HOURS || 24) * 3600 * 1000; +/** A claim awaiting its first successful lookup is retried far more often: the + * operator has just published a TXT record and is waiting for propagation. */ +export const PENDING_RECHECK_MS = +(process.env.DOMAIN_PENDING_RECHECK_MINUTES || 5) * 60 * 1000; +export const GRACE_DAYS = +(process.env.DOMAIN_GRACE_DAYS || 7); + +// Accept "example.com", "example.com/", "https://example.com" or a full URL and +// reduce it to the bare ASCII host. Rejects anything that cannot be a public +// domain an operator could publish a TXT record on. +export function normaliseDomain(input: unknown): NormalisedDomain { + let raw = String(input || "").trim().toLowerCase(); + if (!raw) return { ok: false, error: "enter a domain" }; + if (raw.includes(" ")) return { ok: false, error: "a domain cannot contain spaces" }; + if (!/^[a-z][a-z0-9+.-]*:\/\//.test(raw)) raw = "https://" + raw; + let u: URL; + try { u = new URL(raw); } catch { return { ok: false, error: "that is not a valid domain" }; } + if (u.protocol !== "https:" && u.protocol !== "http:") return { ok: false, error: "use a plain domain, not a " + u.protocol.replace(":", "") + " URL" }; + if (u.username || u.password) return { ok: false, error: "a domain cannot contain a username or password" }; + if (u.port) return { ok: false, error: "leave the port off: verification uses DNS, not a web server" }; + const host = u.hostname; // WHATWG URL gives punycode for IDN + if (host.endsWith(".onion")) return { ok: false, error: "an onion address cannot be verified by DNS; this field is for a clearnet domain you own" }; + if (host === "localhost" || /^\d+\.\d+\.\d+\.\d+$/.test(host) || host.startsWith("[")) { + return { ok: false, error: "use a domain name, not an IP address" }; + } + if (!/^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/.test(host)) { + return { ok: false, error: "that is not a valid domain name" }; + } + if (host.split(".").length < 2) return { ok: false, error: "include the full domain, for example example.com" }; + return { ok: true, domain: host, punycode: /[^\x00-\x7F]/.test(String(input)) || host.includes("xn--") }; +} + +export const txtName = (domain: string): string => `${TXT_PREFIX}.${domain}`; +/** The Host/Name field in a DNS panel is relative to the zone, so most control + * panels want just this label. Handing over the fully-qualified name instead is + * the classic way to end up with _dojobay.example.com.example.com. */ +export const txtHost = (): string => TXT_PREFIX; +export const txtValue = (paymentCode: string): string => `${CLAIM_VERSION} pm=${paymentCode}`; +export const signingText = (domain: string, paymentCode: string): string => claimText(`https://${domain}`, paymentCode); + +// Does a TXT record claim this payment code? Tolerant of extra whitespace and +// of the record being wrapped in quotes by a DNS UI, strict about the code. +export function txtMatches(record: unknown, paymentCode: string): boolean { + const r = String(record || "").trim().replace(/^"|"$/g, "").replace(/\s+/g, " "); + if (!r.startsWith(CLAIM_VERSION)) return false; + const m = r.match(/\bpm=(PM8T[1-9A-HJ-NP-Za-km-z]+)/); + return !!m && m[1] === paymentCode; +} + +// Full verification: the signature first (cheap, local, and the operator's most +// likely mistake), then DNS (slow, over Tor). +export async function verifyClaim( + { domain, paymentCode, signed }: { domain: string; paymentCode: string; signed: string }, + cfg: LookupCfg = {}, +): Promise { + const sig = verifySignedUrlClaim({ signed, expectedUrl: `https://${domain}`, paymentCode }); + if (!sig.ok) return { ok: false, stage: "signature", error: sig.error }; + const dns = await txtRecordAgreed(txtName(domain), (r) => txtMatches(r, paymentCode), cfg); + if (!dns.ok) { + return { ok: false, stage: "dns", inconclusive: !!dns.inconclusive, error: dns.error, + hint: dns.inconclusive ? null : `publish a TXT record at ${txtName(domain)} containing: ${txtValue(paymentCode)}` }; + } + return { ok: true, agreed: dns.agreed, answered: dns.answered }; +} + +// DNS-only re-check for the periodic sweep: the signature is immutable once +// accepted, so there is nothing to re-verify locally. +export async function recheckClaim(claim: DomainClaim, cfg: LookupCfg = {}): Promise { + return txtRecordAgreed(txtName(claim.domain), (r) => txtMatches(r, claim.paymentCode), cfg); +} + +// Fold a re-check result into a claim, applying the grace period. Pure, so the +// policy is testable without any network. +export function applyRecheck(claim: DomainClaim, result: TxtAgreement, now: number = Date.now()): DomainClaim { + const next: DomainClaim = { ...claim, last_check: new Date(now).toISOString() }; + if (result.inconclusive) { + // Could not tell. Change nothing except the timestamp, and say so. + next.last_result = "inconclusive: " + (result.error || "no detail"); + return next; + } + if (result.ok) { + next.verified = true; + next.verified_at = next.verified_at || new Date(now).toISOString(); + next.fail_since = null; + next.last_result = "ok"; + return next; + } + next.last_result = result.error || "no matching TXT record"; + next.fail_since = claim.fail_since || new Date(now).toISOString(); + const failingMs = now - Date.parse(next.fail_since); + if (failingMs >= GRACE_DAYS * 86400 * 1000) next.verified = false; + return next; +} + +export const isDue = (claim: DomainClaim, now: number = Date.now()): boolean => { + if (!claim.last_check) return true; + const since = now - Date.parse(claim.last_check); + return since >= (claim.verified ? RECHECK_MS : PENDING_RECHECK_MS); +}; + +// A URL is publishable as a card link only if it sits on the operator's verified +// domain (the domain itself or a subdomain of it). This is what stops a card +// carrying an unverifiable social profile while keeping "link to my own site". +export function urlOnDomain(url: unknown, domain: string | null | undefined): boolean { + if (!url || !domain) return false; + let u: URL; + try { u = new URL(String(url)); } catch { return false; } + if (u.protocol !== "https:" && u.protocol !== "http:") return false; + const h = u.hostname.toLowerCase(); + return h === domain || h.endsWith("." + domain); +} diff --git a/docker/dojobay/server/fix-payload-version.mjs b/docker/dojobay/server/fix-payload-version.mjs new file mode 100644 index 00000000..b048b618 --- /dev/null +++ b/docker/dojobay/server/fix-payload-version.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +// ============================================================================= +// The Dojo Bay — restore payload.pairing.version to the value that was signed. +// +// Some records have a stored pairing payload whose `version` was updated after +// the operator signed it (a Dojo upgrade, typically), so the published payload +// no longer matches the signature that attests to it. The version is purely +// informational and the live value is read from the node's X-Dojo-Version +// header on every probe, so the right correction is to put the payload back to +// what was signed and let the next signed submission move both together. +// +// STRICTLY LIMITED: this only ever writes payload.pairing.version, and only on +// records where the signed block and the stored payload are otherwise +// identical (key order and whitespace ignored). Anything else is reported and +// left alone. +// +// Usage, on the box: +// cd /var/www/dojobay/server +// node fix-payload-version.mjs # dry run, changes nothing +// sudo systemctl stop dojobay-server.service +// node fix-payload-version.mjs --apply # writes, after a backup +// sudo systemctl start dojobay-server.service +// +// The stop/start matters: server/store.ts keeps the store in memory and is +// designed as a single writer, so editing store.json underneath a running +// server would be overwritten by its next session or nonce write. --apply +// refuses to run while the service is active unless you pass --force. +// ============================================================================= +import { readFile, writeFile, rename, copyFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseSignedBlock } from "./crypto.ts"; + +const APPLY = process.argv.includes("--apply"); +const FORCE = process.argv.includes("--force"); +const DIR = process.env.SERVER_DATA_DIR + || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "data"); +const FILE = path.join(DIR, "store.json"); + +const stable = (v) => { + if (Array.isArray(v)) return "[" + v.map(stable).join(",") + "]"; + if (v && typeof v === "object") { + return "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + stable(v[k])).join(",") + "}"; + } + return JSON.stringify(v) ?? "null"; +}; +// Everything except pairing.version, so we can prove that is the only difference. +const withoutVersion = (payload) => { + const p = structuredClone(payload || {}); + if (p.pairing && typeof p.pairing === "object") delete p.pairing.version; + return { pairing: p.pairing, explorer: p.explorer }; +}; + +if (APPLY && !FORCE) { + let active = ""; + try { active = execFileSync("systemctl", ["is-active", "dojobay-server.service"], { encoding: "utf8" }).trim(); } catch (e) { active = (e.stdout || "").trim(); } + if (active === "active") { + console.error("REFUSING: dojobay-server.service is running.\n" + + "The store is held in memory by the server and would overwrite this edit.\n" + + " sudo systemctl stop dojobay-server.service\n" + + " node fix-payload-version.mjs --apply\n" + + " sudo systemctl start dojobay-server.service\n" + + "(--force overrides this check, but do not use it on a live instance.)"); + process.exit(2); + } +} + +const raw = await readFile(FILE, "utf8"); +const doc = JSON.parse(raw); +const recs = Object.values(doc.submissions || {}).sort((a, b) => a.id.localeCompare(b.id)); + +const planned = []; +const skipped = []; +for (const rec of recs) { + if (!rec.signed) continue; + const p = parseSignedBlock(rec.signed); + if (!p) { skipped.push([rec.id, "signed block does not parse"]); continue; } + let signedObj; + try { signedObj = JSON.parse(p.pairingText); } catch { skipped.push([rec.id, "signed text is not a bare pairing JSON (extra content around it)"]); continue; } + const sv = signedObj?.pairing?.version ?? null; + const cv = rec.payload?.pairing?.version ?? null; + if (sv === cv) continue; // nothing to do + if (stable(withoutVersion(signedObj)) !== stable(withoutVersion(rec.payload))) { + skipped.push([rec.id, `differs beyond the version (signed ${JSON.stringify(sv)} vs stored ${JSON.stringify(cv)}), left alone`]); + continue; + } + planned.push({ rec, from: cv, to: sv }); +} + +console.log(`Store: ${FILE}`); +console.log(`Records with a signed block: ${recs.filter((r) => r.signed).length}\n`); + +if (planned.length) { + console.log(`Version-only differences (${planned.length}) — payload.pairing.version will be set back to the signed value:`); + for (const { rec, from, to } of planned) console.log(` ${rec.id}: ${JSON.stringify(from)} -> ${JSON.stringify(to)}`); + console.log(""); +} +if (skipped.length) { + console.log(`Not touched (${skipped.length}):`); + for (const [id, why] of skipped) console.log(` ${id}: ${why}`); + console.log(""); +} +if (!planned.length) { console.log("Nothing to change."); process.exit(0); } + +if (!APPLY) { + console.log("DRY RUN — nothing written. Re-run with --apply (with the service stopped) to make these changes."); + process.exit(0); +} + +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); +const backup = `${FILE}.bak-${stamp}`; +await copyFile(FILE, backup); +for (const { rec, to } of planned) doc.submissions[rec.id].payload.pairing.version = to; +// A temporary name no other writer can take; see build-public.ts. One write per +// run, so the pid alone distinguishes it. +const tmp = `${FILE}.${process.pid}.tmp`; +await writeFile(tmp, JSON.stringify(doc, null, 2) + "\n"); +await rename(tmp, FILE); + +console.log(`Backup written: ${backup}`); +console.log(`Applied ${planned.length} change(s).`); +console.log("Start the service again, then re-run audit-signed.mjs. The published\n" + + "dojos.json picks the corrected payload up on the next updater cycle."); diff --git a/docker/dojobay/server/index.mjs b/docker/dojobay/server/index.mjs new file mode 100644 index 00000000..680f7ca3 --- /dev/null +++ b/docker/dojobay/server/index.mjs @@ -0,0 +1,25 @@ +// Launcher for the backend, which lives in index.ts. +// +// This file stays plain JavaScript on purpose, for three reasons: +// +// 1. It can be parsed by ANY Node version, so an operator on an older runtime +// gets the message below instead of a syntax error from a .ts file they +// cannot execute. The check must run before the import, hence the dynamic +// import rather than a static one. +// 2. systemd, `npm start` and the README all name index.mjs, so nothing about +// deployment changes. +// 3. self-update.mjs sanity-checks that an update archive contains +// server/index.mjs before accepting it. Renaming this file outright would +// make every legitimate update look malformed. +const major = Number(process.versions.node.split(".")[0]); +if (Number.isNaN(major) || major < 24) { + console.error( + `The Dojo Bay backend needs Node 24 or newer (found ${process.versions.node}).\n` + + "It runs TypeScript directly, which relies on type stripping added in Node 24,\n" + + "and its BIP47 libraries require it too. Upgrade Node, then restart the service."); + process.exit(1); +} + +const mod = await import("./index.ts"); +export const server = mod.server; +export const routes = mod.routes; diff --git a/docker/dojobay/server/index.ts b/docker/dojobay/server/index.ts new file mode 100644 index 00000000..0cd5d94a --- /dev/null +++ b/docker/dojobay/server/index.ts @@ -0,0 +1,1202 @@ +#!/usr/bin/env node +// The Dojo Bay — self-service submission backend (step 2 feature). +// +// Auth47 login, then a gated "manage my Dojo" API. Two hard gates on any create +// or pairing-changing edit: +// 1. connection gate: the pairing code's .onion must currently answer over Tor +// 2. signature gate: a signed pairing payload is required, and must verify +// against the notification address of the authenticated payment code (lab +// logic). The store refuses an unsigned record too, so this gate is where +// an operator is told what to do, not the only thing standing in the way. +// Passing both puts the record in a moderation queue; a maintainer approves it +// (see admin.mjs) before build-public.mjs merges it into the public dojos.json. +// +// Runs behind nginx on 127.0.0.1. No passwords, no external database. +import http from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { StoreRecord } from "../types.js"; + +/** A route handler. Registered against a method and a path pattern. */ +type Handler = (req: IncomingMessage, res: ServerResponse) => unknown | Promise; +import { randomBytes } from "node:crypto"; +import { store } from "./store.ts"; +import { + makeAuth47, notificationAddresses, verifySignedPayload, repairSignedBlock, canonicalPairing, + claimText, verifySignedUrlClaim, verifyOperatorDoc, +} from "./crypto.ts"; +import osMod from "node:os"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +const execFileP = promisify(execFile); +import { probe, PROBE_CFG } from "./probe.mjs"; +import { checkUpdates, updateCacheDecision } from "./updates.mjs"; +import { judgeVersion, MIN_DOJO_VERSION, pairingNetwork, countryFor } from "./dojo-version.ts"; +import { + normaliseDomain, txtName, txtHost, txtValue, signingText, verifyClaim, + recheckClaim, applyRecheck, isDue, urlOnDomain, GRACE_DAYS, +} from "./domains.ts"; +import { resolvePayNym } from "./paynym.mjs"; +import { rebuild } from "./build-public.ts"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const PORT = +(process.env.PORT || 8787); +// The public origin of the site (its .onion). Containerized under +// Archipelago, the onion is assigned by the platform's Tor daemon AFTER this +// process has already started (auto-provisioned for the app's gated port), +// so it cannot be baked in at build or container-start time. BASE_URL stays +// available as an explicit override (e.g. once a clearnet domain is +// verified); absent that, originOf() below derives the current origin from +// whatever host the request actually arrived on. +const CONFIGURED_BASE_URL = process.env.BASE_URL || null; +function originOf(req: IncomingMessage): string { + if (CONFIGURED_BASE_URL) return CONFIGURED_BASE_URL; + const host = String(req.headers["x-forwarded-host"] || req.headers.host || "localhost"); + return `http://${host}`; +} +const NONCE_TTL = 5 * 60 * 1000; // Auth47 nonces valid 5 minutes +const SESSION_TTL = 12 * 60 * 60 * 1000; + +// BIP47 payment codes permitted to moderate at /admin. Either is enough: an +// explicit ADMIN_PAYMENT_CODES env var (a fork's operator sets their own), or +// the payment code recorded in a verified data/operator.json — completing the +// "claim this instance" first-run step (see /api/setup/claim below) makes its +// signer the admin immediately, no restart and no env var to hand-set. +const ADMIN_CODES = (process.env.ADMIN_PAYMENT_CODES || "") + .split(",").map((s) => s.trim()).filter(Boolean); +async function operatorPaymentCode(): Promise { + try { + const dir = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"); + const doc = JSON.parse(await readFile(path.join(dir, "operator.json"), "utf8")); + return verifyOperatorDoc(doc).ok ? doc.paymentCode : null; + } catch { return null; } +} +async function isAdmin(pc: string | null | undefined): Promise { + if (!pc) return false; + if (ADMIN_CODES.includes(pc)) return true; + return pc === (await operatorPaymentCode()); +} + +const SERVER_DATA = process.env.SERVER_DATA_DIR + || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "data"); +// The published view of a node, for the admin panel. A pending submission is +// probed separately into pending-probe.json, but once it is APPROVED the +// updater stops writing there and its live status, chain tip and 24-hour checks +// live in the published files instead. Reading only the pending file therefore +// left every approved listing saying "not yet probed" with a reliability strip +// frozen at whatever it had when it was approved. +async function publishedView() { + const dir = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"); + const read = async (name, fallback) => { + try { return JSON.parse(await readFile(path.join(dir, name), "utf8")); } + catch { return fallback; } + }; + const [dojos, hist] = await Promise.all([ + read("dojos.json", { nodes: [] }), + read("history.json", { nodes: {} }), + ]); + const byId = new Map(); + for (const n of dojos.nodes || []) { + byId.set(n.id, { + status: n.status || null, + checked_at: n.checked_at || null, + block_height: n.block_height ?? null, + detected_version: n.detected_version || null, + checks: (hist.nodes?.[n.id]?.checks) || [], + }); + } + return byId; +} + +async function pendingProbe() { + try { return JSON.parse(await readFile(path.join(SERVER_DATA, "pending-probe.json"), "utf8")); } + catch { return { nodes: {} }; } +} + +// ---- helpers --------------------------------------------------------------- +const json = (res: ServerResponse, code: number, obj: unknown) => { + const body = JSON.stringify(obj); + res.writeHead(code, { "Content-Type": "application/json", "Cache-Control": "no-store" }); + res.end(body); +}; +const readBody = (req: IncomingMessage, limit = 64 * 1024) => new Promise((resolve, reject) => { + let data = ""; let size = 0; + req.on("data", (c: Buffer | string) => { size += c.length; if (size > limit) { reject(new Error("body too large")); req.destroy(); } else data += c; }); + req.on("end", () => resolve(data)); + req.on("error", reject); +}); +function parseCookies(req: IncomingMessage): Record { + const out: Record = {}; const h = req.headers.cookie || ""; + h.split(";").forEach((p) => { const i = p.indexOf("="); if (i > 0) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); }); + return out; +} +async function sessionFrom(req) { + const sid = parseCookies(req).dojobay_sid; + return sid ? await store.getSession(sid) : null; +} +function networkOf(rec) { return rec === "testnet" ? "testnet" : "bitcoin"; } + +// Ownership: a record is owned by whoever holds ANY of its payment codes, +// because a PayNym commonly has two BIP47 codes (segwit + legacy) and the +// wallet may sign Auth47 with either variant. +const owns = (rec, pc) => !!rec && Array.isArray(rec.paymentCodes) && rec.paymentCodes.includes(pc); + +// Node names: operator-chosen, unique per network. The slug both keys the +// record (`${network}-${slug}`) and enforces case/punctuation-insensitive +// uniqueness of the display name. +// Signed pairing blocks arrive by clipboard, which is where stray bytes creep +// in: CRLF line endings, zero-width characters, non-breaking spaces. Wallets +// emit LF-only ASCII, so stripping these BEFORE signature verification keeps a +// mangled paste verifiable while never altering what the wallet actually +// signed. Applied at intake only; stored and emitted bytes are then clean. +const cleanSigned = (v) => { + const t = String(v || "").replace(/\r/g, "").replace(/[\u200b\u200c\u200d\ufeff]/g, "").replace(/\u00a0/g, " ").trim(); + return t || null; +}; + +const slugify = (name) => String(name || "") + .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40); + +// Curated seed nodes are not in the store but still occupy the same public +// namespace, so a submission may not take a seed node's name or id. +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +async function seedNodes() { + const p = path.join(process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"), "seed.json"); + try { return JSON.parse(await readFile(p, "utf8")).nodes || []; } catch { return []; } +} + +// Is `slug` free on `network` for the holder of `pc`? Returns null when free +// or when it names a record the caller already owns (an update), otherwise a +// human-readable reason. Checks every store record regardless of status plus +// the curated seed, so a rejected or pending record cannot be hijacked either. +async function nameConflict(network, slug, pc) { + for (const r of await store.listSubmissions()) { + if (r.network !== network) continue; + if (slugify(r.name) !== slug && r.id !== `${network}-${slug}`) continue; + if (!owns(r, pc)) return `the name is already used by another operator's ${r.status} record`; + } + for (const n of await seedNodes()) { + if (n.network !== network) continue; + if (slugify(n.name) === slug || n.id === `${network}-${slug}`) return "the name is reserved by a curated seed node"; + } + return null; +} + +// The record an owner's (network, slug) submission should update, if any. +async function ownedRecordFor(network, slug, pc) { + for (const r of await store.listSubmissions()) { + if (r.network === network && owns(r, pc) + && (slugify(r.name) === slug || r.id === `${network}-${slug}`)) return r; + } + return null; +} + +// Manage-panel ordering: mainnet before testnet, then alphabetical by name. +const submissionOrder = (a, b) => + a.network !== b.network + ? (a.network === "mainnet" ? -1 : 1) + : String(a.name || a.id).localeCompare(String(b.name || b.id), "en", { sensitivity: "base" }); +const isPlainOnionUrl = (u) => { try { const x = new URL(u); return x.protocol === "http:" && /\.onion$/.test(x.hostname); } catch { return false; } }; + +function validatePayload(payload, network = null) { + if (!payload || typeof payload !== "object") return "missing pairing payload"; + const p = payload.pairing; + if (!p || p.type !== "dojo.api" || !p.url) return "pairing.type must be dojo.api with a url"; + if (!isPlainOnionUrl(p.url)) return "pairing.url must be an http .onion address"; + if (payload.explorer && !isPlainOnionUrl(payload.explorer.url)) return "explorer.url must be an http .onion address"; + // The endpoint must be for the network the listing claims. See pairingNetwork + // for why a crossed pair is worth refusing: it probes green forever and the + // only symptom is a block height nobody reads as an error. Network omitted, + // the URL is not judged, which keeps this usable where the network is not yet + // known. + if (network) { + const looks = pairingNetwork(p.url); + if (looks && looks !== network) { + return network === "testnet" + ? "this is a mainnet endpoint: a testnet Dojo serves http:///test/v2. " + + "Either paste your testnet pairing payload or list it as mainnet." + : "this is a testnet endpoint: a mainnet Dojo serves http:///v2. " + + "Either paste your mainnet pairing payload or list it as testnet."; + } + } + return null; +} + +// ---- routes ---------------------------------------------------------------- +const routes: { method: string; re: RegExp; fn: Handler }[] = []; +const route = (method: string, re: RegExp, fn: Handler) => routes.push({ method, re, fn }); + +// 1) begin login: mint nonce + challenge URI (QR-encoded client-side) +route("POST", /^\/api\/auth47\/challenge$/, async (req, res) => { + await store.gcNonces(); + const nonce = randomBytes(16).toString("hex"); // 32 alphanumeric chars + const expires = Date.now() + NONCE_TTL; + const base = originOf(req); + // Built fresh per request (not a shared module-level instance): the + // Auth47Verifier bakes its callback URL in at construction time, and that + // URL has to match whatever origin the wallet is actually going to reach + // this instance at right now. See originOf() above. + const auth47 = makeAuth47(base); + const uri = auth47.challengeURI(nonce, Math.floor(expires / 1000), base); + await store.putNonce(nonce, { expires, used: false, sid: null }); + json(res, 200, { nonce, uri, expires }); +}); + +// 2) wallet callback: verify proof, bind nonce -> payment code +// The update check's answer, cached in the process. Declared here rather than +// beside the route that fills it, because the login below clears it and a const +// used before its declaration is a trap waiting for someone to reorder a file. +let UPDATES_CACHE = null; +// When a forced check last actually went out, and the shortest gap between two +// of them. Declared beside the cache they qualify, and above their first use: +// a const read by code that runs before its declaration has failed outright +// here before. +let FORCED_UPDATE_AT = 0; +const FORCED_UPDATE_FLOOR = 60 * 1000; + +route("POST", /^\/api\/auth47\/callback$/, async (req, res) => { + let proof; + try { proof = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } + // originOf(req) is what challengeURI put in the r parameter (a wallet always + // calls its callback back on the same host it read the challenge from), so + // it is what the proof must name. See makeAuth47.verify for what this stops. + const base = originOf(req); + const auth47 = makeAuth47(base); + const v = auth47.verify(proof, { expectedResource: base }); + if (!v.ok) return json(res, 401, { error: v.error }); + // tie proof back to a live nonce (prevents replay to a different session) + let nonce = null; + try { nonce = new URL(proof.challenge).hostname; } catch {} + const rec = nonce ? await store.takeNonce(nonce) : null; + if (!rec) return json(res, 401, { error: "unknown or expired nonce" }); + if (rec.expires < Date.now()) return json(res, 401, { error: "challenge expired" }); + const sid = await store.putSession({ paymentCode: v.paymentCode, expires: Date.now() + SESSION_TTL }); + // Signing in is the moment somebody is about to look at the console, so it is + // the moment to stop answering from a six-hour-old cache. Without this an + // operator who pushed a commit twenty minutes ago is told they are up to date + // and has no way to say otherwise: signing out and back in did not help, + // because the cache lives in the process rather than the session, and only a + // service restart cleared it. Discarding it here costs one request over Tor + // per login and removes the whole confusion. + UPDATES_CACHE = null; + // stash the sid against the nonce value so the browser poll can pick it up + await store.putNonce("claimed:" + nonce, { expires: Date.now() + NONCE_TTL, sid }); + json(res, 200, { ok: true }); +}); + +// 3) browser poll: has my nonce been claimed? if so, set the session cookie +route("GET", /^\/api\/auth47\/poll$/, async (req, res) => { + const u = new URL(req.url, "http://x"); + const nonce = u.searchParams.get("nonce") || ""; + const claim = await store.takeNonce("claimed:" + nonce); + if (!claim) return json(res, 200, { authenticated: false }); + res.setHeader("Set-Cookie", + `dojobay_sid=${claim.sid}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.floor(SESSION_TTL / 1000)}`); + json(res, 200, { authenticated: true }); +}); + +// 4) who am I +route("GET", /^\/api\/me$/, async (req, res) => { + const s = await sessionFrom(req); + if (!s) return json(res, 200, { authenticated: false }); + const mine = (await store.submissionsFor(s.paymentCode)).sort(submissionOrder); + json(res, 200, { authenticated: true, paymentCode: s.paymentCode, admin: await isAdmin(s.paymentCode), submissions: mine }); +}); + +// ---- first-run: claim this instance ----------------------------------------- +// Containerized under Archipelago, this instance starts with no data/operator.json +// (see docker/dojobay/data-template) — deliberately: there is no install-time +// wizard to have written one, unlike the upstream CLI installer. Whoever signs +// in with Auth47 first and completes this claim becomes the operator (first +// claim wins, refused once one exists), the same "first admin" shape most +// self-hosted apps use for their initial setup. No new cryptography: claimText +// and verifySignedUrlClaim/verifyOperatorDoc are the exact primitives the +// upstream README already has an operator sign and verify by hand. +route("GET", /^\/api\/instance$/, async (req, res) => { + const seeds = await seedNodes(); + json(res, 200, { + onion: originOf(req), + operatorConfigured: !!(await operatorPaymentCode()), + hasAnchor: seeds.length > 0, + }); +}); + +route("GET", /^\/api\/setup\/claim-text$/, async (req, res) => { + const s = await sessionFrom(req); + if (!s) return json(res, 401, { error: "not authenticated" }); + if (await operatorPaymentCode()) return json(res, 409, { error: "this instance is already claimed" }); + const onion = originOf(req); + json(res, 200, { onion, text: claimText(onion, s.paymentCode) }); +}); + +route("POST", /^\/api\/setup\/claim$/, async (req, res) => { + const s = await sessionFrom(req); + if (!s) return json(res, 401, { error: "not authenticated" }); + if (await operatorPaymentCode()) return json(res, 409, { error: "this instance is already claimed" }); + let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } + + let signed = cleanSigned(body.signed); + if (!signed) { + return json(res, 400, { error: "paste the signed block. Sign the exact text shown, under PayNym → Sign message." }); + } + const repaired = repairSignedBlock(signed); + if (repaired) signed = repaired.block; + + const onion = originOf(req); + const claim = verifySignedUrlClaim({ signed, expectedUrl: onion, paymentCode: s.paymentCode }); + if (!claim.ok) return json(res, 400, { error: claim.error }); + + const doc = { onion, paymentCode: s.paymentCode, verifySigned: signed }; + // Belt and braces: write only a document that the same check every other + // instance (bootstrap import, self-update's peer trust check, every rebuild) + // will itself accept as this instance's operator binding. + const recheck = verifyOperatorDoc(doc, { expectedOnion: onion }); + if (!recheck.ok) return json(res, 500, { error: "internal: claim verified but the assembled operator.json did not (" + recheck.error + ")" }); + + const dir = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"); + await writeFile(path.join(dir, "operator.json"), JSON.stringify(doc, null, 2) + "\n"); + json(res, 200, { ok: true, onion, paymentCode: s.paymentCode }); +}); + +// ---- admin (moderation) ---------------------------------------------------- +// All require an authenticated session whose payment code is in ADMIN_CODES. +async function adminFrom(req, res) { + const s = await sessionFrom(req); + if (!s) { json(res, 401, { error: "not authenticated" }); return null; } + if (!(await isAdmin(s.paymentCode))) { json(res, 403, { error: "not authorised" }); return null; } + return s; +} + +// list submissions with their pending-probe status + reliability history +route("GET", /^\/api\/admin\/submissions$/, async (req, res) => { + if (!(await adminFrom(req, res))) return; + const probes = (await pendingProbe()).nodes || {}; + const live = await publishedView(); + const subs = (await store.listSubmissions()).map((s) => ({ + id: s.id, network: s.network, status: s.status, name: s.name || null, + paynym: s.paynym || null, paymentCodes: s.paymentCodes, + jurisdiction: s.jurisdiction || null, country: s.country || null, + hardware: s.hardware || null, signed: !!s.signed, + version: (live.get(s.id)?.detected_version) || (probes[s.id] && probes[s.id].detected_version) + || s.payload?.pairing?.version || null, + pairingUrl: s.payload?.pairing?.url || null, + created_at: s.created_at || null, updated_at: s.updated_at || null, + // Prefer the published view: it is what the card shows and it keeps being + // updated. Fall back to the pending probe for a record not yet approved. + probe: live.get(s.id) || probes[s.id] || null, // { status, checked_at, block_height, checks[] } + probe_source: live.has(s.id) ? "published" : (probes[s.id] ? "pending" : null), + })); + json(res, 200, { admin: true, submissions: subs }); +}); + +// The store change (approve/reject/remove) is committed before the public list +// is rebuilt, so a rebuild failure must be REPORTED, not thrown as a 500 that +// hides which half happened: the moderation applied but publication did not. +// The updater re-runs the rebuild at the start of every 10-minute cycle, so a +// failed publish heals itself; the error here tells the admin why it deferred. +async function tryRebuild() { + try { return await rebuild(); } + catch (e) { return { error: e.message, msg: "rebuild failed: " + e.message + " (the updater retries every 10 minutes)" }; } +} + +route("POST", /^\/api\/admin\/approve$/, async (req, res) => { + if (!(await adminFrom(req, res))) return; + let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } + const rec = await store.getSubmission(body.id); + if (!rec) return json(res, 404, { error: "not found" }); + rec.status = "approved"; + rec.updated_at = new Date().toISOString(); + if (body.paynym) rec.paynym = body.paynym.startsWith("+") ? body.paynym : "+" + body.paynym; + else if (!rec.paynym) { const r = await resolvePayNym(rec.paymentCodes[0]).catch(() => null); if (r) rec.paynym = r; } + // Mirror the operator's PayNym avatar now rather than waiting a cycle; the + // updater retries missing ones every ten minutes, so failure here is fine. + import("../scripts/update.mjs").then(({ fetchAvatar }) => Promise.all( + rec.paymentCodes.map((c) => fetchAvatar(c, { + proxyHost: PROBE_CFG.proxyHost, proxyPort: PROBE_CFG.proxyPort, + destDir: path.join(process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"), "avatars"), + }).catch(() => {})) + )).catch(() => {}); + await store.putSubmission(rec); + const out = await tryRebuild(); + json(res, 200, { ok: true, submission: rec, rebuild: out }); +}); + +route("POST", /^\/api\/admin\/reject$/, async (req, res) => { + if (!(await adminFrom(req, res))) return; + let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } + const rec = await store.getSubmission(body.id); + if (!rec) return json(res, 404, { error: "not found" }); + rec.status = "rejected"; + rec.updated_at = new Date().toISOString(); + await store.putSubmission(rec); + const out = await tryRebuild(); // drops it from the public list if it was approved + json(res, 200, { ok: true, rebuild: out }); +}); + +route("POST", /^\/api\/admin\/remove$/, async (req, res) => { + if (!(await adminFrom(req, res))) return; + let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } + await store.deleteSubmission(body.id); + const out = await tryRebuild(); + json(res, 200, { ok: true, rebuild: out }); +}); + +// 5) logout +route("POST", /^\/api\/logout$/, async (req, res) => { + const sid = parseCookies(req).dojobay_sid; + if (sid) await store.dropSession(sid); + res.setHeader("Set-Cookie", "dojobay_sid=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"); + json(res, 200, { ok: true }); +}); + +// 6) is a node name free on a network? (pre-flight for the submission form; +// the POST below re-checks and is the authority) +route("GET", /^\/api\/dojo\/name-check$/, async (req, res) => { + const s = await sessionFrom(req); + if (!s) return json(res, 401, { error: "not authenticated" }); + const u = new URL(req.url, "http://x"); + const network = u.searchParams.get("network") === "testnet" ? "testnet" : "mainnet"; + const slug = slugify(u.searchParams.get("name")); + if (!slug) return json(res, 400, { error: "name must contain at least one letter or digit" }); + const conflict = await nameConflict(network, slug, s.paymentCode); + const mine = conflict ? null : await ownedRecordFor(network, slug, s.paymentCode); + json(res, 200, { available: !conflict, reason: conflict, slug, update: !!mine, id: mine ? mine.id : `${network}-${slug}` }); +}); + +// 7) create or replace one of my Dojo records (keyed by network + node name) +route("POST", /^\/api\/dojo$/, async (req, res) => { + const s = await sessionFrom(req); + if (!s) return json(res, 401, { error: "not authenticated" }); + let body; + try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } + + const network: "mainnet" | "testnet" | null = body.network === "testnet" ? "testnet" : (body.network === "mainnet" ? "mainnet" : null); + if (!network) return json(res, 400, { error: "network must be mainnet or testnet" }); + + const name = String(body.name || "").trim().slice(0, 40); + const slug = slugify(name); + if (!slug) return json(res, 400, { error: "name is required (letters, digits and hyphens)" }); + const conflict = await nameConflict(network, slug, s.paymentCode); + if (conflict) return json(res, 409, { error: `name "${name}" is taken on ${network}: ${conflict}` }); + + const payloadErr = validatePayload(body.payload, network); + if (payloadErr) return json(res, 400, { error: payloadErr }); + + body.signed = cleanSigned(body.signed); + // signature gate. The signed block is REQUIRED: it is the only part of a + // listing a visitor can check without trusting this site, so a listing + // without one asks for trust we have no way to earn. Refused here rather than + // at the store so the operator is told what to do about it while they still + // have the form open, and before the connection gate spends thirty seconds + // probing a node whose submission cannot be accepted anyway. + if (!body.signed) { + return json(res, 400, { error: "signature gate: paste the signed pairing block. " + + "Sign the exact pairing text shown above in your wallet under PayNym → Sign message, " + + "then paste the whole block, headers included." }); + } + { + // Operators paste this into a web form, which eats whitespace as readily as + // a chat window does — and the signature covers the blank line before the + // BIP47 line. Repair that before judging, so a correct signature is not + // reported as invalid. A reconstruction is only accepted when it verifies + // against an address the declared code derives, and the repaired block is + // what gets stored, so a later audit verifies too. + const repaired = repairSignedBlock(body.signed); + if (repaired) body.signed = repaired.block; + + const sig = verifySignedPayload({ + signedText: body.signed, + expectedMessage: canonicalPairing(body.payload), + // A PayNym signs from its mainnet notification address even when the + // node being listed is testnet, so accept either derivation. + expectedAddress: notificationAddresses(s.paymentCode), + network: networkOf(network), + }); + if (!sig.ok) return json(res, 400, { error: "signature gate: " + sig.error }); + } + + // connection gate: the node must answer right now over Tor. When the pairing + // payload carries an apikey (it should), this performs the same authenticated + // chain-tip read the health checker uses, so a submission must prove its + // apikey works and the Dojo is serving block data, not merely that the onion + // is reachable. Without an apikey it falls back to a plain reachability probe. + const check = await probe(body.payload.pairing.url, { ...PROBE_CFG, apikey: body.payload.pairing.apikey, network }); + if (!check.up) return json(res, 422, { error: "connection gate: node unreachable or not serving block data over Tor (" + (check.reason || "no response") + ")", probe: check }); + + // An owned record with this name (or this id, for records that predate + // operator naming) is updated in place, keeping its id and therefore its + // reliability history; otherwise a new record is created at network-slug. + const existing = await ownedRecordFor(network, slug, s.paymentCode); + + // Minimum Dojo version, on REGISTRATION only. + // + // Judged on what the node just told us in its X-Dojo-Version header rather + // than on the version inside the payload: that field is frozen when the + // payload is generated and can be years stale, so a current node can declare + // an ancient version quite honestly. + // + // Existing operators are not re-judged. An operator updating a listing they + // already hold — a moved onion, a rotated key — is not registering, and + // trapping them behind a rule introduced after they joined would punish them + // for maintaining their node. New listings only. + if (!existing) { + const v = judgeVersion(check.detectedVersion, body.payload?.pairing?.version, MIN_DOJO_VERSION); + if (!v.ok) { + return json(res, 422, { + error: "version gate: " + v.reason, + minimum: MIN_DOJO_VERSION, + detected: check.detectedVersion || null, + }); + } + } + + const id = existing ? existing.id : `${network}-${slug}`; + const now = new Date().toISOString(); + // Resolve the registered PayNym from paynym.rs (best-effort, over Tor). Keep a + // previously resolved value if the lookup is momentarily unavailable. + const resolvedNym = await resolvePayNym(s.paymentCode).catch(() => null); + const rec: StoreRecord = { + id, network, name, + // Union with any codes already on the record, so a record migrated with + // both PayNym variants keeps them when the operator edits via either. + paymentCodes: [...new Set([...(existing?.paymentCodes || []), s.paymentCode])], + paynym: resolvedNym || (existing && existing.paynym) || null, + jurisdiction: (body.jurisdiction || "").toString().slice(0, 64) || null, + // Inferred from what the operator wrote about where they are, never asked + // for separately. It used to be its own field, sliced to two characters and + // upper-cased, which rejected nothing: "FIN" silently became "FI" and was + // published as whichever country those letters name, while a single letter + // or half a pasted flag emoji were stored as given and rendered on a card + // as letterboxes. Now there is one question, no validation, and a flag when + // the answer happens to name somewhere. + // + // An existing code survives an edit that no longer implies one, so nobody + // loses a flag they already had by rewording their location. + country: countryFor(body.jurisdiction) || (existing && existing.country) || null, + hardware: (body.hardware || "").toString().slice(0, 120) || null, + // Exactly the two keys the signature covers. A Dojo export may carry more + // (an indexer block, a services[] array); none of it is signed, and the + // published payload is what a visitor pairs with, so it stores nothing it + // cannot attest to. The Electrum endpoint on a card comes from probing + // /support/services instead. + payload: { pairing: body.payload.pairing, explorer: body.payload.explorer }, + signed: body.signed || null, + // A link supplied here is set; left blank, any existing link is kept (the + // Edit panel, where the field is prefilled, is the place to clear it). + status: "pending", // moderation state: pending | approved | rejected + last_probe: check, + created_at: existing ? existing.created_at : now, + updated_at: now, + }; + await store.putSubmission(rec); + json(res, 200, { ok: true, submission: rec, note: "Submitted for review. It will appear once a maintainer approves it." }); +}); + +// Editable metadata: name and hardware. The Dojo version is NOT editable it is +// read live from the node's X-Dojo-Version response header by the updater (see +// scripts/update.mjs), so it always reflects what the node is actually running. +// These are display fields, so an edit keeps the record's moderation status and +// its id (and therefore its history); only a full resubmission re-enters +// moderation. A rename must not collide with ANY other record's name on that +// network, including the editor's own other records, hence the excludeId scan. +async function slugTakenByOther(network, slug, excludeId) { + for (const r of await store.listSubmissions()) { + if (r.id === excludeId || r.network !== network) continue; + if (slugify(r.name) === slug || r.id === `${network}-${slug}`) { + return `the name is already used by ${r.paynym || "another"}'s ${r.status} record`; + } + } + for (const n of await seedNodes()) { + if (n.network !== network || n.id === excludeId) continue; + if (slugify(n.name) === slug || n.id === `${network}-${slug}`) return "the name is reserved by a curated seed node"; + } + return null; +} + +async function applyEdit(rec, body, res) { + const name = String(body.name || "").trim().slice(0, 40); + const slug = slugify(name); + if (!slug) return json(res, 400, { error: "name is required (letters, digits and hyphens)" }); + const taken = await slugTakenByOther(rec.network, slug, rec.id); + if (taken) return json(res, 409, { error: `name "${name}" is taken on ${rec.network}: ${taken}` }); + rec.name = name; + rec.hardware = String(body.hardware || "").trim().slice(0, 120) || null; + rec.updated_at = new Date().toISOString(); + await store.putSubmission(rec); + const out = rec.status === "approved" ? await tryRebuild() : null; // approved edits publish immediately + json(res, 200, { ok: true, submission: rec, rebuild: out }); +} + +// 9) edit display fields on one of my records +route("POST", /^\/api\/dojo\/edit$/, async (req, res) => { + const s = await sessionFrom(req); + if (!s) return json(res, 401, { error: "not authenticated" }); + let body; + try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } + const rec = await store.getSubmission(body.id); + if (!rec || !owns(rec, s.paymentCode)) return json(res, 404, { error: "not found" }); + await applyEdit(rec, body, res); +}); + +// 9b) update the pairing details of a record you already own. +// +// The onion of a Dojo can change, an apikey can be rotated, an operator can +// start exposing an Electrum indexer. None of that changes WHO runs the node, +// and approval here binds to the payment code rather than to a particular +// address: a maintainer approving a listing is approving the operator, and +// leaves visitors to judge the node. So a pairing update keeps the record's +// moderation status, its id and therefore its reliability history, and only +// ever writes the payload and its signature. +// +// The same gates as a submission still apply, because they protect the reader +// rather than gatekeep the operator: the payload must be well-formed, the new +// onion must answer over Tor right now (which catches a mistyped address before +// it replaces a working one), and a signature, if supplied, must verify against +// the payment code signed in. +route("POST", /^\/api\/dojo\/pairing$/, async (req, res) => { + const s = await sessionFrom(req); + if (!s) return json(res, 401, { error: "not authenticated" }); + let body; + try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } + + const rec = await store.getSubmission(body.id); + if (!rec || !owns(rec, s.paymentCode)) return json(res, 404, { error: "not found" }); + + // Taken from the RECORD, not from the request: an edit cannot change which + // network a listing is on, so the new endpoint is judged against the network + // the listing already has. + const network = rec.network === "testnet" ? "testnet" : "mainnet"; + const payloadErr = validatePayload(body.payload, network); + if (payloadErr) return json(res, 400, { error: payloadErr }); + + body.signed = cleanSigned(body.signed); + // Required here for the same reason as at submission, and for one more: this + // endpoint assigns rec.signed unconditionally, so an edit that omitted the + // block used to replace a verified signature with null and quietly turn a + // checkable listing into an unattested one. New pairing details need a new + // signature over them; the old one covers the old details and would be a lie + // about the new. + if (!body.signed) { + return json(res, 400, { error: "signature gate: paste a signed block covering the NEW pairing details. " + + "Your existing signature covers the details you are replacing, so it cannot carry over. " + + "Your listing is unchanged." }); + } + { + const repaired = repairSignedBlock(body.signed); + if (repaired) body.signed = repaired.block; + const sig = verifySignedPayload({ + signedText: body.signed, + expectedMessage: canonicalPairing(body.payload), + expectedAddress: notificationAddresses(s.paymentCode), + }); + if (!sig.ok) return json(res, 400, { error: "signature gate: " + sig.error }); + } + + const check = await probe(body.payload.pairing.url, { + ...PROBE_CFG, apikey: body.payload.pairing.apikey, network, + }); + if (!check.up) { + return json(res, 422, { + error: "connection gate: that node is unreachable or not serving block data over Tor (" + + (check.reason || "no response") + "). Your listing is unchanged.", + probe: check, + }); + } + + // Exactly the two keys the signature covers, and nothing the operator posted + // alongside them. canonicalPairing is what was verified above, so anything + // else in body.payload is unattested and must not be stored, let alone + // published: dojos.json publishes payload wholesale for visitors to pair + // with. + rec.payload = { pairing: body.payload.pairing, explorer: body.payload.explorer }; + rec.signed = body.signed || null; + rec.last_probe = check; + rec.updated_at = new Date().toISOString(); + await store.putSubmission(rec); + + // Republish straight away when the record is live, so a moved onion is + // corrected on the cards without waiting for the next probe cycle. + const rebuilt = rec.status === "approved" ? await tryRebuild() : null; + json(res, 200, { ok: true, submission: rec, rebuild: rebuilt, + note: "Pairing details updated. Your listing keeps its place and its history." }); +}); + +// 10) admin: edit display fields on any record +route("POST", /^\/api\/admin\/edit$/, async (req, res) => { + const s = await adminFrom(req, res); + if (!s) return; + let body; + try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } + const rec = await store.getSubmission(body.id); + if (!rec) return json(res, 404, { error: "not found" }); + await applyEdit(rec, body, res); +}); + +// 12) admin: apply an update. +// +// Upstream Dojo Bay self-updates by fetching a source archive and swapping it +// onto disk under a systemd unit it then restarts. None of that applies to a +// container Archipelago builds and versions itself — there is no systemd unit +// here to restart, and overwriting the image's own files under a read-only +// root would not survive the next container recreate even if it worked. So +// self-update.mjs is not shipped in this image, and this route explains that +// instead of trying it: updating this app means updating the Dojo Bay app +// from the Archipelago App Store, the same as any other app. +let UPDATE_JOB = null; // kept for the status route's shape; never set in this image +route("POST", /^\/api\/admin\/update$/, async (req, res) => { + if (!(await adminFrom(req, res))) return; + json(res, 409, { + error: "Self-update is not available in this build. This instance is an Archipelago app: " + + "update it from the App Store like any other app, not from here.", + }); +}); + +// 12b) admin: import listings from another Dojo Bay. +// +// The same operation the installer performs at setup, offered to a running +// instance. It is a background job for the same reason the self-update is: the +// three documents come from another onion over Tor, which is seconds at best, +// and holding a request open for that is worse than polling. +// +// It runs IN this process rather than by spawning the script, because the +// backend holds the store in memory as its single writer. A second process +// writing store.json while this one has it loaded is the bug the maintenance +// tools all refuse to risk. +// +// Two phases on purpose. The premise of importing from another directory is +// that you do not trust it, so an operator sees the plan (what would be +// imported, what is already listed here under another id, what is refused and +// why) before anything is written. That is the same dry run the command line +// defaults to. +// +// Imported records arrive PENDING. At install, approved is right, because +// choosing to bootstrap is the decision to trust that list wholesale. In a +// running instance with a moderation queue, a listing that appeared on the site +// without passing through it would be the other directory publishing here. +let IMPORT_JOB = null; // { id, phase, log[], done, ok, error, apply, onion, result } +route("POST", /^\/api\/admin\/import$/, async (req, res) => { + if (!(await adminFrom(req, res))) return; + if (IMPORT_JOB && !IMPORT_JOB.done) return json(res, 409, { error: "an import is already in progress" }); + let body; try { body = JSON.parse(await readBody(req)); } catch { body = {}; } + + const onionHost = String(body.onion || "").replace(/^https?:\/\//, "").replace(/\/.*$/, ""); + if (!/^[a-z2-7]{56}\.onion$/.test(onionHost)) { + return json(res, 400, { error: "a valid .onion address is required" }); + } + // The peer's payment code is what the operator binding is checked against, + // and it is the operator's own statement of who they are choosing to trust. + // Without it there is nothing to compare the remote signature to, so the + // import would verify only that the remote signed something. + const trustedCode = String(body.code || "").trim(); + if (!/^(PM8T|PM8[A-Za-z0-9])/.test(trustedCode) || trustedCode.length < 100) { + return json(res, 400, { error: "the payment code of the instance you are importing from is required" }); + } + + const id = Date.now().toString(36); + const job = IMPORT_JOB = { id, phase: "starting", log: [], done: false, ok: false, error: null, + apply: body.apply === true, onion: onionHost, result: null }; + const log = (line: string) => { job.log.push(String(line)); if (job.log.length > 400) job.log.shift(); }; + + (async () => { + try { + job.phase = job.apply ? "importing" : "planning"; + const { bootstrapImport } = await import("../scripts/bootstrap-import.mjs"); + job.result = await bootstrapImport({ + onionHost, trustedCode, dryRun: !job.apply, log, + dataDir: process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"), + status: "pending", + }); + // Rebuild after an apply. The new records are pending and publish + // nothing, but an import also carries history onto listings this instance + // already has and adds domain claims, and both of those do reach the + // published file. A planning run writes nothing and needs none. + if (job.apply) { job.phase = "rebuilding"; await tryRebuild(); } + + // Then probe, for the same reason the installer runs a cycle before it + // declares success. A pending record's live status comes from + // pending-probe.json, which only the update cycle writes, so until one + // runs an imported listing has no status at all and the moderation queue + // shows a wall of nodes reading inactive. They are not inactive; nothing + // has asked them yet, and a moderator deciding whether to approve is + // exactly the person who needs the answer. + // + // Skipped when a cycle is already running, since there is no lock and two + // at once would race to write the same files. systemd knows, and reading + // that needs no privilege. + if (job.apply && (job.result?.imported ?? 0) > 0) { + job.phase = "probing"; + const running = await execFileP("systemctl", ["is-active", "--quiet", "dojobay-update.service"]) + .then(() => true, () => false); + if (running) { + log("a probe cycle is already running; the imported nodes will get their status from it"); + } else { + log("probing the imported nodes over Tor (this takes a minute)"); + try { + await execFileP(process.execPath, [path.join(ROOT, "scripts", "update.mjs")], + { cwd: ROOT, timeout: 5 * 60 * 1000 }); + log("probe cycle complete: the imported nodes show their own status"); + } catch (e: any) { + // Non-fatal. The records are imported and the timer comes round + // within ten minutes; what is lost is the status being right + // immediately, not the import. + log("! probe cycle failed: " + (e.message || String(e))); + log(" the import is complete; the update timer will fill in statuses within ten minutes"); + } + } + } + job.ok = true; job.done = true; job.phase = "done"; + } catch (e: any) { + job.error = e.message; job.ok = false; job.done = true; job.phase = "failed"; + log("✗ " + e.message); + } + })(); + + json(res, 202, { started: true, id, apply: job.apply }); +}); + +route("GET", /^\/api\/admin\/import\/status$/, async (req, res) => { + if (!(await adminFrom(req, res))) return; + json(res, 200, { job: IMPORT_JOB }); +}); + +route("GET", /^\/api\/admin\/update\/status$/, async (req, res) => { + if (!(await adminFrom(req, res))) return; + // After a successful apply the service restarts; on the way back up the + // helper leaves data/updates/last-result.json, which we surface so the panel + // can confirm completion across the restart. + let lastResult = null; + try { lastResult = JSON.parse(await readFile(path.join(process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"), "updates", "last-result.json"), "utf8")); } catch {} + json(res, 200, { job: UPDATE_JOB, lastResult }); +}); + +// The restart permission self-update needs cannot be checked from here. +// +// Two attempts, both wrong, and the second wrong in a way that took a live +// instance to find. systemctl restart --dry-run returns before any bus call, so +// it reported success whatever the account could do. pkcheck asks polkit the +// right question, but polkit refuses CheckAuthorization() WITH DETAILS from any +// caller that is not uid 0 or the action's owner, and the rule keys on the unit +// and the verb, so without details it cannot match and the answer would be a +// false no. The rules directory is 750 root:polkitd, so reading the file is +// closed off too. +// +// So this instance says nothing about whether the permission is present. What +// it can do is report the one thing it has evidence for: an update that +// installed and did not restart, which lastResult already records, and which is +// exactly the symptom a missing permission produces. +route("GET", /^\/api\/admin\/updates$/, async (req, res) => { + if (!(await adminFrom(req, res))) return; + // Six hours is right for an unattended check over Tor, where GitHub rate + // limits shared exit nodes. It is wrong for somebody who has just signed in + // to look, which is why the login discards it, and wrong for somebody who has + // just pushed while already signed in, which is what ?refresh=1 is for. + // + // The floor is what stops that button being a way to hammer GitHub from an + // exit node shared with every other Tor user. A forced check inside the floor + // is answered from the cache with the wait attached, rather than refused: + // the operator asked what the state is, and the honest answer is the last one + // known plus how stale it is. + const forced = /[?&]refresh=1(&|$)/.test(req.url || ""); + const decision = updateCacheDecision({ + cachedAt: UPDATES_CACHE ? UPDATES_CACHE.at : null, + forced, forcedAt: FORCED_UPDATE_AT, floorMs: FORCED_UPDATE_FLOOR }); + if (decision.serveCached) { + return json(res, 200, decision.waitS + ? { ...UPDATES_CACHE.result, refresh_wait_s: decision.waitS } + : UPDATES_CACHE.result); + } + if (forced) FORCED_UPDATE_AT = Date.now(); + try { + // The account this process runs as, so the panel can print a command that + // works rather than a placeholder. The two machines differ: one built by the + // installer runs as dojobay, one set up by hand may not. + const result = { available: true, + serviceUser: (() => { try { return osMod.userInfo().username; } catch { return null; } })(), + // Absolute, because the remedy the panel prints used to be a relative + // path with nowhere stated to run it. An operator reading "cp + // deploy/polkit-restart.rules.example" has to work out both the directory + // and that .example is the literal filename rather than a placeholder. + ruleSource: path.join(ROOT, "deploy/polkit-restart.rules.example"), + rulePath: "/etc/polkit-1/rules.d/49-dojobay-restart.rules", + ...(await checkUpdates({ cfg: { proxyHost: PROBE_CFG.proxyHost, proxyPort: PROBE_CFG.proxyPort } })) }; + UPDATES_CACHE = { at: Date.now(), result }; + json(res, 200, result); + } catch (e) { + json(res, 200, { available: false, error: e.message }); + } +}); + +// 11) reliability export: the full 24h check series and 90-day rollups in one +// document, optionally filtered to a single node. Not linked anywhere on +// the front end; the raw files also remain at /data/history.json and +// /data/history-daily.json. +route("GET", /^\/api\/history\/export$/, async (req, res) => { + const u = new URL(req.url, "http://x"); + const id = u.searchParams.get("id"); + const dataDir = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"); + const read = async (f, fb) => { try { return JSON.parse(await readFile(path.join(dataDir, f), "utf8")); } catch { return fb; } }; + const hist = await read("history.json", { nodes: {} }); + const daily = await read("history-daily.json", { nodes: {} }); + const ids = id ? [id] : [...new Set([...Object.keys(hist.nodes || {}), ...Object.keys(daily.nodes || {})])].sort(); + const nodes = {}; + for (const k of ids) { + const h = (hist.nodes || {})[k], d = (daily.nodes || {})[k]; + if (!h && !d) continue; + nodes[k] = { checks: (h && h.checks) || [], days: (d && d.days) || [] }; + const retired = (h && h.retired) || (d && d.retired); + if (retired) nodes[k].retired = retired; + } + if (id && !nodes[id]) return json(res, 404, { error: "no history for that id" }); + json(res, 200, { + generated_at: new Date().toISOString(), + interval_minutes: hist.interval_minutes || 10, + window_checks: hist.window_checks || 144, + nodes, + }); +}); + +// 8) delete one of my records +route("POST", /^\/api\/dojo\/delete$/, async (req, res) => { + const s = await sessionFrom(req); + if (!s) return json(res, 401, { error: "not authenticated" }); + let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } + const rec = await store.getSubmission(body.id); + if (!rec || !owns(rec, s.paymentCode)) return json(res, 404, { error: "not found" }); + await store.deleteSubmission(body.id); + json(res, 200, { ok: true }); +}); + +// A card link is only publishable on the operator's verified domain. This is the +// constraint that replaces a freeform URL field: "link to my own site" survives, +// an unverifiable social profile does not. + +// ---- verified operator domains --------------------------------------------- +// An operator proves control of one clearnet domain: the domain names their +// payment code in a TXT record, and they sign a statement naming the domain. +// The badge on their cards, and the card-title link, both depend on it. + +const domainCfg = () => ({ proxyHost: PROBE_CFG.proxyHost, proxyPort: PROBE_CFG.proxyPort }); + +// What the operator has, plus exactly what to publish and sign. Returning the +// instructions from the server keeps them identical to what verification checks. +route("GET", /^\/api\/domain$/, async (req, res) => { + const s = await sessionFrom(req); + if (!s) return json(res, 401, { error: "not authenticated" }); + const claim = await store.getDomain(s.paymentCode); + json(res, 200, { + claim: claim ? { + domain: claim.domain, verified: !!claim.verified, verified_at: claim.verified_at || null, + last_check: claim.last_check || null, last_result: claim.last_result || null, + failing_since: claim.fail_since || null, grace_days: GRACE_DAYS, + } : null, + txt_host: txtHost(), + txt_prefix: txtName(""), + txt_value: txtValue(s.paymentCode), + signing_hint: "Sign under PayNym → Sign message, which uses your PayNym's notification address.", + }); +}); + +// Instructions for a specific domain, so the console can show the exact record +// and text before the operator has signed anything. +route("POST", /^\/api\/domain\/prepare$/, async (req, res) => { + const s = await sessionFrom(req); + if (!s) return json(res, 401, { error: "not authenticated" }); + let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } + const norm = normaliseDomain(body?.domain); + if (!norm.ok) return json(res, 400, { error: norm.error }); + json(res, 200, { + domain: norm.domain, + punycode: !!norm.punycode, + // Two forms on purpose: most panels (Namecheap, Cloudflare, Route 53) want + // the label relative to the zone, a few want the fully-qualified name. + // Handing over only the latter produces _dojobay.example.com.example.com. + txt_host: txtHost(), + txt_name: txtName(norm.domain), + txt_value: txtValue(s.paymentCode), + sign_text: signingText(norm.domain, s.paymentCode), + }); +}); + +route("POST", /^\/api\/domain$/, async (req, res) => { + const s = await sessionFrom(req); + if (!s) return json(res, 401, { error: "not authenticated" }); + let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } + const norm = normaliseDomain(body?.domain); + if (!norm.ok) return json(res, 400, { error: norm.error }); + let signed = String(body?.signed || "").trim(); + if (!signed) return json(res, 400, { error: "paste the signed block" }); + // Same paste hazard as the submission gate: restore the blank line the + // signature covers, when a reconstruction verifies cryptographically. + const repairedClaim = repairSignedBlock(signed); + if (repairedClaim) signed = repairedClaim.block; + + // A domain already verified by a different operator is not fatal (a host may + // run several operators' nodes) but it is worth surfacing to an admin. + const clash = (await store.listDomains()) + .find((c) => c && c.verified && c.domain === norm.domain && c.paymentCode !== s.paymentCode); + + const r = await verifyClaim({ domain: norm.domain, paymentCode: s.paymentCode, signed }, domainCfg()); + const now = new Date().toISOString(); + const prev = await store.getDomain(s.paymentCode); + + // A bad signature is the operator's to fix and nothing is stored. A missing + // TXT record is usually propagation, so the claim is SAVED unverified and the + // sweep keeps looking: the operator does not have to sign again, and an + // unverified claim confers nothing (no badge, and no card link, because + // checkNameUrl requires a verified domain). + if (!r.ok && r.stage === "signature") { + return json(res, 400, { error: r.error, stage: r.stage }); + } + await store.putDomain({ + paymentCode: s.paymentCode, domain: norm.domain, signed, + verified: !!r.ok, + verified_at: r.ok ? now : null, + last_check: r.ok ? now : null, // null so the sweep retries immediately + last_result: r.ok ? "ok" : (r.error || "awaiting the TXT record"), + fail_since: null, + created_at: (prev && prev.created_at) || now, + also_claimed_by: clash ? clash.paymentCode : null, + }); + if (!r.ok) { + const rebuiltPending = await tryRebuild(); + return json(res, 202, { + ok: false, pending: true, domain: norm.domain, + error: r.error, hint: r.hint || null, inconclusive: !!r.inconclusive, + note: "Your signature is verified and saved. We could not see the TXT record yet, " + + "which usually means DNS has not propagated. This is retried automatically; " + + "you do not need to sign again.", + rebuild: rebuiltPending, + }); + } + // A changed domain can invalidate an existing card link, so republish. + const rebuilt = await tryRebuild(); + json(res, 200, { ok: true, domain: norm.domain, resolvers_agreed: r.agreed, rebuild: rebuilt }); +}); + +// Re-check on demand, using the signature already stored. The operator has just +// published a TXT record and wants an answer now rather than at the next sweep; +// they should not have to sign again, and the GET deliberately does not hand +// their signed block back to the browser. +route("POST", /^\/api\/domain\/recheck$/, async (req, res) => { + const s = await sessionFrom(req); + if (!s) return json(res, 401, { error: "not authenticated" }); + const claim = await store.getDomain(s.paymentCode); + if (!claim) return json(res, 404, { error: "no domain claim to re-check" }); + const r = await verifyClaim({ domain: claim.domain, paymentCode: claim.paymentCode, signed: claim.signed }, domainCfg()); + const now = new Date().toISOString(); + const next = { ...claim, last_check: now, + verified: !!r.ok, + verified_at: r.ok ? (claim.verified_at || now) : claim.verified_at, + last_result: r.ok ? "ok" : (r.error || "no matching TXT record"), + fail_since: r.ok ? null : claim.fail_since }; + await store.putDomain(next); + const rebuilt = await tryRebuild(); + json(res, r.ok ? 200 : 202, { + ok: !!r.ok, pending: !r.ok, domain: claim.domain, + error: r.ok ? undefined : r.error, hint: r.ok ? undefined : (r.hint || null), + inconclusive: !!r.inconclusive, rebuild: rebuilt, + }); +}); + +route("DELETE", /^\/api\/domain$/, async (req, res) => { + const s = await sessionFrom(req); + if (!s) return json(res, 401, { error: "not authenticated" }); + await store.deleteDomain(s.paymentCode); + const rebuilt = await tryRebuild(); + json(res, 200, { ok: true, rebuild: rebuilt }); +}); + +// Admin revocation. A badge attests to control, not to trustworthiness, so +// there must be a way to remove one from a lookalike or abusive domain. +route("POST", /^\/api\/admin\/domain\/revoke$/, async (req, res) => { + if (!(await adminFrom(req, res))) return; + let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } + const code = String(body?.paymentCode || ""); + const claim = await store.getDomain(code); + if (!claim) return json(res, 404, { error: "no domain claim for that payment code" }); + await store.putDomain({ ...claim, verified: false, revoked: true, + last_result: "revoked by admin", last_check: new Date().toISOString() }); + const rebuilt = await tryRebuild(); + json(res, 200, { ok: true, rebuild: rebuilt }); +}); + +route("GET", /^\/api\/admin\/domains$/, async (req, res) => { + if (!(await adminFrom(req, res))) return; + const list = (await store.listDomains()).map((c) => ({ + paymentCode: c.paymentCode, domain: c.domain, verified: !!c.verified, revoked: !!c.revoked, + verified_at: c.verified_at || null, last_check: c.last_check || null, + last_result: c.last_result || null, failing_since: c.fail_since || null, + also_claimed_by: c.also_claimed_by || null, + })); + json(res, 200, { domains: list, grace_days: GRACE_DAYS }); +}); + +// Periodic re-check. This lives in the backend rather than the ten-minute +// updater because the store is owned by the backend's user; the updater runs as +// a different user and must not write it. DNS changes slowly, so the sweep is +// daily per claim, and it never lets an unreachable resolver strip a badge. +// Rejected submissions are not kept indefinitely. The window exists so a +// maintainer can undo a mistaken rejection; after it, the operator's payment +// code, pairing payload, apikey and signature are removed. Defaults to the same +// grace period the retired history uses. +const REJECTED_RETENTION_DAYS = +(process.env.REJECTED_RETENTION_DAYS || process.env.HISTORY_GRACE_DAYS || 14); + +async function sweepRejected() { + try { + const gone = await store.pruneRejected(REJECTED_RETENTION_DAYS); + if (gone.length) { + console.log(`[retention] removed ${gone.length} rejected submission(s) older than ` + + `${REJECTED_RETENTION_DAYS} days: ${gone.join(", ")}`); + } + return gone; + } catch (e) { + console.error("[retention] sweep failed:", (e as Error).message); + return []; + } +} + +async function sweepDomains() { + let changed = false; + for (const claim of await store.listDomains()) { + if (claim.revoked || !isDue(claim)) continue; + let result; + try { result = await recheckClaim(claim, domainCfg()); } + catch (e) { result = { ok: false, inconclusive: true, error: e.message }; } + const next = applyRecheck(claim, result); + if (JSON.stringify(next) !== JSON.stringify(claim)) { + await store.putDomain(next); + if (next.verified !== claim.verified) changed = true; + } + } + if (changed) await tryRebuild(); + return changed; +} + +if (process.env.DOMAIN_SWEEP !== "0") { + const every = +(process.env.DOMAIN_SWEEP_MINUTES || 30) * 60 * 1000; + const t = setInterval(() => { + sweepDomains().catch(() => {}); + sweepRejected().catch(() => {}); + }, every); + t.unref?.(); + // Also once at startup, so a long-dead instance does not wait for the first tick. + sweepRejected().catch(() => {}); +} + +// ---- server ---------------------------------------------------------------- +const server = http.createServer(async (req, res) => { + try { + const path = new URL(req.url, "http://x").pathname; + for (const r of routes) { + if (r.method === req.method && r.re.test(path)) return await r.fn(req, res); + } + json(res, 404, { error: "not found" }); + } catch (e) { + json(res, 500, { error: "server error", detail: e.message }); + } +}); +server.listen(PORT, "127.0.0.1", () => console.log(`dojobay backend on 127.0.0.1:${PORT}` + + (CONFIGURED_BASE_URL ? ` (base ${CONFIGURED_BASE_URL})` : " (base: derived per-request from Host)"))); + +export { server, routes }; diff --git a/docker/dojobay/server/package-lock.json b/docker/dojobay/server/package-lock.json new file mode 100644 index 00000000..9014818d --- /dev/null +++ b/docker/dojobay/server/package-lock.json @@ -0,0 +1,260 @@ +{ + "name": "dojobay-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dojobay-server", + "version": "1.0.0", + "dependencies": { + "@bitcoinerlab/secp256k1": "1.2.0", + "@dojo-tools/auth47": "2.0.0", + "@dojo-tools/bip47": "2.0.0", + "@dojo-tools/bitcoinjs-message": "4.0.0" + }, + "devDependencies": { + "bip39": "3.1.0" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@bitcoinerlab/secp256k1": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.2.0.tgz", + "integrity": "sha512-jeujZSzb3JOZfmJYI0ph1PVpCRV5oaexCgy+RvCXV8XlY+XFB/2n3WOcvBsKLsOw78KYgnQrQWb2HrKE4be88Q==", + "license": "MIT", + "dependencies": { + "@noble/curves": "^1.7.0" + } + }, + "node_modules/@dojo-tools/auth47": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@dojo-tools/auth47/-/auth47-2.0.0.tgz", + "integrity": "sha512-uoSG3MGy0TLanBftq49v9MdcFgy4xzCVU2iUUWEpQreFvfeVuxQbaTMGi5g7R0UafLiVVMIFf1rcF6JvvkxHbQ==", + "license": "LGPL-3.0", + "dependencies": { + "@dojo-tools/bip47": "2.0.0", + "@dojo-tools/bitcoinjs-message": "4.0.0" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@dojo-tools/bip47": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@dojo-tools/bip47/-/bip47-2.0.0.tgz", + "integrity": "sha512-0B5nlP/71ArOY8yPOjuJacPPiHPeyZOlI8618VJcfiibZQGugEFV3HIG5Doxo6sSSwU5NrX/96OF6KG4pmzsOA==", + "license": "LGPL-3.0", + "dependencies": { + "@noble/hashes": "2.2.0", + "@scure/base": "^2.0.0", + "bip32": "5.0.1" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@dojo-tools/bitcoinjs-message": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@dojo-tools/bitcoinjs-message/-/bitcoinjs-message-4.0.0.tgz", + "integrity": "sha512-bVK5tsrORzZ3aMgI5kn9YcfAqJzdiObFL/3Pxg8+ouTsXdyDOrBoITpCniemLxMUCanXUWou9LWg7Th5PzFLOg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0", + "@scure/base": "^2.0.0", + "varuint-bitcoin": "^2.0.0" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/base-x": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", + "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", + "license": "MIT" + }, + "node_modules/bip32": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/bip32/-/bip32-5.0.1.tgz", + "integrity": "sha512-PWlHIAgYCfVhwqNpZyeakHXuLAGyN6rEQZnhxHxKI3BoFJRVWLl26455fhRlHsmbYcV986HqtPnt33Edu5sTCw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "@scure/base": "^1.1.1", + "uint8array-tools": "^0.0.8", + "valibot": "^1.2.0", + "wif": "^5.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/bip32/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/bip32/node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/bip39": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.1.0.tgz", + "integrity": "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==", + "dev": true, + "license": "ISC", + "dependencies": { + "@noble/hashes": "^1.2.0" + } + }, + "node_modules/bip39/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "license": "MIT", + "dependencies": { + "base-x": "^5.0.0" + } + }, + "node_modules/bs58check": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz", + "integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^6.0.0" + } + }, + "node_modules/bs58check/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/uint8array-tools": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz", + "integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/valibot": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/varuint-bitcoin": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-2.0.0.tgz", + "integrity": "sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==", + "license": "MIT", + "dependencies": { + "uint8array-tools": "^0.0.8" + } + }, + "node_modules/wif": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/wif/-/wif-5.0.0.tgz", + "integrity": "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA==", + "license": "MIT", + "dependencies": { + "bs58check": "^4.0.0" + } + } + } +} diff --git a/docker/dojobay/server/package.json b/docker/dojobay/server/package.json new file mode 100644 index 00000000..33a6b0c9 --- /dev/null +++ b/docker/dojobay/server/package.json @@ -0,0 +1,24 @@ +{ + "name": "dojobay-server", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Auth47-gated self-service submission API for The Dojo Bay.", + "engines": { + "node": ">=24" + }, + "scripts": { + "start": "node index.mjs", + "build-public": "node build-public.mjs", + "test": "node selftest.mjs" + }, + "dependencies": { + "@dojo-tools/auth47": "2.0.0", + "@dojo-tools/bip47": "2.0.0", + "@dojo-tools/bitcoinjs-message": "4.0.0", + "@bitcoinerlab/secp256k1": "1.2.0" + }, + "devDependencies": { + "bip39": "3.1.0" + } +} diff --git a/docker/dojobay/server/paynym.mjs b/docker/dojobay/server/paynym.mjs new file mode 100644 index 00000000..db0d4381 --- /dev/null +++ b/docker/dojobay/server/paynym.mjs @@ -0,0 +1,95 @@ +// PayNym.rs lookup. paynym.rs runs the same API the historical Samourai server +// exposed, and offers both a clearnet host and a Tor onion. We prefer the onion +// (the box already has a SOCKS proxy for the connection gate, and it keeps the +// lookup inside Tor), falling back to clearnet. +// +// The call is POST {base}/api/v1/nym body {"nym": ""} and the +// response carries the registered nym label. This resolution is ALWAYS +// best-effort: any failure returns null and callers must carry on, because a +// paynym.rs outage must never block a submission or an approval. +import { socks5Connect, PROBE_CFG } from "./probe.mjs"; + +// Override via env if the onion address changes. +const PAYNYM_ONION = process.env.PAYNYM_ONION + || "http://paynym25chftmsywv4v2r67agbrr62lcxagsf4tymbzpeeucucy2ivad.onion"; +const PAYNYM_CLEARNET = process.env.PAYNYM_CLEARNET || "https://paynym.rs"; + +// Pull the human label out of whatever shape the API returns. The legacy API +// nests it under codes[].claimed / nymName; we probe a few known keys so a +// minor schema change degrades to "not found" rather than a wrong value. +function extractNym(obj) { + if (!obj || typeof obj !== "object") return null; + const direct = obj.nymName || obj.nym_name || obj.nym; + if (typeof direct === "string" && direct.length) return direct; + if (Array.isArray(obj.codes) && obj.codes[0] && typeof obj.codes[0].claimed === "string") return obj.codes[0].claimed; + return null; +} + +// Minimal HTTP POST over a SOCKS5 stream (onion), reading the JSON body. +function postOverTor(onionUrl, path, jsonBody, timeoutMs) { + return new Promise(async (resolve) => { + let socket; + try { + const u = new URL(onionUrl); + socket = await socks5Connect(PROBE_CFG.proxyHost, PROBE_CFG.proxyPort, u.hostname, +(u.port || 80), timeoutMs); + } catch { return resolve(null); } + const body = Buffer.from(JSON.stringify(jsonBody), "utf8"); + const host = new URL(onionUrl).hostname; + const req = + `POST ${path} HTTP/1.0\r\nHost: ${host}\r\nContent-Type: application/json\r\n` + + `Content-Length: ${body.length}\r\nConnection: close\r\n\r\n`; + let buf = ""; + const done = (v) => { try { socket.destroy(); } catch {} resolve(v); }; + const timer = setTimeout(() => done(null), timeoutMs); + socket.on("data", (d) => { buf += d.toString("utf8"); }); + socket.on("close", () => { + clearTimeout(timer); + const i = buf.indexOf("\r\n\r\n"); + if (i < 0) return resolve(null); + try { resolve(JSON.parse(buf.slice(i + 4))); } catch { resolve(null); } + }); + socket.on("error", () => done(null)); + socket.write(req + body.toString("utf8")); + }); +} + +async function postClearnet(base, paymentCode, timeoutMs) { + if (typeof fetch !== "function") return null; + try { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), timeoutMs); + const r = await fetch(base + "/api/v1/nym", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nym: paymentCode }), + signal: ctrl.signal, + }); + clearTimeout(t); + if (!r.ok) return null; + return await r.json(); + } catch { return null; } +} + +// Fetch the raw nym document (codes[], nymName, ...) for a handle or payment +// code, Tor first. Returns the parsed object or null. Never throws. +export async function fetchNymInfo(nymOrCode, { timeoutMs = 20000, preferTor = true } = {}) { + if (!nymOrCode) return null; + let obj = null; + if (preferTor) obj = await postOverTor(PAYNYM_ONION, "/api/v1/nym", { nym: nymOrCode }, timeoutMs); + if (!obj) obj = await postClearnet(PAYNYM_CLEARNET, nymOrCode, timeoutMs); + return obj && typeof obj === "object" ? obj : null; +} + +// Every BIP47 code variant registered for a PayNym (segwit + legacy), because +// the wallet may sign Auth47 with either. [] when unresolvable. +export async function fetchNymCodes(nymOrCode, opts) { + const info = await fetchNymInfo(nymOrCode, opts); + return Array.isArray(info?.codes) ? info.codes.filter((c) => c && typeof c.code === "string") : []; +} + +// Resolve a payment code to its registered PayNym label, or null. Never throws. +export async function resolvePayNym(paymentCode, opts) { + const name = extractNym(await fetchNymInfo(paymentCode, opts)); + if (!name) return null; + return name.startsWith("+") ? name : "+" + name; +} diff --git a/docker/dojobay/server/probe.mjs b/docker/dojobay/server/probe.mjs new file mode 100644 index 00000000..7e2afc41 --- /dev/null +++ b/docker/dojobay/server/probe.mjs @@ -0,0 +1,10 @@ +// On-demand Tor reachability check, reusing the exact probe from the updater so +// the self-service "connection gate" and the cron checker agree. +export { probe, socks5Connect } from "../scripts/update.mjs"; + +export const PROBE_CFG = { + proxyHost: process.env.TOR_SOCKS_HOST || "127.0.0.1", + proxyPort: +(process.env.TOR_SOCKS_PORT || 9050), + timeoutMs: +(process.env.TIMEOUT_MS || 30000), + connectOnly: process.env.CONNECT_ONLY === "1", +}; diff --git a/docker/dojobay/server/remove-listing.ts b/docker/dojobay/server/remove-listing.ts new file mode 100644 index 00000000..62c5da14 --- /dev/null +++ b/docker/dojobay/server/remove-listing.ts @@ -0,0 +1,129 @@ +#!/usr/bin/env node +// ============================================================================= +// The Dojo Bay — remove a listing and its reliability history. +// +// Deleting a record through the API leaves its history behind: unlisting stamps +// it `retired` and keeps it for HISTORY_GRACE_DAYS so that a node relisted +// within the window resurrects its uptime intact. That is right for a node +// coming back, and wrong for one being removed deliberately. This removes both, +// now. +// +// Usage, on the box: +// cd /var/www/dojobay/server +// node remove-listing.ts # dry run +// sudo systemctl stop dojobay-server.service +// node remove-listing.ts --apply +// sudo systemctl start dojobay-server.service +// node build-public.mjs +// +// As with the other write tools, --apply refuses to run while the service is +// up, because store.ts holds the store in memory as a single writer and would +// overwrite the edit. Both files are backed up first. +// ============================================================================= +import { readFile, writeFile, rename, copyFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { StoreRecord } from "../types.js"; + +const argv = process.argv.slice(2); +const APPLY = argv.includes("--apply"); +const FORCE = argv.includes("--force"); +const IDS = argv.filter((a) => !a.startsWith("--")); + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const STORE_DIR = process.env.SERVER_DATA_DIR || path.join(HERE, "data"); +const PUBLIC_DIR = process.env.PUBLIC_DATA_DIR || path.join(HERE, "..", "data"); +const STORE = path.join(STORE_DIR, "store.json"); +const HISTORY = ["history.json", "history-daily.json"].map((f) => path.join(PUBLIC_DIR, f)); + +if (!IDS.length) { + console.error("Usage: node remove-listing.ts [--apply] …\n" + + "Record ids are shown by audit-signed.mjs, for example mainnet-kilombino."); + process.exit(2); +} + +if (APPLY && !FORCE) { + let active = ""; + try { active = execFileSync("systemctl", ["is-active", "dojobay-server.service"], { encoding: "utf8" }).trim(); } + catch (e: any) { active = (e.stdout || "").trim(); } + if (active === "active") { + console.error("REFUSING: dojobay-server.service is running.\n" + + "The store is held in memory by the server and would overwrite this edit.\n" + + " sudo systemctl stop dojobay-server.service\n" + + " node remove-listing.ts --apply \n" + + " sudo systemctl start dojobay-server.service"); + process.exit(2); + } +} + +const readJSON = async (p: string, fallback: any) => { + try { return JSON.parse(await readFile(p, "utf8")); } catch { return fallback; } +}; +// A temporary name no other writer can take; see build-public.ts. The counter +// matters as well as the pid, because one run rewrites both the store and the +// seed in quick succession. +let tmpSeq = 0; +const writeAtomic = async (p: string, doc: any) => { + const tmp = `${p}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`; + await writeFile(tmp, JSON.stringify(doc, null, 2) + "\n"); + await rename(tmp, p); +}; + +const store = await readJSON(STORE, { submissions: {} }); +const found: StoreRecord[] = []; +const missing: string[] = []; +for (const id of IDS) { + const rec = store.submissions?.[id]; + if (rec) found.push(rec); else missing.push(id); +} + +console.log(`Store: ${STORE}`); +console.log(`History: ${PUBLIC_DIR}\n`); + +for (const rec of found) { + const codes = (rec.paymentCodes || []).length; + console.log(` ${rec.id} (${rec.status})`); + console.log(` name ${rec.name || "(none)"}`); + console.log(` onion ${rec.payload?.pairing?.url || "(none)"}`); + console.log(` codes ${codes || "NONE — this listing has no owner"}`); +} +for (const id of missing) console.log(` ${id}: not in the store`); +console.log(""); + +let histCounts: Record = {}; +for (const f of HISTORY) { + const doc = await readJSON(f, { nodes: {} }); + histCounts[path.basename(f)] = IDS.filter((id) => doc.nodes && doc.nodes[id]).length; +} +console.log("History entries to remove: " + + Object.entries(histCounts).map(([f, n]) => `${f}: ${n}`).join(", ") + "\n"); + +if (!found.length && !Object.values(histCounts).some(Boolean)) { + console.log("Nothing to remove."); process.exit(missing.length ? 1 : 0); +} + +if (!APPLY) { + console.log("DRY RUN — nothing written. Re-run with --apply (service stopped) to remove."); + process.exit(0); +} + +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); +await copyFile(STORE, `${STORE}.bak-${stamp}`); +for (const rec of found) delete store.submissions[rec.id]; +await writeAtomic(STORE, store); +console.log(`Backup written: ${STORE}.bak-${stamp}`); + +for (const f of HISTORY) { + const doc = await readJSON(f, null); + if (!doc || !doc.nodes) continue; + let touched = false; + for (const id of IDS) if (doc.nodes[id]) { delete doc.nodes[id]; touched = true; } + if (!touched) continue; + await copyFile(f, `${f}.bak-${stamp}`); + await writeAtomic(f, doc); + console.log(`Purged history from ${path.basename(f)} (backup alongside).`); +} + +console.log(`\nRemoved ${found.length} listing(s). Start the service, then run\n` + + "build-public.mjs to republish, and audit-signed.mjs to confirm the result."); diff --git a/docker/dojobay/server/selftest.mjs b/docker/dojobay/server/selftest.mjs new file mode 100644 index 00000000..3e0a01d3 --- /dev/null +++ b/docker/dojobay/server/selftest.mjs @@ -0,0 +1,2027 @@ +#!/usr/bin/env node +// Offline end-to-end test of the backend. Spins a mock SOCKS proxy (so the +// connection gate passes without real Tor), simulates a wallet signing the +// Auth47 challenge and the pairing payload, and drives the HTTP API. +import net from "node:net"; +import assert from "node:assert"; +import { BIP47Factory } from "@dojo-tools/bip47"; +import { bitcoinMessageFactory } from "@dojo-tools/bitcoinjs-message"; +import * as bip47utils from "@dojo-tools/bip47/utils"; +import ecc from "@bitcoinerlab/secp256k1"; +import { mnemonicToSeedSync } from "bip39"; +import os from "node:os"; +import pathMod from "node:path"; + +// point the backend at a temp store + mock proxy BEFORE importing it +process.env.SERVER_DATA_DIR = "/tmp/dojobay-selftest"; +process.env.BASE_URL = "http://exampledojobayonion.onion"; +process.env.PORT = "0"; +process.env.TOR_SOCKS_PORT = "19077"; +// isolate the public data dir so admin approve's rebuild() never writes live data +process.env.PUBLIC_DATA_DIR = "/tmp/dojobay-selftest-data"; +// make the simulated wallet's payment code an admin so /admin routes are testable +process.env.ADMIN_PAYMENT_CODES = BIP47Factory(ecc) + .fromSeed(mnemonicToSeedSync("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")) + .toPaymentCodePublic().toBase58(); +await import("node:fs/promises").then(async (m) => { + await m.rm(process.env.SERVER_DATA_DIR, { recursive: true, force: true }); + await m.rm(process.env.PUBLIC_DATA_DIR, { recursive: true, force: true }); + await m.mkdir(process.env.PUBLIC_DATA_DIR, { recursive: true }); + try { await m.copyFile(new URL("../data/seed.json", import.meta.url), process.env.PUBLIC_DATA_DIR + "/seed.json"); } + catch { await m.writeFile(process.env.PUBLIC_DATA_DIR + "/seed.json", JSON.stringify({ nodes: [] })); } +}); + +// always-up mock SOCKS5 proxy that plays the Dojo API (login + wallet tip), +// so the authenticated connection gate passes without real Tor. +const proxy = net.createServer((s) => { + let st = "g"; + s.on("data", (d) => { + if (st === "g") { s.write(Buffer.from([5, 0])); st = "c"; return; } + if (st === "c") { s.write(Buffer.from([5, 0, 0, 1, 0, 0, 0, 0, 0, 0])); st = "t"; return; } + const req = d.toString("latin1"); + let body; + if (req.includes("/auth/login")) body = JSON.stringify({ authorizations: { access_token: "tok" } }); + else if (req.includes("/wallet")) body = JSON.stringify({ info: { latest_block: { height: 900000, time: 1 } } }); + else { s.write("HTTP/1.0 404 x\r\n\r\n"); s.end(); return; } + s.write(`HTTP/1.0 200 OK\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`); + s.end(); + }); + s.on("error", () => {}); +}); +await new Promise((r) => proxy.listen(19077, "127.0.0.1", () => r(null))); + +// The suite drives the server module itself. index.mjs is a launcher whose only +// job is to refuse an old Node before importing this; it is checked separately +// below rather than run here, so the suite is not gated on the host's version. +const { server } = await import("./index.ts"); +await new Promise((r) => (server.listening ? r() : server.on("listening", r))); +const base = "http://127.0.0.1:" + /** @type {import("node:net").AddressInfo} */ (server.address()).port; + +// --- simulated wallet --- +const bip47 = BIP47Factory(ecc), msg = bitcoinMessageFactory(ecc), net47 = bip47utils.networks.bitcoin; +const acct = bip47.fromSeed(mnemonicToSeedSync("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")); +const paymentCode = acct.toPaymentCodePublic().toBase58(); +const priv = acct.getNotificationPrivateKey(); +const notifAddr = acct.toPaymentCodePublic().getNotificationAddress(); + +let cookie = ""; +async function api(path, method = "GET", body) { + const res = await fetch(base + path, { + method, + headers: { "Content-Type": "application/json", ...(cookie ? { Cookie: cookie } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }); + const sc = res.headers.get("set-cookie"); + if (sc) cookie = sc.split(";")[0]; + const txt = await res.text(); + return { status: res.status, body: txt ? JSON.parse(txt) : null }; +} + +let passed = 0; +const ok = (c, label) => { assert.ok(c, label); passed++; console.log(" ok -", label); }; + +console.log("backend self-test"); + +// 1) login: challenge -> sign -> callback -> poll -> cookie +const ch = await api("/api/auth47/challenge", "POST", {}); +ok(ch.status === 200 && ch.body.uri.startsWith("auth47://"), "challenge issued"); +const signedChallenge = (() => { const u = new URL(ch.body.uri); u.searchParams.delete("c"); return decodeURIComponent(u.toString()); })(); +const proofSig = Buffer.from(msg.sign(signedChallenge, priv, true, net47.messagePrefix)).toString("base64"); +const cb = await api("/api/auth47/callback", "POST", { auth47_response: "1.0", challenge: signedChallenge, signature: proofSig, nym: paymentCode }); +ok(cb.status === 200, "wallet proof accepted"); +const poll = await api("/api/auth47/poll?nonce=" + ch.body.nonce); +ok(poll.status === 200 && poll.body.authenticated, "poll sets session"); +const me = await api("/api/me"); +ok(me.body.authenticated && me.body.paymentCode === paymentCode, "session bound to payment code"); + +// 2) wrong-signer proof is rejected +{ + const ch2 = await api("/api/auth47/challenge", "POST", {}); + const sc2 = (() => { const u = new URL(ch2.body.uri); u.searchParams.delete("c"); return decodeURIComponent(u.toString()); })(); + const bad = bip47.fromSeed(mnemonicToSeedSync("legal winner thank year wave sausage worth useful legal winner thank yellow")); + const badSig = Buffer.from(msg.sign(sc2, bad.getNotificationPrivateKey(), true, net47.messagePrefix)).toString("base64"); + const r = await api("/api/auth47/callback", "POST", { auth47_response: "1.0", challenge: sc2, signature: badSig, nym: paymentCode }); + ok(r.status === 401, "mismatched signature rejected at login"); +} + +// 2b) THE RELAY. A proof is only evidence of what it was signed over, and the +// library can check that the challenge's r parameter is a well-formed URL +// but not that it is OURS. Without the binding, an attacker takes a live +// nonce from this instance, shows a victim the same challenge with r +// rewritten to their own site, and relays the signed result back here: the +// victim's wallet displays the attacker's site, the signature verifies, and +// a session is minted here in the victim's name. +{ + const ch3 = await api("/api/auth47/challenge", "POST", {}); + const relayed = (() => { + const u = new URL(ch3.body.uri); + u.searchParams.delete("c"); + u.searchParams.set("r", "http://attacker7777777777777777777777777777777777777777777.onion"); + return decodeURIComponent(u.toString()); + })(); + // Genuinely signed by the real operator, over the attacker's resource. This + // is the whole point: the signature is valid and the nonce is live. + const sig = Buffer.from(msg.sign(relayed, priv, true, net47.messagePrefix)).toString("base64"); + const r = await api("/api/auth47/callback", "POST", + { auth47_response: "1.0", challenge: relayed, signature: sig, nym: paymentCode }); + ok(r.status === 401 && /different site/.test(r.body.error || ""), + "a validly signed proof naming another site is refused: " + JSON.stringify(r.body.error)); + const p3 = await api("/api/auth47/poll?nonce=" + ch3.body.nonce); + ok(!p3.body.authenticated, "and no session is waiting to be collected with that nonce"); +} + +// 2c) the binding tolerates the differences that are not differences, and only +// those. A trailing slash and host case are the same site; a different +// origin or a path underneath it is not. +{ + const { makeAuth47 } = await import("./crypto.ts"); + const a47 = makeAuth47(process.env.BASE_URL); + const mk = async (resource) => { + const ch4 = await api("/api/auth47/challenge", "POST", {}); + const u = new URL(ch4.body.uri); + u.searchParams.delete("c"); + u.searchParams.set("r", resource); + const c = decodeURIComponent(u.toString()); + const sg = Buffer.from(msg.sign(c, priv, true, net47.messagePrefix)).toString("base64"); + return api("/api/auth47/callback", "POST", + { auth47_response: "1.0", challenge: c, signature: sg, nym: paymentCode }); + }; + ok((await mk(process.env.BASE_URL + "/")).status === 200, + "a trailing slash is the same site"); + ok((await mk(process.env.BASE_URL + "/somewhere")).status === 401, + "a path underneath it is not"); + ok((await mk(process.env.BASE_URL.replace("http://", "https://"))).status === 401, + "nor is the same host on another scheme"); + + // and the shape itself: a caller who forgets the expectation gets a refusal + // rather than a silent pass, which is what made the original bug invisible. + const unbound = a47.verify({ auth47_response: "1.0", challenge: "auth47://x?r=http://y.onion", + signature: "AA==", nym: paymentCode }); + ok(!unbound.ok && /expected resource/.test(unbound.error), + "verify refuses outright when no expected resource is given: " + JSON.stringify(unbound.error)); +} + +// 3) submit a Dojo with a valid signed payload -> passes both gates -> pending +const payload = { + pairing: { type: "dojo.api", version: "1.28.0", apikey: "deadbeef", url: "http://ebtnuwk5qayotlk7brszskn2zbtzu54y24s6lmojt6j4cv7uaiwlsyad.onion/v2" }, + explorer: { type: "explorer.btc_rpc_explorer", url: "http://eaa3qxan44q2rksr23nferh5ntxsqcdcdkjmotlyo7h56widf4y3yiqd.onion" }, +}; +const canonical = JSON.stringify({ pairing: payload.pairing, explorer: payload.explorer }); +// Real wallet exports sign the pairing JSON PLUS the BIP47 line and code (the +// full text between the markers, no trailing newline), verified against a +// genuine Samourai export. Construct blocks exactly that way. +const signedTextOf = (json, code) => `${json}\n\nBIP47:\n${code}`; +const blockOf = (msgText, addr, sig) => + `-----BEGIN BITCOIN SIGNED MESSAGE-----\n${msgText}\n-----BEGIN BITCOIN SIGNATURE-----\nVersion: Bitcoin-qt (1.0)\nAddress: ${addr}\n\n${sig}\n-----END BITCOIN SIGNATURE-----`; +const signedText = signedTextOf(canonical, paymentCode); +const sigLine = Buffer.from(msg.sign(signedText, priv, true, net47.messagePrefix)).toString("base64"); +const signedBlock = blockOf(signedText, notifAddr, sigLine); +// Any payload can be signed the same way. Pairing edits need a signature over +// the NEW details, so the suite must be able to produce one on demand rather +// than reusing the block that covers the payload being replaced. +const signBlockFor = (p) => { + const text = signedTextOf(JSON.stringify({ pairing: p.pairing, explorer: p.explorer }), paymentCode); + return blockOf(text, notifAddr, Buffer.from(msg.sign(text, priv, true, net47.messagePrefix)).toString("base64")); +}; +const create = await api("/api/dojo", "POST", { network: "mainnet", name: "selftest-node", jurisdiction: "Europe", hardware: "N100 16GB", payload, signed: signedBlock }); +ok(create.status === 200 && create.body.submission.status === "pending", "valid submission accepted, pending review"); + +// 4) signature gate failure modes, each with its own distinct error. +{ + const badSigned = signedBlock.replace(notifAddr, "1BitcoinEaterAddressDontSendf59kuE"); + const r = await api("/api/dojo", "POST", { network: "mainnet", name: "selftest-node", payload, signed: badSigned }); + ok(r.status === 400 && /signature gate/.test(r.body.error), "wrong-address signed payload rejected"); + + const { verifySignedPayload } = await import("./crypto.ts"); + // Regression for the truncated-message bug: a signature covering ONLY the + // pairing JSON (the old, wrong assumption) presented in a block that prints + // the BIP47 lines must be refused, because the wallet signs the full text. + const jsonOnlySig = Buffer.from(msg.sign(canonical, priv, true, net47.messagePrefix)).toString("base64"); + const oldStyle = verifySignedPayload({ signedText: blockOf(signedText, notifAddr, jsonOnlySig), expectedMessage: canonical, expectedAddress: notifAddr }); + const corrupted = verifySignedPayload({ signedText: signedBlock.replace(sigLine, sigLine.replace(/^./, (c) => c === "H" ? "I" : "H")), expectedMessage: canonical, expectedAddress: notifAddr }); + ok(!oldStyle.ok && oldStyle.error === "invalid signature" && !corrupted.ok && /invalid signature|could not be verified/.test(corrupted.error), + "invalid signatures (truncated-coverage and corrupted) report 'invalid signature'"); + + // Valid signature, but the BIP47 code inside the signed text does not derive + // the signing address: sign a text carrying a DIFFERENT (valid) code. + const other = bip47.fromSeed(mnemonicToSeedSync("legal winner thank year wave sausage worth useful legal winner thank yellow")); + const otherCode = other.toBase58(); + const mixedText = signedTextOf(canonical, otherCode); + const mixedSig = Buffer.from(msg.sign(mixedText, priv, true, net47.messagePrefix)).toString("base64"); + const mixed = verifySignedPayload({ signedText: blockOf(mixedText, notifAddr, mixedSig), expectedMessage: canonical, expectedAddress: notifAddr }); + ok(!mixed.ok && /valid, but the signing address is not the notification address/.test(mixed.error), + "valid signature over a mismatched payment code reports the derivation failure, not 'invalid signature'"); + + // Valid signature, garbage where the payment code should be. + const junkText = signedTextOf(canonical, "PM8TJnotacode"); + const junkSig = Buffer.from(msg.sign(junkText, priv, true, net47.messagePrefix)).toString("base64"); + const junk = verifySignedPayload({ signedText: blockOf(junkText, notifAddr, junkSig), expectedMessage: canonical, expectedAddress: notifAddr }); + ok(!junk.ok && /not a valid payment code/.test(junk.error), + "valid signature over an undecodable BIP47 line reports the invalid code"); + + // Ground truth: a GENUINE wallet export (the maxtannahill node; the apikey + // is public). This pins the real signed-text format independently of the + // blocks this suite constructs for itself, which is exactly how the + // truncated-message bug evaded the previous version of these tests. + const realBlock = `-----BEGIN BITCOIN SIGNED MESSAGE----- +{"pairing":{"type":"dojo.api","version":"1.27.0","apikey":"jaf8fQuGD3QBWLjso6BqU4GEFZ8rW77hXGJfpXNq","url":"http://rwijn27ypfktrhsyrfnob66sjdgpyw6cvlk3ijzyzpj6w36emyk5x5ad.onion/v2"},"explorer":{"type":"explorer.btc_rpc_explorer","url":"http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion"}} + +BIP47: +PM8TJfHaHuh5xgKoEbrkWaBtytb8qrRNYdmHzxiFcvacD6HpyyxvSV3VLKYsr6UvMxB4jvJP4xxNvCp2pRY3cJPNmLB2L8nYEttaFVszXSBjXNMy8cD9 +-----BEGIN BITCOIN SIGNATURE----- +Version: Bitcoin-qt (1.0) +Address: 1HmVAPcz3hyETMnu4UzgJTw1mmrNcJKVB + +H6BZzINZjJQz6LVJIduOpAtXrJUt61dNlnmEf5P6DSmUUOO78YmVOc8bg5biESMFUckk1oAJ/CP9/JLqipPb0fM= +-----END BITCOIN SIGNATURE-----`; + const { parseSignedBlock, notificationAddress: notifOf } = await import("./crypto.ts"); + const rp = parseSignedBlock(realBlock); + const real = verifySignedPayload({ signedText: realBlock, expectedMessage: rp.pairingText, expectedAddress: notifOf(rp.paymentCode) }); + ok(real.ok && rp.message === rp.pairingText + "\n\nBIP47:\n" + rp.paymentCode + && notifOf(rp.paymentCode) === rp.address, + "a genuine wallet export verifies: the signature covers json + BIP47 line + code"); +} + +// 5) connection gate: point the probe at a proxy that reports the onion down. +{ + const down = net.createServer((s) => { + let st = "g"; + s.on("data", () => { + if (st === "g") { s.write(Buffer.from([5, 0])); st = "c"; return; } + s.write(Buffer.from([5, 4, 0, 1, 0, 0, 0, 0, 0, 0])); s.end(); // 0x04 host unreachable + }); + s.on("error", () => {}); + }); + await new Promise((r) => down.listen(19078, "127.0.0.1", () => r(null))); + const { PROBE_CFG } = await import("./probe.mjs"); + PROBE_CFG.proxyPort = 19078; // live object, mutated in place + // mainnet with a fresh name, rather than testnet to dodge the name conflict: + // the shared fixture payload is a mainnet endpoint, and listing it as testnet + // is now refused by the payload validator before the connection gate is + // reached, which would make this test pass for the wrong reason. The signature + // covers the payload and not the name, so renaming costs nothing. + const r = await api("/api/dojo", "POST", { network: "mainnet", name: "unreachable-node", payload, signed: signedBlock }); + ok(r.status === 422 && /connection gate/.test(r.body.error), "unreachable node rejected by connection gate"); + PROBE_CFG.proxyPort = 19077; // restore the up proxy + down.close(); +} + +// 6) admin moderation via the /admin API + publish +const anon = await fetch(base + "/api/admin/submissions"); // no cookie +ok(anon.status === 401, "admin route rejects anonymous"); +const alist = await api("/api/admin/submissions"); +ok(alist.status === 200 && alist.body.admin === true && alist.body.submissions.some((s) => s.status === "pending"), + "admin can list pending submissions"); +const pendId = alist.body.submissions.find((s) => s.status === "pending").id; +const appr = await api("/api/admin/approve", "POST", { id: pendId, paynym: "+testoperator" }); +ok(appr.status === 200 && appr.body.ok && appr.body.rebuild.nodes >= 1, "admin approve publishes"); +const fsp = await import("node:fs/promises"); +const pub = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/dojos.json", "utf8")); +ok(pub.nodes.some((n) => n.paynym === "+testoperator"), "approved submission appears in public dojos.json"); +// ---- new-schema checks (paymentCodes[], operator names, migration) --------- + +// 7) multi-code ownership: a PayNym commonly has two BIP47 code variants and +// the wallet may sign Auth47 with either, so a record must match on +// membership of its paymentCodes array, not equality with one code. +{ + const { store } = await import("./store.ts"); // same instance the server uses + const rec = await store.getSubmission("mainnet-selftest-node"); + const legacyVariant = "PMlegacyVariantOfTheSameNym"; + rec.paymentCodes.push(legacyVariant); + await store.putSubmission(rec); + const viaPrimary = await store.submissionsFor(paymentCode); + const viaLegacy = await store.submissionsFor(legacyVariant); + ok(viaPrimary.some((r) => r.id === "mainnet-selftest-node") + && viaLegacy.some((r) => r.id === "mainnet-selftest-node"), + "both payment-code variants match the same record"); + const meAgain = await api("/api/me"); + ok(meAgain.body.submissions.some((r) => r.id === "mainnet-selftest-node"), + "/api/me still lists the record after the second code is added"); +} + +// 8) name uniqueness: another operator may not take a name that is in use. +{ + const jarB = { cookie: "" }; + const apiB = async (path, method = "GET", body) => { + const res = await fetch(base + path, { + method, + headers: { "Content-Type": "application/json", ...(jarB.cookie ? { Cookie: jarB.cookie } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }); + const sc = res.headers.get("set-cookie"); + if (sc) jarB.cookie = sc.split(";")[0]; + const txt = await res.text(); + return { status: res.status, body: txt ? JSON.parse(txt) : null }; + }; + const acctB = bip47.fromSeed(mnemonicToSeedSync("legal winner thank year wave sausage worth useful legal winner thank yellow")); + const chB = await apiB("/api/auth47/challenge", "POST", {}); + const scB = (() => { const u = new URL(chB.body.uri); u.searchParams.delete("c"); return decodeURIComponent(u.toString()); })(); + const sigB = Buffer.from(msg.sign(scB, acctB.getNotificationPrivateKey(), true, net47.messagePrefix)).toString("base64"); + await apiB("/api/auth47/callback", "POST", { auth47_response: "1.0", challenge: scB, signature: sigB, nym: acctB.toPaymentCodePublic().toBase58() }); + await apiB("/api/auth47/poll?nonce=" + chB.body.nonce); + const ncB = await apiB("/api/dojo/name-check?network=mainnet&name=Selftest%20Node"); + ok(ncB.status === 200 && ncB.body.available === false, "name-check reports a taken name (case/punctuation-insensitive)"); + const dup = await apiB("/api/dojo", "POST", { network: "mainnet", name: "selftest-node", payload, signed: signedBlock }); + ok(dup.status === 409, "duplicate name from another operator rejected with 409"); + const ncOwner = await api("/api/dojo/name-check?network=mainnet&name=selftest-node"); + ok(ncOwner.status === 200 && ncOwner.body.available === true && ncOwner.body.update === true, + "owner's own name reads as available (an update, keeping the record id)"); +} + +// 9) manage-panel ordering: /api/me returns mainnet before testnet, then +// alphabetical by name. +{ + const { store } = await import("./store.ts"); + /** + * @param {"mainnet"|"testnet"} network + * @param {string} name + * @returns {import("../types.js").StoreRecord} + */ + const stub = (network, name) => ({ + id: `${network}-${name}`, network, name, paymentCodes: [paymentCode], + paynym: null, payload: { pairing: { type: "dojo.api", url: "http://" + "a".repeat(56) + ".onion/v2" } }, + signed: signedBlock, + status: "pending", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", + }); + await store.putSubmission(stub("testnet", "alpha")); + await store.putSubmission(stub("mainnet", "zulu")); + const meOrd = await api("/api/me"); + const order = meOrd.body.submissions.map((r) => r.name); + ok(JSON.stringify(order) === JSON.stringify(["selftest-node", "zulu", "alpha"]), + "submissions ordered mainnet-then-testnet, then by name (" + order.join(", ") + ")"); +} + +// 10) migration script: dry-run prints its plan (including the code-less +// adoption warning) and writes nothing; a real run creates owned records +// and adopts code-less ones as admin-managed exceptions; seed.json is +// never rewritten; a second run skips everything (byte-identical store). +{ + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const run = promisify(execFile); + const MIG_DATA = "/tmp/dojobay-selftest-mig-data"; + const MIG_STORE = "/tmp/dojobay-selftest-mig-store"; + await fsp.rm(MIG_DATA, { recursive: true, force: true }); + await fsp.rm(MIG_STORE, { recursive: true, force: true }); + await fsp.mkdir(MIG_DATA, { recursive: true }); + const fixturePayload = { pairing: { type: "dojo.api", url: "http://" + "b".repeat(56) + ".onion/v2" } }; + await fsp.writeFile(MIG_DATA + "/seed.json", JSON.stringify({ nodes: [ + { id: "mainnet-fam-one", network: "mainnet", name: "Fam One", paynym: "+fam", payload: fixturePayload, signed: signedBlock }, + { id: "mainnet-fam-two", network: "mainnet", name: "Fam Two", paynym: "+fam", payload: fixturePayload, signed: signedBlock }, + { id: "testnet-keeper", network: "testnet", name: "wanderinKeeper", paynym: null, payload: fixturePayload }, + { id: "mainnet-fam-mute", network: "mainnet", name: "Fam Mute", paynym: "+fam", payload: fixturePayload }, + ] }, null, 2)); + await fsp.writeFile(MIG_DATA + "/paynym-codes.json", JSON.stringify({ mapping: { + "+fam": { nymName: "+fam", codes: [{ code: "PMfamSegwit", segwit: true }, { code: "PMfamLegacy", segwit: false }] }, + } }, null, 2)); + const env = { ...process.env, PUBLIC_DATA_DIR: MIG_DATA, SERVER_DATA_DIR: MIG_STORE }; + const script = new URL("../scripts/migrate-seed-to-store.mjs", import.meta.url).pathname; + const seedBefore = await fsp.readFile(MIG_DATA + "/seed.json", "utf8"); + + const dry = await run(process.execPath, [script, "--dry-run"], { env }); + const storeAbsent = await fsp.access(MIG_STORE + "/store.json").then(() => false, () => true); + ok(/create\s+mainnet-fam-one\s+name=one/.test(dry.stdout) + && /refuse\s+testnet-keeper\s+name=wanderinKeeper/.test(dry.stdout) + && /REFUSED: testnet-keeper no BIP47 payment code/.test(dry.stdout) + && storeAbsent, + "migration --dry-run: family prefix stripped, a code-less node refused, nothing written"); + ok(/refuse\s+mainnet-fam-mute/.test(dry.stdout) + && /REFUSED: mainnet-fam-mute no signed pairing block/.test(dry.stdout) + && /2 refused: testnet-keeper, mainnet-fam-mute/.test(dry.stdout), + "and an owned node with no signed pairing block is refused too, and counted in the summary"); + + await run(process.execPath, [script], { env }); + const store1 = await fsp.readFile(MIG_STORE + "/store.json", "utf8"); + const migrated = JSON.parse(store1).submissions; + const seedAfter = await fsp.readFile(MIG_DATA + "/seed.json", "utf8"); + ok(migrated["mainnet-fam-one"].status === "approved" + && migrated["mainnet-fam-one"].paymentCodes.length === 2 + && migrated["mainnet-fam-one"].source === "seed-migration" + && !migrated["testnet-keeper"] + && !migrated["mainnet-fam-mute"] + && seedAfter === seedBefore, + "migration creates owned records, never writes a code-less or unsigned one, never rewrites seed.json"); + + const second = await run(process.execPath, [script], { env }); + const store2 = await fsp.readFile(MIG_STORE + "/store.json", "utf8"); + ok(/nothing to do/.test(second.stdout) && /skip\s+mainnet-fam-one/.test(second.stdout) && store2 === store1, + "second migration run skips existing ids (store byte-identical)"); + await fsp.rm(MIG_DATA, { recursive: true, force: true }); + await fsp.rm(MIG_STORE, { recursive: true, force: true }); +} + +// 11) a moderation change whose publish (rebuild) fails must report the +// failure to the admin, not swallow it: this is how an approved node +// silently never reached the public dojos.json. +{ + const goodDir = process.env.PUBLIC_DATA_DIR; + process.env.PUBLIC_DATA_DIR = "/dev/null/not-a-directory"; // rebuild will throw + const rej = await api("/api/admin/reject", "POST", { id: "mainnet-selftest-node" }); + ok(rej.status === 200 && rej.body.ok && rej.body.rebuild && rej.body.rebuild.error, + "moderation succeeds but a failed publish is reported (rebuild.error)"); + process.env.PUBLIC_DATA_DIR = goodDir; + const reAppr = await api("/api/admin/approve", "POST", { id: "mainnet-selftest-node", paynym: "+testoperator" }); + ok(reAppr.status === 200 && reAppr.body.rebuild && !reAppr.body.rebuild.error, "publish succeeds again once writable"); +} + +// 12) updater reconciliation: an approved node deleted from dojos.json (the +// approve-mid-probe-cycle clobber) is restored by reconcilePublicList(), +// which the updater now runs at the start of every cycle. +{ + const dojosPath = process.env.PUBLIC_DATA_DIR + "/dojos.json"; + const doc = JSON.parse(await fsp.readFile(dojosPath, "utf8")); + doc.nodes = doc.nodes.filter((n) => n.id !== "mainnet-selftest-node"); + await fsp.writeFile(dojosPath, JSON.stringify(doc, null, 2) + "\n"); + const { reconcilePublicList } = await import("../scripts/update.mjs"); + await reconcilePublicList(); + const healed = JSON.parse(await fsp.readFile(dojosPath, "utf8")); + ok(healed.nodes.some((n) => n.id === "mainnet-selftest-node"), + "reconcile restores an approved node clobbered out of dojos.json"); +} + +// 13) history grace period: delisting a node stamps its history `retired` +// instead of deleting it; relisting within the window clears the stamp +// with the data intact; only a long-expired retiree is deleted. +{ + const histPath = process.env.PUBLIC_DATA_DIR + "/history.json"; + const marker = [{ t: "2026-07-14 00:00", up: true }]; + const doc = JSON.parse(await fsp.readFile(histPath, "utf8")); + doc.nodes["mainnet-selftest-node"] = { checks: marker.slice() }; + doc.nodes["mainnet-long-gone"] = { checks: marker.slice(), retired: "2026-06-01T00:00:00Z" }; + await fsp.writeFile(histPath, JSON.stringify(doc, null, 2) + "\n"); + + const rej = await api("/api/admin/reject", "POST", { id: "mainnet-selftest-node" }); // delists + rebuilds + const afterRej = JSON.parse(await fsp.readFile(histPath, "utf8")).nodes; + ok(rej.status === 200 && afterRej["mainnet-selftest-node"] + && afterRej["mainnet-selftest-node"].retired + && JSON.stringify(afterRej["mainnet-selftest-node"].checks) === JSON.stringify(marker), + "delisted node's history is retired (stamped), not deleted"); + ok(!afterRej["mainnet-long-gone"], "history retired beyond the grace window is deleted"); + + await api("/api/admin/approve", "POST", { id: "mainnet-selftest-node", paynym: "+testoperator" }); // relists + rebuilds + const afterAppr = JSON.parse(await fsp.readFile(histPath, "utf8")).nodes["mainnet-selftest-node"]; + ok(afterAppr && !afterAppr.retired + && JSON.stringify(afterAppr.checks) === JSON.stringify(marker), + "relisting within the grace window resurrects the history untouched"); +} + +// 14) display-field edits: owner can amend name and hardware; the id, status +// and history are untouched; renames respect per-network uniqueness. The +// Dojo version is NOT editable: a version sent in the edit is ignored and +// the card keeps the API-derived value (here the pairing default, since no +// live probe has run in this test). +{ + const ed = await api("/api/dojo/edit", "POST", { id: "mainnet-selftest-node", name: "selftest-node", hardware: "RPi5 8GB", version: "9.9.9-test" }); + const rec = await api("/api/me").then((r) => r.body.submissions.find((x) => x.id === "mainnet-selftest-node")); + ok(ed.status === 200 && rec.hardware === "RPi5 8GB" && rec.version == null && rec.status === "approved", + "owner edit updates hardware, keeps id and approved status, and cannot set a version"); + const pub = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/dojos.json", "utf8")); + const pubNode = pub.nodes.find((n) => n.id === "mainnet-selftest-node"); + ok(pubNode && pubNode.version === "1.28.0" && pubNode.paymentCode === rec.paymentCodes[0], + "approved edit publishes immediately; card version stays the API-derived value, ignoring the edit"); + + const clashOwn = await api("/api/dojo/edit", "POST", { id: "mainnet-selftest-node", name: "zulu" }); + const clashSeed = await api("/api/dojo/edit", "POST", { id: "mainnet-selftest-node", name: "Maxtannahill" }); + ok(clashOwn.status === 409 && clashSeed.status === 409, + "renames rejected when colliding with own other record or the anchor seed node"); + + const anon = await fetch(base + "/api/dojo/edit", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: "mainnet-selftest-node", name: "x" }) }); + const admEd = await api("/api/admin/edit", "POST", { id: "testnet-alpha", name: "alpha", hardware: "edited-by-admin" }); + const stub = await api("/api/me").then((r) => r.body.submissions.find((x) => x.id === "testnet-alpha")); + ok(anon.status === 401 && admEd.status === 200 && stub.hardware === "edited-by-admin", + "anonymous edit rejected; admin can edit any record via /api/admin/edit"); +} + +// 15) the card shows the PayNym's canonical (non-segwit) code variant when the +// mapping identifies it, falling back to the record's first code. +{ + const { displayPaymentCode } = await import("./build-public.ts"); + const sub = { paynym: "+max", paymentCodes: ["PMsegwitVariant", "PMlegacyVariant"] }; + const mapping = { "+max": { codes: [{ code: "PMsegwitVariant", segwit: true }, { code: "PMlegacyVariant", segwit: false }] } }; + ok(displayPaymentCode(sub, mapping) === "PMlegacyVariant" + && displayPaymentCode(sub, {}) === "PMsegwitVariant" + && displayPaymentCode({ paymentCodes: [] }, mapping) === null, + "display code prefers the non-segwit variant, falls back to the first, null when none"); +} + +// 16) intake hygiene: pasted CRLF/zero-width bytes are stripped from signed +// blocks before verification; export endpoint merges both history windows. +// +// The card link is gone. It let an operator point the card title anywhere +// they had proven they controlled, which meant one listing could carry two +// claims of identity: the verified domain badge and a title link. One is +// enough, and it is the one with a TXT record behind it. +{ + // signed cleaning: resubmit the check-3 record with a clipboard-mangled + // signed block (CRLF + zero-width space); it must still pass the signature + // gate and be STORED byte-clean. + const mangled = signedBlock.replace(/\n/g, "\r\n") + "\u200b"; + const resub = await api("/api/dojo", "POST", { network: "mainnet", name: "selftest-node", jurisdiction: "Europe", hardware: "N100 16GB", payload, signed: mangled }); + const rec = await api("/api/me").then((r) => r.body.submissions.find((x) => x.id === "mainnet-selftest-node")); + ok(resub.status === 200 && rec.signed === signedBlock && !rec.signed.includes("\r"), + "CRLF/zero-width paste artefacts stripped before verification; stored block byte-clean"); + + // restore approved status (resubmission re-enters moderation) + await api("/api/admin/approve", "POST", { id: "mainnet-selftest-node", paynym: "+testoperator" }); + + // A verified domain is still granted here, because the checks below and the + // published badge depend on one. Granted directly in the store, since the API + // path needs DNS. + const { store: st } = await import("./store.ts"); + await st.putDomain({ paymentCode, domain: "example.org", signed: "(test)", + verified: true, verified_at: new Date().toISOString(), last_check: new Date().toISOString(), + last_result: "ok", fail_since: null, created_at: new Date().toISOString() }); + + // The card link is not merely unused, it is unreachable: a request carrying + // one is accepted and the field ignored, rather than silently stored where a + // future rebuild might publish it again. + const withUrl = await api("/api/dojo/edit", "POST", + { id: "mainnet-selftest-node", name: "selftest-node", name_url: "https://example.org/mynode" }); + const after = await api("/api/me").then((r) => r.body.submissions.find((x) => x.id === "mainnet-selftest-node")); + const pub16 = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/dojos.json", "utf8")) + .nodes.find((n) => n.id === "mainnet-selftest-node"); + ok(withUrl.status === 200 && !after.name_url, + "an edit carrying a card link succeeds and stores nothing for it"); + ok(!("name_url" in pub16) && pub16.operator_domain === "example.org", + "and the published node has no link field at all, only the verified domain"); + + // export endpoint: both windows merged, per-node filter, 404 on unknown + const all = await api("/api/history/export"); + const one = await api("/api/history/export?id=mainnet-selftest-node"); + const none = await api("/api/history/export?id=no-such-node"); + ok(all.status === 200 && all.body.nodes["mainnet-selftest-node"] + && Array.isArray(one.body.nodes["mainnet-selftest-node"].checks) + && Array.isArray(one.body.nodes["mainnet-selftest-node"].days) + && Object.keys(one.body.nodes).length === 1 && none.status === 404, + "history export merges 24h checks and daily rollups, filters by id, 404s unknown ids"); +} + +// 17) update check: commits behind main, and which RELEASE we are running. +// "Releases behind" used to count releases published after the local build +// timestamp, so an instance running the exact commit of the newest release +// always reported itself one behind — a tag is always created after the +// commit it points at was built. It now resolves tags to commits. +{ + const { checkUpdates } = await import("./updates.mjs"); + const releases = [ + { tag_name: "v0.2", published_at: "2026-06-01T00:00:00Z" }, + { tag_name: "v0.1", published_at: "2025-12-01T00:00:00Z" }, + ]; + const tags = [ + { name: "v0.2", commit: { sha: "abc1234def5678900000000000000000000000a" } }, + { name: "v0.1", commit: { sha: "0000000000000000000000000000000000000b" } }, + ]; + const transportFor = (withTags) => async (apiPath) => { + if (apiPath.startsWith("/repos/Dojobay/dojobay/compare/")) + return { status: 200, body: JSON.stringify({ status: "behind", ahead_by: 4, behind_by: 0 }) }; + if (apiPath.startsWith("/repos/Dojobay/dojobay/releases")) + return { status: 200, body: JSON.stringify(releases) }; + if (apiPath.startsWith("/repos/Dojobay/dojobay/tags")) + return withTags ? { status: 200, body: JSON.stringify(tags) } : { status: 500, body: "{}" }; + return { status: 404, body: "{}" }; + }; + const setVersion = (commit, built) => fsp.writeFile(process.env.PUBLIC_DATA_DIR + "/version.json", + JSON.stringify({ commit, built })); + + // running the exact commit of the newest release, tagged AFTER we built it + await setVersion("abc1234", "2026-01-01T00:00:00Z"); + const onLatest = await checkUpdates({ transport: /** @type {any} */ (transportFor(true)) }); + ok(onLatest.releases_behind === 0 && onLatest.current_release === "v0.2" + && onLatest.releases_behind_approx === false, + "running the newest release's commit reports zero behind, however late the tag was created"); + + // an untagged commit mid-cycle: no identity match, so the timestamp guess, + // flagged as approximate rather than presented as fact + await setVersion("deadbee", "2026-01-01T00:00:00Z"); + const midCycle = await checkUpdates({ transport: /** @type {any} */ (transportFor(true)) }); + ok(midCycle.releases_behind === 1 && midCycle.current_release === null + && midCycle.releases_behind_approx === true, + "an untagged commit falls back to the timestamp count and says it is approximate"); + + // The tags call failing must not break the check, and must not invent a + // number either: the timestamp guess is systematically wrong for the + // commonest case, an instance running the very newest release. + await setVersion("abc1234", "2026-01-01T00:00:00Z"); + const noTags = await checkUpdates({ transport: /** @type {any} */ (transportFor(false)) }); + ok(noTags.releases_behind === null && noTags.releases_behind_approx === true + && /tag lookup/.test(noTags.releases_note || ""), + "an unavailable tags endpoint reports unknown, with the reason, rather than a guess"); + + await setVersion("abc1234", "2026-01-01T00:00:00Z"); + const u = await checkUpdates({ transport: /** @type {any} */ (transportFor(true)) }); + ok(u.commits_behind === 4 && u.latest_release === "v0.2" && u.commit === "abc1234", + "update check still reports commits behind main and the latest release"); + + const anon = await fetch(base + "/api/admin/updates"); + const admin = await api("/api/admin/updates"); + ok(anon.status === 401 && admin.status === 200 && admin.body.available === false && admin.body.error, + "updates route: anonymous 401; unreachable GitHub reported in-band to the admin"); + + // A rate-limited exit is the commonest way this check fails and the least + // like a fault: GitHub allows sixty unauthenticated requests an hour per IP, + // and a Tor exit is one address shared with everyone using it. An operator + // told "HTTP 403" goes looking for a broken instance. + const { githubRefusal } = await import("./updates.mjs"); + for (const code of [403, 429]) { + const msg = githubRefusal("compare", code); + ok(/rate-limit/i.test(msg) && /exit/.test(msg) && msg.includes(String(code)), + `HTTP ${code} is explained as a rate-limited exit, with the status still in it`); + } + ok(/peer/i.test(githubRefusal("compare", 403)), + "and points at the route that does not touch GitHub"); + ok(githubRefusal("compare", 500) === "compare: HTTP 500", + "while anything else is reported as what it was, with no story attached"); + // No call-site prefix on the rate-limit message. Which of the three requests + // hit the limit tells an operator nothing, and "compare: GitHub is + // rate-limiting..." reads as though compare were the thing that failed. + ok(!/^compare:/.test(githubRefusal("compare", 403)), + "and the rate-limit message does not open with the name of the call that hit it"); + + // The import routes. Their argument checking and their refusal to run two at + // once are testable here; the fetch itself needs another instance over Tor, + // which this suite has no way to provide, so what is asserted is everything + // that happens before the first byte leaves the machine. + const anonImport = await fetch(base + "/api/admin/import", { method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ onion: "x" }) }); + ok(anonImport.status === 401, "import route refuses anyone who is not an admin"); + + const badOnion = await api("/api/admin/import", "POST", { onion: "not-an-onion", code: paymentCode }); + ok(badOnion.status === 400 && /\.onion/.test(badOnion.body.error || ""), + "and refuses an address that is not a 56-character onion"); + + // Without the peer's payment code there is nothing for the operator binding + // to be checked against, so the import would establish only that the remote + // signed something, not that it is the instance the operator chose to trust. + const noCode = await api("/api/admin/import", "POST", { onion: "a".repeat(56) + ".onion" }); + ok(noCode.status === 400 && /payment code/.test(noCode.body.error || ""), + "and refuses without the payment code of the instance being imported from"); + + // A real start, which will fail at the fetch because that onion does not + // exist. What matters is that it is a job rather than a held-open request, + // and that the failure is reported in-band rather than as a dead panel. + const started = await api("/api/admin/import", "POST", + { onion: "b".repeat(56) + ".onion", code: paymentCode }); + ok(started.status === 202 && started.body.started === true && started.body.apply === false, + "an import starts as a background job, and defaults to planning rather than writing"); + + // Two imports must not run at once, and a second request is refused while the + // first is unfinished. That is deliberately NOT asserted by firing a second + // request and expecting 409: whether the first is still running by then + // depends on how quickly the fetch fails, which is a property of the machine + // rather than of this code. On a box with Tor it hangs for seconds; in CI + // there is no Tor at all, the connection is refused on the next turn of the + // loop, and a second import is then correctly ACCEPTED because nothing is + // running. The first version of this test asserted 409 unconditionally and + // failed in CI for exactly that reason. + // + // What is asserted instead is the rule itself, which is monotonic and does + // not depend on timing: the guard is on the job being unfinished, and the + // job is created before the work starts so there is no window in which two + // could begin. Weaker than a behavioural test, and said so here rather than + // dressed up as one. + const idx = await fsp.readFile(new URL("./index.ts", import.meta.url), "utf8"); + const importRoute = idx.slice(idx.indexOf('route("POST", /^\\/api\\/admin\\/import$/'), + idx.indexOf('route("GET", /^\\/api\\/admin\\/import\\/status$/')); + ok(/IMPORT_JOB && !IMPORT_JOB\.done\) return json\(res, 409/.test(importRoute), + "a second import while one is running is refused rather than interleaved"); + ok(importRoute.indexOf("IMPORT_JOB = {") < importRoute.indexOf("bootstrapImport({"), + "and the job exists before the work starts, so there is no window in which two could begin"); + + for (let i = 0; i < 60; i++) { + const st = await api("/api/admin/import/status"); + if (st.body.job && st.body.job.done) break; + await new Promise((r) => setTimeout(r, 500)); + } + const fin = await api("/api/admin/import/status"); + ok(fin.status === 200 && fin.body.job && fin.body.job.done === true && fin.body.job.ok === false + && typeof fin.body.job.error === "string", + "an unreachable peer ends the job with a reason rather than leaving it running"); + + // The cache rule the "Check again" button depends on. Tested here rather + // than through the route, because the route only fills its cache on a + // successful check and GitHub is unreachable in this suite, so the cached + // path is never taken and the floor would never run. A rule that cannot be + // exercised is a rule nobody has checked. + const { updateCacheDecision } = await import("./updates.mjs"); + const now = 1_000_000_000_000; + const hour = 3600 * 1000; + + ok(updateCacheDecision({ cachedAt: null, now }).serveCached === false, + "no cached answer means the check goes out"); + ok(updateCacheDecision({ cachedAt: now - 5 * hour, now }).serveCached === true, + "an ordinary request inside six hours is answered from the cache"); + ok(updateCacheDecision({ cachedAt: now - 7 * hour, now }).serveCached === false, + "and outside six hours it is not"); + + // The button's whole purpose: an operator who pushed while already signed in + // gets a real check rather than an answer from before their push. + ok(updateCacheDecision({ cachedAt: now - 5 * hour, now, forced: true, forcedAt: 0 }).serveCached === false, + "a forced check bypasses a cache that is still fresh"); + ok(updateCacheDecision({ cachedAt: now - 5 * hour, now, forced: true, forcedAt: now - 90 * 1000 }).serveCached === false, + "and again once the floor has passed"); + + // And the floor, which is what stops that button hammering GitHub through an + // exit node shared with every other Tor user. Refusing outright would tell + // the operator nothing, so the last known answer comes back with the wait. + const held = updateCacheDecision({ cachedAt: now - 5 * hour, now, forced: true, forcedAt: now - 20 * 1000 }); + ok(held.serveCached === true && held.waitS === 40, + "inside the floor the cached answer comes back with the seconds remaining: " + held.waitS); + ok(updateCacheDecision({ cachedAt: now - 5 * hour, now, forced: false, forcedAt: now - 20 * 1000 }).waitS === 0, + "and an unforced request is never told to wait, since it asked for nothing"); +} + +// 18) operator binding + bootstrap import: the binding verifies a real +// wallet signature over "onion + BIP47 line"; the import refuses an +// instance whose binding fails or whose code differs from the one the +// operator trusted, and otherwise imports nodes (skipping existing ids) +// with full code variants and carried histories. +{ + const onionHost = "b".repeat(56) + ".onion"; + const opCode = paymentCode; // the test wallet from the Auth47 checks + const opMessage = `http://${onionHost}/\n\nBIP47: ${opCode}`; + const opSig = Buffer.from(msg.sign(opMessage, acct.getNotificationPrivateKey(), true, net47.messagePrefix)).toString("base64"); + const opBlock = `-----BEGIN BITCOIN SIGNED MESSAGE-----\n${opMessage}\n-----BEGIN BITCOIN SIGNATURE-----\nAddress: ${notifAddr}\n\n${opSig}\n-----END BITCOIN SIGNATURE-----`; + const opDoc = { onion: `http://${onionHost}/`, paymentCode: opCode, verifySigned: opBlock }; + + const { verifyOperatorDoc, notificationAddresses } = await import("./crypto.ts"); + const vOk = verifyOperatorDoc(opDoc, { expectedOnion: `http://${onionHost}` }); + const vWrongOnion = verifyOperatorDoc(opDoc, { expectedOnion: "http://" + "c".repeat(56) + ".onion" }); + const vTampered = verifyOperatorDoc({ ...opDoc, verifySigned: opBlock.replace(onionHost, "c".repeat(56) + ".onion") }); + ok(vOk.ok && !vWrongOnion.ok && !vTampered.ok, + "operator binding: valid signature accepted; wrong onion and tampered message refused"); + + // The same payment code signed from a TESTNET wallet. + // + // A PayNym is a mainnet identity, but a wallet in testnet mode derives the + // notification address for that network, so the same code signs from a + // different address. Requiring the mainnet form refused perfectly good + // bindings from anyone running a testnet wallet. + const tnetAddr = notificationAddresses(opCode).find((a) => a !== notifAddr); + ok(tnetAddr && tnetAddr !== notifAddr, "the code derives a second, testnet address: " + tnetAddr); + const tnetBlock = opBlock.replace(`Address: ${notifAddr}`, `Address: ${tnetAddr}`); + const vTestnet = verifyOperatorDoc({ ...opDoc, verifySigned: tnetBlock }, { expectedOnion: `http://${onionHost}` }); + ok(vTestnet.ok && vTestnet.address === tnetAddr, + "operator binding accepts a testnet-derived signing address: " + JSON.stringify(vTestnet.error || "")); + + // but an address that is neither derivation is still refused, and the error + // names both so an operator can see which their wallet actually used + const strayAddr = notificationAddresses(bip47.fromSeed(mnemonicToSeedSync( + "legal winner thank year wave sausage worth useful legal winner thank yellow")).toBase58())[0]; + const vStray = verifyOperatorDoc({ ...opDoc, verifySigned: opBlock.replace(`Address: ${notifAddr}`, `Address: ${strayAddr}`) }, + { expectedOnion: `http://${onionHost}` }); + ok(!vStray.ok && /on mainnet, or/.test(vStray.error) && vStray.error.includes(notifAddr), + "an unrelated signing address is refused, naming both addresses the code could have used"); + + // A terminal that swallows the newline after the BEGIN marker must not break + // an otherwise valid binding: that newline is not part of the signed text. + const eaten = opBlock.replace("MESSAGE-----\n", "MESSAGE-----"); + ok(verifyOperatorDoc({ ...opDoc, verifySigned: eaten }, { expectedOnion: `http://${onionHost}` }).ok, + "operator binding survives a paste that lost the newline after the BEGIN marker"); + + // A truncated paste must say so rather than blaming the wallet, and a + // signature from the wrong account must name both addresses. + const truncated = verifyOperatorDoc({ ...opDoc, verifySigned: opBlock.split("\n").slice(0, 3).join("\n") }); + const otherAcct = bip47.fromSeed(mnemonicToSeedSync("legal winner thank year wave sausage worth useful legal winner thank yellow")); + const wrongSig = Buffer.from(msg.sign(opMessage, otherAcct.getNotificationPrivateKey(), true, net47.messagePrefix)).toString("base64"); + const wrongSigner = verifyOperatorDoc({ ...opDoc, + verifySigned: `-----BEGIN BITCOIN SIGNED MESSAGE-----\n${opMessage}\n-----BEGIN BITCOIN SIGNATURE-----\nAddress: ${otherAcct.getNotificationAddress()}\n\n${wrongSig}\n-----END BITCOIN SIGNATURE-----` }); + ok(/truncated/.test(truncated.error) + && wrongSigner.error.includes(otherAcct.getNotificationAddress()) && wrongSigner.error.includes(notifAddr), + "truncated paste and wrong-signer errors are diagnosable (names what is missing / both addresses)"); + + // A real, portable domain proof: signed over "https://example.org/" + blank + // line + the BIP47 line, exactly as the site produces it. + const urlClaimText = `https://example.org/\n\nBIP47: ${paymentCode}`; + const signedUrlBlock = `-----BEGIN BITCOIN SIGNED MESSAGE-----\n${urlClaimText}\n` + + `-----BEGIN BITCOIN SIGNATURE-----\nVersion: Bitcoin-qt (1.0)\nAddress: ${notifAddr}\n\n` + + `${Buffer.from(msg.sign(urlClaimText, priv, true, net47.messagePrefix)).toString("base64")}\n` + + `-----END BITCOIN SIGNATURE-----`; + const remoteNodes = { + nodes: [ + { id: "mainnet-selftest-node", network: "mainnet", name: "selftest-node", + payload: { pairing: { type: "dojo.api", url: "http://" + "d".repeat(56) + ".onion/v2", apikey: "k" } }, + operator_domain: "example.org", + operator_domain_proof: { domain: "example.org", paymentCode, txt_name: "_dojobay.example.org", + txt_value: `dojobay-domain-v1 pm=${paymentCode}`, signed: signedUrlBlock, verified_at: "2026-07-01T00:00:00Z" } }, + // Signed over ITS OWN payload. It used to carry the block covering a + // different node's pairing details and imported cleanly, because nothing + // verified the signature: the source instance's word was the only thing + // vouching for it. + { id: "mainnet-imported", network: "mainnet", name: "imported", paynym: "+imp", + paymentCode: "PMimpDisplay", + signed: signBlockFor({ pairing: { type: "dojo.api", url: "http://" + "e".repeat(56) + ".onion/v2", apikey: "k" } }), + payload: { pairing: { type: "dojo.api", url: "http://" + "e".repeat(56) + ".onion/v2", apikey: "k" } }, + // a forged proof: the signature does not check out against the code + operator_domain: "evil.example", + operator_domain_proof: { domain: "evil.example", paymentCode: "PM8T" + "9".repeat(112), + txt_name: "_dojobay.evil.example", txt_value: "dojobay-domain-v1 pm=PM8T" + "9".repeat(112), + signed: signedUrlBlock, verified_at: "2026-07-01T00:00:00Z" } }, + // published by an instance that does not enforce the signature rule, or + // from before it existed. It must not enter this store. + { id: "mainnet-hearsay", network: "mainnet", name: "hearsay", paynym: "+imp", + paymentCode: "PMimpDisplay", + payload: { pairing: { type: "dojo.api", url: "http://" + "f".repeat(56) + ".onion/v2", apikey: "k" } } }, + // A well-formed block that covers somebody else's payload. This is what a + // careless or compromised directory publishes, and what taking the + // source's word for a signature would let through. + { id: "mainnet-forged", network: "mainnet", name: "forged", paynym: "+imp", + paymentCode: "PMimpDisplay", signed: signedBlock, + payload: { pairing: { type: "dojo.api", url: "http://" + "g".repeat(56) + ".onion/v2", apikey: "k" } } }, + ], + }; + const remoteDocs = { + "/data/operator.json": opDoc, + "/data/dojos.json": remoteNodes, // proofs are attached to its nodes below + "/data/history.json": { interval_minutes: 10, window_checks: 144, nodes: { + "mainnet-imported": { checks: [{ t: "2026-07-01 00:00", up: true }] }, + "mainnet-selftest-node": { checks: [{ t: "2026-07-01 00:00", up: false }] }, + } }, + "/data/history-daily.json": { nodes: { "mainnet-imported": { days: [{ d: "2026-07-01", pct: 99, close: 1 }] } } }, + }; + const { bootstrapImport } = await import("../scripts/bootstrap-import.mjs"); + const { store } = await import("./store.ts"); + const fetchDoc = async (p) => { if (!(p in remoteDocs)) throw new Error("404 " + p); return remoteDocs[p]; }; + const fetchCodes = async () => [{ code: "PMimpSegwit", segwit: true }, { code: "PMimpLegacy", segwit: false }]; + + await ok(await bootstrapImport({ + onionHost, trustedCode: "PM8T" + "2".repeat(112), fetchDoc, fetchCodes, dataDir: process.env.PUBLIC_DATA_DIR, log: () => {}, + }).then(() => false, (e) => /DIFFERENT payment code/.test(e.message)), + "bootstrap refuses an instance operated by a different code than the one trusted"); + + // clear any claim an earlier check left behind, so the import starts clean + await store.deleteDomain(paymentCode); + const r = await bootstrapImport({ onionHost, trustedCode: opCode, fetchDoc, fetchCodes, dataDir: process.env.PUBLIC_DATA_DIR, log: () => {} }); + const imp = await store.getSubmission("mainnet-imported"); + const untouched = await store.getSubmission("mainnet-selftest-node"); + const histAfter = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/history.json", "utf8")).nodes; + ok(!(await store.getSubmission("mainnet-hearsay")), + "an unsigned node published by the remote instance is refused rather than imported"); + // The block is well-formed and genuinely signed; it just covers a different + // node's payload. hasSignedBlock cannot tell the difference and neither can + // putSubmission, so before the signature was verified here this imported + // cleanly on the source instance's word alone. + ok(!(await store.getSubmission("mainnet-forged")), + "a well-formed block over somebody else's payload is refused: signatures are verified here, not taken on trust"); + + // An import into a RUNNING instance arrives pending, so it lands in the + // moderation queue the operator already uses. At install approved is right, + // because choosing to bootstrap is the decision to trust that list wholesale; + // in a running instance a listing that appeared on the site without passing + // the queue would be another directory publishing here. + { + const url = "http://" + "h".repeat(56) + ".onion/v2"; + const one = { pairing: { type: "dojo.api", url, apikey: "k" } }; + const docs = { + "/data/operator.json": opDoc, + "/data/dojos.json": { nodes: [{ id: "mainnet-queued", network: "mainnet", name: "queued", + paynym: "+imp", paymentCode: "PMimpDisplay", signed: signBlockFor(one), payload: one }] }, + }; + const r2 = await bootstrapImport({ onionHost, trustedCode: opCode, status: "pending", + fetchDoc: async (pth) => { if (!(pth in docs)) throw new Error("404 " + pth); return docs[pth]; }, + fetchCodes, dataDir: process.env.PUBLIC_DATA_DIR, log: () => {} }); + const queued = await store.getSubmission("mainnet-queued"); + ok(r2.imported === 1 && queued && queued.status === "pending", + "an import can arrive pending, for a running instance with a moderation queue: " + (queued && queued.status)); + ok(r2.plan && r2.plan.some((row) => row.id === "mainnet-queued" && row.action === "import" && row.url === url), + "and the plan comes back as data, so a console can render it rather than parse log lines"); + await store.deleteSubmission("mainnet-queued"); + } + + // The route asks for that, rather than inheriting the installer's default. + { + const idx = await fsp.readFile(new URL("./index.ts", import.meta.url), "utf8"); + const block = idx.slice(idx.indexOf('route("POST", /^\\/api\\/admin\\/import$/'), + idx.indexOf('route("GET", /^\\/api\\/admin\\/import\\/status$/')); + ok(block.length > 200 && /status: "pending"/.test(block), + "the admin import route asks for pending records rather than taking the installer's default"); + + // A pending record's live status comes from pending-probe.json, which only + // the update cycle writes. Until one runs, an imported listing has no + // status and the moderation queue shows it as inactive, which is not what + // it is: nothing has asked it yet, and the moderator deciding whether to + // approve is the person who needs the answer. + const job = idx.slice(idx.indexOf('route("POST", /^\\/api\\/admin\\/import$/'), + idx.indexOf('route("GET", /^\\/api\\/admin\\/import\\/status$/')); + ok(/"scripts", "update.mjs"/.test(job) && /job\.phase = "probing"/.test(job), + "an applied import runs a probe cycle, as the installer does before declaring success"); + ok(job.indexOf("tryRebuild()") < job.indexOf('job.phase = "probing"'), + "after the rebuild, so the cycle sees the records it is about to probe"); + ok(/is-active", "--quiet", "dojobay-update\.service"/.test(job), + "and skips it when a cycle is already running, since there is no lock and two would race"); + ok(/\(job\.result\?\.imported \?\? 0\) > 0/.test(job), + "nothing is probed when nothing was imported"); + ok(/dryRun: !job\.apply/.test(block), + "and plans unless the operator explicitly asked to apply"); + } + ok(r.imported === 1 && imp && imp.status === "approved" + && imp.paymentCodes.includes("PMimpSegwit") && imp.paymentCodes.includes("PMimpLegacy") && imp.paymentCodes.includes("PMimpDisplay") + && imp.source === `bootstrap-import:${onionHost}` + && untouched && !String(untouched.source || "").startsWith("bootstrap") + && histAfter["mainnet-imported"] && histAfter["mainnet-imported"].checks.length === 1 + && histAfter["mainnet-selftest-node"].checks[0].t !== "2026-07-01 00:00", + "bootstrap imports new nodes with all code variants and history; existing ids untouched"); + + // The duplicate an operator actually hits. Bootstrapping a new instance from a + // directory that already lists your own node used to create a second record + // for it, because each instance derives an id from the name it was given and + // the two differ. The anchor is in seed.json rather than the store, so it was + // invisible to the existing-id check: the operator's own node was the one + // guaranteed to duplicate. + { + const anchorUrl = "http://" + "d".repeat(56) + ".onion/v2"; + const seedPath = process.env.PUBLIC_DATA_DIR + "/seed.json"; + const seedDoc = JSON.parse(await fsp.readFile(seedPath, "utf8")); + const keptSeed = JSON.stringify(seedDoc); + seedDoc.nodes[0].payload = { pairing: { type: "dojo.api", apikey: "k", url: anchorUrl } }; + const anchorId = seedDoc.nodes[0].id; + await fsp.writeFile(seedPath, JSON.stringify(seedDoc, null, 2) + "\n"); + + // the same machine, published by the remote instance under its own id + // Cast: the checker infers a literal shape from the fixtures these replace, + // and the import reads them as plain documents. + remoteDocs["/data/dojos.json"] = /** @type {any} */ ({ nodes: [{ + id: "mainnet-their-name-for-it", network: "mainnet", name: "their name for it", + paynym: "+imp", paymentCode: "PMimpDisplay", signed: signedBlock, + payload: { pairing: { type: "dojo.api", apikey: "k", url: anchorUrl.toUpperCase() + "/" } }, + }] }); + remoteDocs["/data/history.json"] = /** @type {any} */ ({ nodes: { "mainnet-their-name-for-it": { checks: [{ t: "2026-07-02 00:00", up: true }] } } }); + remoteDocs["/data/history-daily.json"] = /** @type {any} */ ({ nodes: {} }); + + const dup = await bootstrapImport({ onionHost, trustedCode: opCode, fetchDoc, fetchCodes, + dataDir: process.env.PUBLIC_DATA_DIR, log: () => {} }); + ok(dup.imported === 0 && dup.merged === 1, + "the operator's own node arrives as a merge rather than a second listing"); + ok(!(await store.getSubmission("mainnet-their-name-for-it")), + "and no record is created for it under the other instance's id"); + // Upper-cased and with a trailing slash in the fixture, because neither + // changes which endpoint is meant and both are the sort of difference that + // would defeat a naive string compare. + const h = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/history.json", "utf8")).nodes; + ok(h[anchorId] && h[anchorId].checks.some((c) => c.t === "2026-07-02 00:00"), + "its history is carried onto the id this instance uses, so months of uptime survive"); + + await fsp.writeFile(seedPath, keptSeed + "\n"); + } + + // A verified domain travels with the data, because the signed statement names + // the domain and the code but never the instance that verified it. It must NOT + // arrive verified: importing a badge on another instance's word would let one + // compromised directory mint verified domains across a federation. + const claimed = await store.getDomain(paymentCode); + ok(claimed && claimed.domain === "example.org" && claimed.signed === signedUrlBlock, + "a domain claim published by the source is carried across intact"); + ok(claimed.verified === false && claimed.last_check === null + && /awaiting our own DNS/.test(claimed.last_result || ""), + "and arrives UNVERIFIED, so this instance must see the TXT record itself"); + + // a proof whose signature does not check out is refused outright + ok(r.domains_imported === 1 && r.domains_refused === 1, + "a proof with a bad signature is refused rather than imported: " + JSON.stringify({ i: r.domains_imported, x: r.domains_refused })); + ok((await store.getDomain("PM8T" + "9".repeat(112))) === null, + "and nothing is stored for it"); + + await store.deleteDomain(paymentCode); +} + +// 19) self-update sourcing: GitHub and peer fetchers verify before trusting, +// apply() stages a real archive, and the admin routes are gated. +{ + const { fetchFromPeer, applyUpdate } = await import("./self-update.mjs"); + const { packSource } = await import("../scripts/pack-source.mjs"); + + // build a real archive to feed the peer fetcher's zip step + const tmp = await fsp.mkdtemp(pathMod.join(os.tmpdir(), "dojobay-su-")); + const packed = await packSource({ outDir: tmp }); + const zipBytes = await fsp.readFile(packed.out); + + // a valid peer operator binding (reuse the operator doc from check 18 shape) + const peerOnion = "f".repeat(56) + ".onion"; + const peerMsg = `http://${peerOnion}/\n\nBIP47: ${paymentCode}`; + const peerSig = Buffer.from(msg.sign(peerMsg, priv, true, net47.messagePrefix)).toString("base64"); + const peerBlock = `-----BEGIN BITCOIN SIGNED MESSAGE-----\n${peerMsg}\n-----BEGIN BITCOIN SIGNATURE-----\nAddress: ${notifAddr}\n\n${peerSig}\n-----END BITCOIN SIGNATURE-----`; + const peerOpDoc = { onion: `http://${peerOnion}/`, paymentCode, verifySigned: peerBlock }; + const fetchDoc = async (p) => { + if (p === "/data/operator.json") return { status: 200, body: JSON.stringify(peerOpDoc) }; + if (p === "/data/version.json") return { status: 200, body: JSON.stringify({ commit: "peercommit" }) }; + throw new Error("404 " + p); + }; + const fetchZip = async () => ({ status: 200, bodyBuf: zipBytes }); + + // wrong trusted code -> refuse before fetching the zip + await ok(await fetchFromPeer({ onionHost: peerOnion, trustedCode: "PM8T" + "3".repeat(112), fetchDoc, fetchZip, log: () => {} }) + .then(() => false, (e) => /different payment code/.test(e.message)), + "peer update refuses a peer whose operator code differs from the trusted one"); + + // correct code -> returns verified bytes, which apply() stages + const got = await fetchFromPeer({ onionHost: peerOnion, trustedCode: paymentCode, fetchDoc, fetchZip, log: () => {} }); + // A web root with code in it, because applyUpdate now refuses to replace a + // tree it could not back up, and an empty directory is not a tree anyone + // updates. Instance data alongside, so the backup filter is exercised rather + // than assumed. + const webRoot = await fsp.mkdtemp(pathMod.join(os.tmpdir(), "dojobay-suweb-")); + await fsp.mkdir(pathMod.join(webRoot, "server", "data"), { recursive: true }); + await fsp.mkdir(pathMod.join(webRoot, "assets"), { recursive: true }); + await fsp.writeFile(pathMod.join(webRoot, "server", "index.mjs"), "// current"); + await fsp.writeFile(pathMod.join(webRoot, "server", "data", "store.json"), "{}"); + await fsp.writeFile(pathMod.join(webRoot, "assets", "app.js"), "// current"); + const applied = await applyUpdate({ ...got, webRoot, spawnHelper: false, log: () => {} }); + const backedUpStore = await fsp.readFile(pathMod.join(applied.backupDir, "server/data/store.json")).then(() => true, () => false); + ok(!backedUpStore, "the backup carries code but never the store, which holds sessions and node API keys"); + const stagedOk = await fsp.readFile(pathMod.join(applied.staging, "server/index.mjs")).then(() => true, () => false); + ok(got.version === "peercommit" && stagedOk && applied.entries > 30, + "verified peer archive is staged for apply"); + await fsp.rm(tmp, { recursive: true, force: true }); + await fsp.rm(webRoot, { recursive: true, force: true }); + + // admin gating of the job routes + const anonStart = await fetch(base + "/api/admin/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); + const anonStatus = await fetch(base + "/api/admin/update/status"); + const adminStatus = await api("/api/admin/update/status"); + ok(anonStart.status === 401 && anonStatus.status === 401 && adminStatus.status === 200, + "update routes require admin; status readable by admin"); +} + +// 20) live-detected Dojo version (X-Dojo-Version): rebuild carries the value the +// updater wrote and folds it into the card version. The version is derived +// entirely from the node's API detected live wins, pairing is only the +// bootstrap fallback, and an operator edit can never change it. +{ + const { rebuild, effectiveVersion } = await import("./build-public.ts"); + const id = "mainnet-selftest-node"; + const dojosPath = process.env.PUBLIC_DATA_DIR + "/dojos.json"; + + ok(effectiveVersion("1.33.7", "1.28.0") === "1.33.7" + && effectiveVersion(null, "1.28.0") === "1.28.0" + && effectiveVersion(null, null) === null, + "effectiveVersion: detected live version wins, pairing is the fallback"); + + // simulate the updater having recorded a live version on the node + const snap = JSON.parse(await fsp.readFile(dojosPath, "utf8")); + snap.nodes.find((n) => n.id === id).detected_version = "1.33.7"; + await fsp.writeFile(dojosPath, JSON.stringify(snap, null, 2) + "\n"); + await rebuild(); + let n = JSON.parse(await fsp.readFile(dojosPath, "utf8")).nodes.find((x) => x.id === id); + ok(n.detected_version === "1.33.7" && n.version === "1.33.7", + "rebuild carries detected_version and shows it as the card version"); + + // an edit that tries to set a version is ignored; the detected value stands + await api("/api/dojo/edit", "POST", { id, name: "selftest-node", hardware: "RPi5 8GB", version: "0.0.1-hax" }); + n = JSON.parse(await fsp.readFile(dojosPath, "utf8")).nodes.find((x) => x.id === id); + ok(n.version === "1.33.7" && n.detected_version === "1.33.7", + "an operator edit cannot override the live-detected version"); +} + +// 21) live-detected Electrum endpoint (/support/services): rebuild carries what +// the updater read and publishes it as indexer_url, and nothing else can +// put a URL there. A node that publishes none yields null so the card can +// show N/A, which is why a declared URL is no longer a fallback: a healthy +// node exposing no indexer never acquires a detected value, so the declared +// one would have been published for good. +{ + const { rebuild, effectiveIndexer } = await import("./build-public.ts"); + const id = "mainnet-selftest-node"; + const dojosPath = process.env.PUBLIC_DATA_DIR + "/dojos.json"; + const live = "tcp://" + "i".repeat(56) + ".onion:50001"; + const declared = "ssl://" + "d".repeat(56) + ".onion:50002"; + + ok(effectiveIndexer(live) === live && effectiveIndexer(null) === null + && effectiveIndexer(undefined) === null, + "effectiveIndexer: the probed endpoint or nothing"); + + // A payload carrying an indexer must not reach a card by any route. The + // fixture is built the way a Dojo export builds one, with both the flattened + // indexer and the modern services[] array, because the gate used to read + // either. `declared` exists in this test only to be refused. + const withIdx = { pairing: { ...payload.pairing }, explorer: payload.explorer, + indexer: { type: "indexer", url: declared }, + services: [{ type: "indexer", url: declared }] }; + const upd = await api("/api/dojo/pairing", "POST", { id, payload: withIdx, signed: signBlockFor(withIdx) }); + const { store: idxStore } = await import("./store.ts"); + const idxRec = await idxStore.getSubmission(id); + ok(upd.status === 200 && Object.keys(idxRec.payload).sort().join(",") === "explorer,pairing", + "an indexer block posted with a pairing update is discarded: the stored payload is what was signed"); + + await rebuild(); + const idxNode = JSON.parse(await fsp.readFile(dojosPath, "utf8")).nodes.find((x) => x.id === id); + ok(idxNode.indexer_url === null && !("indexer" in idxNode.payload), + "and the card publishes N/A rather than the declared endpoint"); + + const snap = JSON.parse(await fsp.readFile(dojosPath, "utf8")); + snap.nodes.find((n) => n.id === id).detected_indexer = live; + await fsp.writeFile(dojosPath, JSON.stringify(snap, null, 2) + "\n"); + await rebuild(); + const n = JSON.parse(await fsp.readFile(dojosPath, "utf8")).nodes.find((x) => x.id === id); + ok(n.detected_indexer === live && n.indexer_url === live, + "rebuild carries detected_indexer and publishes it as indexer_url"); + + const other = JSON.parse(await fsp.readFile(dojosPath, "utf8")).nodes.find((x) => x.id !== id); + ok(!other || other.indexer_url === null || typeof other.indexer_url === "string", + "nodes without a probed endpoint publish null (card shows N/A)"); +} + +// 22) signature gate robustness, from real listings found by the store audit. +// Wallets and admin panels serialise the pairing JSON differently, and a +// PayNym signs from its mainnet notification address even for a testnet +// node. Both used to fail the gate despite the signature being perfect. +{ + const { verifySignedPayload, sameSignedPayload, notificationAddresses } = await import("./crypto.ts"); + const sign = (text, acct) => Buffer.from(msg.sign(text, acct.getNotificationPrivateKey(), true, net47.messagePrefix)).toString("base64"); + const blockOf2 = (text, addr) => `-----BEGIN BITCOIN SIGNED MESSAGE-----\n${text}\n-----BEGIN BITCOIN SIGNATURE-----\nAddress: ${addr}\n\n${sign(text, acct)}\n-----END BITCOIN SIGNATURE-----`; + + // pretty-printed, exactly as several real listings were signed + const pretty = JSON.stringify(JSON.parse(canonical), null, 2); + const prettySigned = pretty + "\n\nBIP47:\n" + paymentCode; + const rPretty = verifySignedPayload({ signedText: blockOf2(prettySigned, notifAddr), expectedMessage: canonical, expectedAddress: notifAddr }); + + // same data, keys in a different order + const src = JSON.parse(canonical); + const reordered = JSON.stringify({ explorer: src.explorer, pairing: Object.fromEntries(Object.keys(src.pairing).reverse().map((k) => [k, src.pairing[k]])) }); + const reSigned = reordered + "\n\nBIP47:\n" + paymentCode; + const rReorder = verifySignedPayload({ signedText: blockOf2(reSigned, notifAddr), expectedMessage: canonical, expectedAddress: notifAddr }); + + ok(rPretty.ok && rReorder.ok, + "pretty-printed and key-reordered signatures verify: the same payload, serialised differently"); + + // a changed value must still be refused + const changed = JSON.parse(canonical); changed.pairing.version = "9.9.9"; + const chSigned = JSON.stringify(changed) + "\n\nBIP47:\n" + paymentCode; + const rChanged = verifySignedPayload({ signedText: blockOf2(chSigned, notifAddr), expectedMessage: canonical, expectedAddress: notifAddr }); + ok(!rChanged.ok && /does not match/.test(rChanged.error) + && !sameSignedPayload('{"a":1}', '{"a":2}') && sameSignedPayload('{"a":1,"b":2}', '{"b":2,"a":1}') + && !sameSignedPayload('{"a":1}', '{"a":1,"b":2}'), + "a changed value, or an added or removed field, is still refused"); + + // a PayNym signs from its mainnet address even for a testnet listing + const addrs = notificationAddresses(paymentCode); + ok(addrs.length === 2 && addrs[0] === notifAddr, + "notificationAddresses returns both derivations, mainnet first"); + const rTestnet = verifySignedPayload({ signedText: blockOf2(signedText, notifAddr), expectedMessage: canonical, expectedAddress: addrs, network: "testnet" }); + ok(rTestnet.ok, "a testnet listing signed with the mainnet notification address verifies"); +} + +// 23) the store auditor must reproduce the gate's verdict, not its own. It +// once derived the notification address for the record's own network, so +// every testnet listing was reported as failing while the gate accepted it. +{ + const { auditRecord } = await import("./audit-signed.mjs"); + const rec = (net) => ({ id: `${net}-audit`, network: net, name: "audit", status: "approved", + paymentCodes: [paymentCode], payload, signed: signedBlock }); + ok(auditRecord(rec("mainnet")).bucket === "VERIFIED", "auditor verifies a good mainnet record"); + ok(auditRecord(rec("testnet")).bucket === "VERIFIED", + "auditor verifies a testnet record signed with the mainnet notification address (mirrors the gate)"); + ok(auditRecord({ ...rec("mainnet"), signed: null }).bucket === "UNSIGNED", "auditor reports an unsigned record"); + ok(auditRecord({ ...rec("mainnet"), payload: { ...payload, pairing: { ...payload.pairing, apikey: "changed" } } }).bucket === "FAILED", + "auditor fails a record whose payload no longer matches what was signed"); +} + +// 24) verified operator domains: the pure parts (normalisation, the TXT record, +// the DoH answer shape and the grace policy), then the API path with DNS +// stubbed, since a self-test must not depend on the internet. +{ + const dom = await import("./domains.ts"); + const dns = await import("./dns.ts"); + const { store: st } = await import("./store.ts"); + + // normalisation: accept what an operator is likely to type, reject the rest + ok(dom.normaliseDomain("Example.COM").domain === "example.com" + && dom.normaliseDomain("https://example.com/").domain === "example.com" + && dom.normaliseDomain(" https://sub.example.com/path?q=1 ").domain === "sub.example.com" + && dom.normaliseDomain("xn--bcher-kva.de").domain === "xn--bcher-kva.de", + "domain normalisation reduces what operators type to a bare ASCII host"); + ok(!dom.normaliseDomain("").ok && !dom.normaliseDomain("localhost").ok + && !dom.normaliseDomain("192.168.0.1").ok && !dom.normaliseDomain("example.com:8080").ok + && !dom.normaliseDomain("abc.onion").ok && !dom.normaliseDomain("nodots").ok + && /onion address cannot be verified/.test(dom.normaliseDomain("abc.onion").error), + "domain normalisation refuses IPs, ports, localhost, bare labels and onions"); + + // the TXT record: strict about the code, tolerant of quoting and whitespace + const rec = dom.txtValue(paymentCode); + ok(dom.txtName("example.com") === "_dojobay.example.com" + && dom.txtMatches(rec, paymentCode) + && dom.txtMatches('"' + rec + '"', paymentCode) + && dom.txtMatches(rec.replace(" ", " "), paymentCode) + && !dom.txtMatches(rec.replace(/pm=PM8T/, "pm=PM8Tx"), paymentCode) + && !dom.txtMatches("v=spf1 include:example.com", paymentCode) + && !dom.txtMatches("dojobay-domain-v1", paymentCode), + "the TXT record matcher accepts real-world quoting but pins the payment code"); + + // DoH answers, including a long record split across quoted strings + ok(JSON.stringify(dns.parseTxtAnswer(JSON.stringify({ Status: 0, Answer: [{ type: 16, data: '"a" "b"' }] }))) === '["ab"]' + && JSON.stringify(dns.parseTxtAnswer(JSON.stringify({ Status: 3 }))) === "[]" + && dns.parseTxtAnswer(JSON.stringify({ Status: 2 })) === null + && dns.parseTxtAnswer("not json") === null, + "DoH answers parse: split strings joined, NXDOMAIN empty, SERVFAIL unusable"); + + // the signed claim reuses the operator-binding shape + const claim = dom.signingText("example.com", paymentCode); + ok(claim === `https://example.com/\n\nBIP47: ${paymentCode}`, "the text to sign is the URL, a blank line, then the BIP47 line"); + const { verifySignedUrlClaim } = await import("./crypto.ts"); + const blockFor = (text) => `-----BEGIN BITCOIN SIGNED MESSAGE-----\n${text}\n-----BEGIN BITCOIN SIGNATURE-----\nAddress: ${notifAddr}\n\n${Buffer.from(msg.sign(text, priv, true, net47.messagePrefix)).toString("base64")}\n-----END BITCOIN SIGNATURE-----`; + const good = verifySignedUrlClaim({ signed: blockFor(claim), expectedUrl: "https://example.com", paymentCode }); + const wrongDomain = verifySignedUrlClaim({ signed: blockFor(claim), expectedUrl: "https://other.com", paymentCode }); + const tampered = verifySignedUrlClaim({ signed: blockFor(claim).replace("example.com/", "evil.com/"), expectedUrl: "https://evil.com", paymentCode }); + ok(good.ok && !wrongDomain.ok && /this claim is for/.test(wrongDomain.error) + && !tampered.ok && /invalid signature|does not/.test(tampered.error), + "a signed domain claim verifies, and is refused for another domain or if altered"); + + // grace policy: a badge survives an unreachable resolver, and only drops after + // a sustained failure, keeping the claim so a restored record restores it + /** @type {import("../types.js").DomainClaim} */ + const base = { paymentCode, domain: "example.com", signed: "(test)", verified: true, + verified_at: "2026-01-01T00:00:00Z", last_check: "2026-01-01T00:00:00Z", last_result: "ok", + fail_since: null, created_at: "2026-01-01T00:00:00Z" }; + const now = Date.parse("2026-07-01T00:00:00Z"); + const inc = dom.applyRecheck(base, { ok: false, inconclusive: true, error: "tor down" }, now); + const failed1 = dom.applyRecheck(base, { ok: false, inconclusive: false, error: "no TXT record" }, now); + const failedLong = dom.applyRecheck({ ...base, fail_since: "2026-06-01T00:00:00Z" }, { ok: false, inconclusive: false, error: "no TXT record" }, now); + const recovered = dom.applyRecheck(failedLong, { ok: true, inconclusive: false }, now); + ok(inc.verified === true && inc.fail_since === null && /inconclusive/.test(inc.last_result), + "an unreachable resolver never strips a badge"); + ok(failed1.verified === true && failed1.fail_since + && failedLong.verified === false + && recovered.verified === true && recovered.fail_since === null, + `a missing record drops the badge only after ${dom.GRACE_DAYS} days, and restoring it recovers without re-signing`); + + ok(dom.urlOnDomain("https://example.com/x", "example.com") + && dom.urlOnDomain("https://a.example.com/", "example.com") + && !dom.urlOnDomain("https://notexample.com/", "example.com") + && !dom.urlOnDomain("https://example.com.evil.net/", "example.com") + && !dom.urlOnDomain("javascript:alert(1)", "example.com"), + "a card link is only on-domain for the domain itself or a true subdomain"); + + // API: prepare returns the exact record and text; submission verifies with DNS + // stubbed, and admin revocation clears the badge + const prep = await api("/api/domain/prepare", "POST", { domain: "Example.COM" }); + ok(prep.status === 200 && prep.body.txt_name === "_dojobay.example.com" + && prep.body.txt_value === rec && prep.body.sign_text === claim, + "prepare returns the exact TXT record and text to sign"); + + await st.deleteDomain(paymentCode); + const listed = await api("/api/admin/domains"); + ok(listed.status === 200 && Array.isArray(listed.body.domains), "admin can list domain claims"); + + await st.putDomain({ paymentCode, domain: "example.org", signed: "(test)", verified: true, + verified_at: new Date().toISOString(), last_check: new Date().toISOString(), last_result: "ok", + fail_since: null, created_at: new Date().toISOString() }); + const revoked = await api("/api/admin/domain/revoke", "POST", { paymentCode }); + const after = await st.getDomain(paymentCode); + const { rebuild: rb } = await import("./build-public.ts"); + await rb(); + const node = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/dojos.json", "utf8")) + .nodes.find((n) => n.id === "mainnet-selftest-node"); + ok(revoked.status === 200 && after.verified === false && after.revoked === true + && node.operator_domain === null && !("name_url" in node), + "admin revocation drops the badge on the next rebuild, and there is no card link " + + "left to withhold: the domain badge is the only claim a card carries"); +} + +// 25) the launcher: server/index.mjs must keep existing and must refuse an old +// Node before importing the TypeScript server. self-update.mjs sanity-checks +// an archive by looking for server/index.mjs, and systemd, npm start and the +// README all name it, so renaming it would break more than it appears. +{ + const launcher = await fsp.readFile(new URL("./index.mjs", import.meta.url), "utf8"); + ok(/process\.versions\.node/.test(launcher) && /< 24/.test(launcher) && /process\.exit\(1\)/.test(launcher), + "index.mjs refuses Node older than 24 with a message, before importing index.ts"); + ok(/await import\("\.\/index\.ts"\)/.test(launcher) && !/^import .*index\.ts/m.test(launcher), + "the launcher imports the server dynamically, so the version check runs first"); + ok(/export const server/.test(launcher), "the launcher re-exports the server for callers"); + + // build-public.mjs is the same pattern, and its name is depended on from + // further away: the deploy workflow, npm run build-public, install.mjs and + // apply-update.mjs, which spawns it DURING a self-update while still running + // the old copy of itself. A rename would break an instance mid-update. + const bp = await fsp.readFile(new URL("./build-public.mjs", import.meta.url), "utf8"); + ok(/< 24/.test(bp) && /await import\("\.\/build-public\.ts"\)/.test(bp), + "build-public.mjs guards the Node version, then imports build-public.ts dynamically"); + ok(/export const rebuild/.test(bp) && /pathToFileURL/.test(bp), + "the rebuild launcher re-exports rebuild and still runs it when invoked directly"); +} + +// 26) whitespace repair: the signature covers the blank line before the BIP47 +// line, and copying a block through chat, a form or a mail client routinely +// eats it. A reconstruction is accepted ONLY if it verifies against an +// address the declared code derives, so this is search, not trust. +{ + const { repairSignedBlock, verifySignedPayload, notificationAddresses } = await import("./crypto.ts"); + const intact = signedBlock; // json + blank line + BIP47 line + const mangled = intact.replace(`${canonical}\n\nBIP47:`, `${canonical}\nBIP47:`); + ok(mangled !== intact, "the fixture really did lose its blank line"); + + const before = verifySignedPayload({ signedText: mangled, expectedMessage: canonical, expectedAddress: notifAddr }); + ok(!before.ok && /invalid signature/.test(before.error), + "a block that lost its blank line fails verification as supplied"); + + const fixed = repairSignedBlock(mangled); + ok(fixed && /blank line/.test(fixed.note || ""), "the repair reports what it changed"); + const after = verifySignedPayload({ signedText: fixed.block, expectedMessage: canonical, expectedAddress: notifAddr }); + ok(after.ok, "the repaired block verifies, so it is safe to store"); + + // an intact block is returned unchanged, with nothing to report + const untouched = repairSignedBlock(intact); + ok(untouched && untouched.note === null, "an intact block is passed through unrepaired"); + + // repair must never rescue a genuinely bad signature, or one whose code does + // not own the signing address + const corrupt = mangled.replace(sigLine, sigLine.replace(/^./, (c) => (c === "H" ? "I" : "H"))); + ok(repairSignedBlock(corrupt) === null, "a corrupted signature is not rescued by repair"); + const otherCode = bip47.fromSeed(mnemonicToSeedSync("legal winner thank year wave sausage worth useful legal winner thank yellow")).toBase58(); + ok(repairSignedBlock(intact.replace(paymentCode, otherCode)) === null, + "a block whose BIP47 code does not derive the signing address is refused"); +} + +// 27) retention: a rejected submission is kept briefly so a maintainer can undo +// a mistake, then removed. Nothing used to remove one, so the store kept the +// payment code, pairing payload, apikey and signature of every operator ever +// turned down, indefinitely. +{ + const { store: st } = await import("./store.ts"); + /** + * @param {string} id + * @param {"pending"|"approved"|"rejected"} status + * @param {string|undefined} updated + * @returns {import("../types.js").StoreRecord} + */ + const mk = (id, status, updated) => ({ id, network: "mainnet", name: id, status, + paymentCodes: [paymentCode], payload, signed: signedBlock, updated_at: updated }); + const day = 86400 * 1000, now = Date.now(); + await st.putSubmission(mk("mainnet-rej-old", "rejected", new Date(now - 30 * day).toISOString())); + await st.putSubmission(mk("mainnet-rej-new", "rejected", new Date(now - 2 * day).toISOString())); + await st.putSubmission(mk("mainnet-rej-nodate", "rejected", undefined)); + await st.putSubmission(mk("mainnet-keep-approved", "approved", new Date(now - 400 * day).toISOString())); + await st.putSubmission(mk("mainnet-keep-pending", "pending", new Date(now - 400 * day).toISOString())); + + const gone = await st.pruneRejected(14, now); + const left = (await st.listSubmissions()).map((r) => r.id); + ok(gone.includes("mainnet-rej-old") && !left.includes("mainnet-rej-old"), + "a rejection older than the retention window is removed"); + ok(!gone.includes("mainnet-rej-new") && left.includes("mainnet-rej-new"), + "a recent rejection is kept, so a mistaken rejection can be undone"); + ok(gone.includes("mainnet-rej-nodate"), + "a rejection with no usable timestamp is removed rather than kept forever"); + ok(left.includes("mainnet-keep-approved") && left.includes("mainnet-keep-pending"), + "approved and pending records are never touched, however old"); + + const stored = await fsp.readFile(process.env.SERVER_DATA_DIR + "/store.json", "utf8"); + ok(!stored.includes("mainnet-rej-old"), + "the removed record is gone from the store file, apikey and signature included"); + for (const id of ["mainnet-rej-new", "mainnet-rej-nodate", "mainnet-keep-approved", "mainnet-keep-pending"]) { + await st.deleteSubmission(id); + } +} + +// 28) the domain badge publishes its own proof, so a reader can check the claim +// with their own tools rather than trusting this instance's tick. +{ + const { store: st } = await import("./store.ts"); + const { rebuild: rb } = await import("./build-public.ts"); + const dojosPath = process.env.PUBLIC_DATA_DIR + "/dojos.json"; + await st.putDomain({ paymentCode, domain: "example.org", signed: signedBlock, verified: true, + verified_at: "2026-07-01T00:00:00Z", last_check: "2026-07-02T00:00:00Z", last_result: "ok", + fail_since: null, created_at: "2026-07-01T00:00:00Z" }); + await rb(); + const n = JSON.parse(await fsp.readFile(dojosPath, "utf8")).nodes.find((x) => x.id === "mainnet-selftest-node"); + const pf = n.operator_domain_proof; + ok(pf && pf.domain === "example.org" && pf.paymentCode === paymentCode, + "the proof names the domain and the payment code it is bound to"); + ok(pf.txt_name === "_dojobay.example.org" && pf.txt_value === `dojobay-domain-v1 pm=${paymentCode}`, + "it publishes the exact TXT record a reader should look up"); + ok(pf.signed === signedBlock && pf.verified_at === "2026-07-01T00:00:00Z", + "and the signed statement, so the signature half can be checked independently"); + + // a node whose operator has no verified domain publishes nothing + await st.deleteDomain(paymentCode); + await rb(); + const n2 = JSON.parse(await fsp.readFile(dojosPath, "utf8")).nodes.find((x) => x.id === "mainnet-selftest-node"); + ok(n2.operator_domain === null && n2.operator_domain_proof === null, + "no verified domain means no badge and no proof"); +} + +// 29) the submission gate repairs a paste that lost its blank line, end to end. +// Operators paste into a web form, which mangles whitespace exactly as a +// chat window does, and the signature covers that blank line. +{ + const mangled = signedBlock.replace(`${canonical}\n\nBIP47:`, `${canonical}\nBIP47:`); + ok(mangled !== signedBlock, "the fixture really did lose its blank line"); + + const r = await api("/api/dojo", "POST", { + network: "mainnet", name: "paste-repair", jurisdiction: "Testland", + payload, signed: mangled, + }); + ok(r.status === 200, "a submission whose paste lost the blank line is accepted: " + JSON.stringify(r.body?.error || "")); + + // and what is STORED is the repaired block, so a later audit verifies + const { store: st } = await import("./store.ts"); + const { auditRecord } = await import("./audit-signed.mjs"); + const rec = (await st.listSubmissions()).find((x) => x.name === "paste-repair"); + ok(rec && rec.signed !== mangled, "the repaired block is stored, not the mangled paste"); + ok(auditRecord(rec).bucket === "VERIFIED", "so the stored record passes a later audit"); + + // repair must not rescue a signature that is actually wrong + const corrupt = mangled.replace(sigLine, sigLine.replace(/^./, (c) => (c === "H" ? "I" : "H"))); + const bad = await api("/api/dojo", "POST", { + network: "mainnet", name: "paste-repair-bad", jurisdiction: "Testland", + payload, signed: corrupt, + }); + ok(bad.status === 400 && /signature gate/.test(bad.body.error), + "a genuinely bad signature is still refused: " + JSON.stringify(bad.body?.error || "")); + + await st.deleteSubmission(rec.id); +} + +// 30) updating pairing details: an operator whose onion changes keeps their +// listing. Approval binds to the payment code that owns the record, not to +// a particular address, so the moderation status, the id and therefore the +// reliability history all survive. +{ + const { store: st } = await import("./store.ts"); + const id = "mainnet-selftest-node"; + const before = await st.getSubmission(id); + ok(before.status === "approved", "the record under test starts approved"); + + const movedUrl = "http://" + "m".repeat(56) + ".onion/v2"; + const moved = { pairing: { ...payload.pairing, url: movedUrl }, explorer: payload.explorer }; + + const r = await api("/api/dojo/pairing", "POST", { id, payload: moved, signed: signBlockFor(moved) }); + const after = await st.getSubmission(id); + ok(r.status === 200 && after.payload.pairing.url === movedUrl, + "the pairing payload is replaced: " + JSON.stringify(r.body?.error || "")); + ok(after.status === "approved" && after.id === id, + "and the listing keeps its approval and its id, so its history survives"); + + // published immediately, rather than waiting for the next probe cycle + const pub = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/dojos.json", "utf8")) + .nodes.find((n) => n.id === id); + ok(pub && pub.payload.pairing.url === movedUrl, "and the card shows the new address at once"); + + // a signature, when supplied, must cover the payload being submitted + const bad = await api("/api/dojo/pairing", "POST", { id, payload: moved, signed: signedBlock }); + ok(bad.status === 400 && /signature gate/.test(bad.body.error), + "a signature that does not cover the new payload is refused"); + + // and omitting it entirely is refused rather than nulling the stored one. An + // edit with no block used to assign rec.signed = null, which is how a + // verified listing could quietly become an unattested one. + const none = await api("/api/dojo/pairing", "POST", { id, payload: moved }); + const stillSigned = await st.getSubmission(id); + ok(none.status === 400 && /cannot carry over/.test(none.body.error) && stillSigned.signed, + "an edit with no signed block is refused, told why the old one will not do, and the " + + "stored signature survives: " + JSON.stringify(none.body?.error || "")); + + // an unreachable address never replaces a working one: point the prober at the + // proxy that reports "host unreachable", as the submission gate test does + const dead = { pairing: { ...payload.pairing, url: "http://" + "z".repeat(56) + ".onion/v2" }, explorer: payload.explorer }; + const { PROBE_CFG: PC } = await import("./probe.mjs"); + PC.proxyPort = 19078; + const down = await api("/api/dojo/pairing", "POST", { id, payload: dead, signed: signBlockFor(dead) }); + PC.proxyPort = 19077; + const stillThere = await st.getSubmission(id); + ok(down.status === 422 && /connection gate/.test(down.body.error) + && stillThere.payload.pairing.url === movedUrl, + "an unreachable node is refused and the current listing is left alone"); + + // and only the owner may do it + const otherRec = { ...before, id: "mainnet-not-mine", name: "not-mine", paymentCodes: ["PM8T" + "7".repeat(112)] }; + await st.putSubmission(otherRec); + const notMine = await api("/api/dojo/pairing", "POST", { id: "mainnet-not-mine", payload: moved, signed: signBlockFor(moved) }); + ok(notMine.status === 404, "a record owned by another payment code is not editable"); + await st.deleteSubmission("mainnet-not-mine"); + + // restore for later checks + before.payload = payload; await st.putSubmission(before); +} + +// 31) the admin panel shows the same reliability data as the cards. It used to +// read only pending-probe.json, which the updater stops writing once a +// record is approved, so every approved listing said "not yet probed" and +// showed a strip frozen at whatever it had when it was approved. +{ + const dir = process.env.PUBLIC_DATA_DIR; + const id = "mainnet-selftest-node"; + const dojos = JSON.parse(await fsp.readFile(dir + "/dojos.json", "utf8")); + const n = dojos.nodes.find((x) => x.id === id); + n.status = "active"; n.block_height = 906123; n.checked_at = "2026-08-05 00:00"; + n.detected_version = "1.31.0"; + await fsp.writeFile(dir + "/dojos.json", JSON.stringify(dojos, null, 2) + "\n"); + await fsp.writeFile(dir + "/history.json", JSON.stringify({ + interval_minutes: 10, window_checks: 144, + nodes: { [id]: { checks: Array.from({ length: 12 }, (_, i) => ({ t: "2026-08-05T0" + i, up: true })) } }, + }, null, 2) + "\n"); + + const r = await api("/api/admin/submissions"); + const row = r.body.submissions.find((x) => x.id === id); + ok(row && row.probe && row.probe_source === "published", + "an approved record's probe data comes from the published view"); + ok(row.probe.status === "active" && row.probe.block_height === 906123, + "so its live status and chain tip are what the card shows"); + ok(Array.isArray(row.probe.checks) && row.probe.checks.length === 12, + "and its reliability strip has the full window, not a single block"); + ok(row.version === "1.31.0", + "the version shown is the live-detected one, not the pairing payload's"); +} + +// 32) a listing without a BIP47 payment code is structurally impossible. The +// store is the single chokepoint every write passes through, so refusing +// there is what makes it impossible rather than merely discouraged, and the +// rebuild withholds any that predate the rule instead of publishing them. +{ + const { store: st } = await import("./store.ts"); + const { rebuild: rb } = await import("./build-public.ts"); + const base = { network: "mainnet", name: "orphan", status: "approved", payload }; + + let threw = null; + try { await st.putSubmission(/** @type {any} */ ({ ...base, id: "mainnet-orphan", paymentCodes: [] })); } + catch (e) { threw = e; } + ok(threw && /must carry a BIP47 payment code/.test(threw.message), + "the store refuses a record with no payment code"); + + let threw2 = null; + try { await st.putSubmission(/** @type {any} */ ({ ...base, id: "mainnet-orphan2" })); } + catch (e) { threw2 = e; } + ok(threw2, "and one with no paymentCodes field at all"); + ok((await st.getSubmission("mainnet-orphan")) === null, "nothing is written when it refuses"); + + // A record that predates the rule, injected past putSubmission, is withheld + // from the published list rather than shown. Injected into the LOADED store, + // not into store.json: the store holds itself in memory as a single writer + // and load() returns that cache, so a record written to the file behind a + // running process is invisible to the rebuild. This test used to write the + // file, which meant it asserted that a record the rebuild had never heard of + // did not appear — true, and no evidence of anything. + const loaded = await st.get(); + loaded.submissions["mainnet-legacy-orphan"] = + /** @type {any} */ ({ ...base, id: "mainnet-legacy-orphan", paymentCodes: [], signed: signedBlock }); + await rb(); + const pub = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/dojos.json", "utf8")); + ok(!pub.nodes.some((n) => n.id === "mainnet-legacy-orphan"), + "a code-less record already in the store is withheld from the published list"); + + delete loaded.submissions["mainnet-legacy-orphan"]; + await rb(); +} + +// 33) minimum Dojo version, judged on what the node reports live and applied to +// registration only. The version inside a pairing payload is frozen when +// that payload was generated, so a current node can honestly declare an +// ancient one; judging on the declared value would refuse working nodes and +// admit old ones. +{ + const dv = await import("./dojo-version.ts"); + + ok(dv.compareVersions("1.27", "1.27.0") === 0 + && dv.compareVersions("1.29.2", "1.27.0") === 1 + && dv.compareVersions("1.4.5", "1.27.0") === -1 + && dv.compareVersions("v1.28.0-rc1", "1.28.0") === 0, + "versions compare numerically, so 1.4.5 is below 1.27.0 and 1.27 equals 1.27.0"); + + ok(dv.meetsMinimum("1.27.0", "1.27.0") && dv.meetsMinimum("1.31.0", "1.27.0") + && !dv.meetsMinimum("1.26.1", "1.27.0") && dv.meetsMinimum("1.0.0", ""), + "an empty minimum disables the check entirely"); + + // the detected version wins over a stale declared one, in both directions + const stale = dv.judgeVersion("1.26.1", "1.29.9", "1.27.0"); + const current = dv.judgeVersion("1.29.2", "1.4.5", "1.27.0"); + ok(!stale.ok && stale.source === "detected" + && current.ok && current.source === "detected" && current.version === "1.29.2", + "the live-detected version decides, not the payload's frozen claim"); + + const silent = dv.judgeVersion(null, null, "1.27.0"); + ok(!silent.ok && silent.version === null && /did not report a version/.test(silent.reason || ""), + "a node reporting no version at all is refused, and told why"); + + // registration is gated; an existing operator updating a listing is not + const { store: st } = await import("./store.ts"); + const before = await st.getSubmission("mainnet-selftest-node"); + const resubmit = await api("/api/dojo", "POST", { + network: "mainnet", name: "selftest-node", jurisdiction: "Testland", payload, signed: signedBlock, + }); + ok(resubmit.status === 200, + "an operator updating a listing they already hold is not re-judged: " + JSON.stringify(resubmit.body?.error || "")); + ok((await st.getSubmission("mainnet-selftest-node")).id === before.id, + "and keeps the same record"); +} + +// 34) the resource diagnostic must not hand an operator-set path to a +// subprocess. It used to run `df ... $WEB_ROOT` and `du -sb $PUBLIC_DIR`, +// which CodeQL flagged as js/shell-command-injection-from-environment and +// which really does misbehave: df and du read a leading hyphen as an option, +// so WEB_ROOT=-x measured something other than what was asked for and said +// nothing about it. statfs() and a walk answer both questions inside Node. +// Two of the checks below read the source rather than the behaviour, because +// the point is that the dataflow is gone and not merely escaped; the rest +// assert on values, which is the better instrument wherever it is available. +{ + const cr = await import("./check-resources.ts"); + const src = await fsp.readFile(new URL("./check-resources.ts", import.meta.url), "utf8"); + + // every sh() call site takes a literal command and a literal argument list. + // A variable in either is the regression this exists to catch. One identifier + // is allowed through: `unit`, which the loop takes from the exported UNITS. + // Anything else must be a string literal, so reinstating `sh("du", ["-sb", p])` + // fails here rather than shipping. Widening the allowlist should require an + // argument about where that value comes from. + const ALLOWED = new Set(["UNITS", "unit"]); + const calls = [...src.matchAll(/\bsh\(([^)]*?)\)/gs)].map((m) => m[1]); + ok(calls.length >= 2, "the diagnostic still shells out for what only a binary can answer"); + const identifiers = calls.flatMap((c) => + [...c.replace(/"(?:[^"\\]|\\.)*"/g, "").matchAll(/[A-Za-z_$][\w$.]*/g)].map((m) => m[0])); + ok(identifiers.every((i) => ALLOWED.has(i)) && !calls.some((c) => c.includes("`")), + "no sh() call site passes anything but a string literal and a known unit name: " + + JSON.stringify(identifiers)); + + // UNITS is checked as a value, not as text. The previous version of this + // matched the declaration with a regex whose repeated group could match the + // same input two ways, which CodeQL flagged as js/redos and which really was + // exponential: 24 quoted tokens with no closing bracket took 356 ms, doubling + // every two. Nothing untrusted ever reached it, but a test asserting on the + // spelling of a line was the wrong instrument for the question anyway. + ok(Array.isArray(cr.UNITS) && cr.UNITS.length >= 2 + && cr.UNITS.every((u) => typeof u === "string" && u.endsWith(".service") && !u.includes("/")), + "the units the diagnostic asks systemctl about are a fixed list: " + cr.UNITS.join(", ")); + const decl = src.slice(src.indexOf("export const UNITS"), src.indexOf("];", src.indexOf("export const UNITS"))); + ok(decl.length > 0 && !/process\.env|`|\$\{|\(/.test(decl), + "and that list is written out in the file, not read from the environment"); + ok(!/sh\(\s*"(?:du|df|sh|bash)"/.test(src), + "df, du and a shell are gone: nothing spawns a process that parses a path"); + + // dirSize replaces `du -sb`, so it must agree with it, and it must not follow + // a symlink out of the tree it was asked about. + const root = pathMod.join(os.tmpdir(), "dojobay-dirsize-" + Date.now()); + await fsp.mkdir(pathMod.join(root, "nested"), { recursive: true }); + await fsp.writeFile(pathMod.join(root, "a.bin"), Buffer.alloc(1000)); + await fsp.writeFile(pathMod.join(root, "nested", "b.bin"), Buffer.alloc(2000)); + const outside = pathMod.join(os.tmpdir(), "dojobay-dirsize-outside-" + Date.now()); + await fsp.writeFile(outside, Buffer.alloc(9_000_000)); + await fsp.symlink(outside, pathMod.join(root, "link.bin")); + const size = await cr.dirSize(root); + const linkSize = (await fsp.lstat(pathMod.join(root, "link.bin"))).size; + ok(size === 3000 + linkSize, + `dirSize sums a tree and counts a symlink without following it: ${size}`); + ok(await cr.dirSize(pathMod.join(root, "missing")) === null, + "a path that is not there reports nothing rather than zero"); + + // the bug itself: a root whose name begins with a hyphen. du read that as an + // option and reported the wrong tree; nothing in Node cares. + const cwd = process.cwd(); + process.chdir(os.tmpdir()); + const hyphen = "-dojobay-" + Date.now(); + await fsp.mkdir(hyphen, { recursive: true }); + await fsp.writeFile(pathMod.join(hyphen, "c.bin"), Buffer.alloc(4096)); + ok(await cr.dirSize(hyphen) === 4096, + "a path beginning with a hyphen is measured, not parsed as an option"); + await fsp.rm(hyphen, { recursive: true, force: true }); + process.chdir(cwd); + + // diskUsage replaces `df`, and the identity it must keep is df's: available + // excludes the root reserve, so it is never more than what is unused. + const du = await cr.diskUsage(os.tmpdir()); + ok(du && du.size > 0 && du.used >= 0 && du.avail >= 0 && du.used + du.avail <= du.size, + "diskUsage reports a filesystem's size, used and available consistently"); + ok(await cr.diskUsage(pathMod.join(root, "nowhere", "at", "all")) === null, + "and reports nothing for a path on no filesystem it can see"); + + await fsp.rm(root, { recursive: true, force: true }); + await fsp.rm(outside, { force: true }); +} + +// 35) a listing without a signed pairing block is structurally impossible, on +// the same three-point pattern as the payment-code rule in 32: the gates +// refuse it with something an operator can act on, the store refuses it +// however the record was assembled, and the rebuild withholds anything that +// predates the rule rather than publishing it. The signature is the only +// part of a listing a visitor can check without trusting this site, so a +// listing without one asks for trust that cannot be earned. +{ + const { store: st } = await import("./store.ts"); + const { rebuild: rb } = await import("./build-public.ts"); + const base = { network: "mainnet", name: "mute", status: "approved", + paymentCodes: [paymentCode], payload }; + + let threw = null; + try { await st.putSubmission(/** @type {any} */ ({ ...base, id: "mainnet-mute" })); } + catch (e) { threw = e; } + ok(threw && /must carry a signed pairing block/.test(threw.message), + "the store refuses a record with no signed block"); + ok(threw && /remove-listing/.test(threw.message), + "and says what to do about it rather than only that it refused"); + ok((await st.getSubmission("mainnet-mute")) === null, "nothing is written when it refuses"); + + // a shape check, not a verification: whether the block verifies is settled at + // the gates, which have the session and the canonical message to hand. + let threw2 = null; + try { await st.putSubmission(/** @type {any} */ ({ ...base, id: "mainnet-mute2", signed: "I promise it is mine" })); } + catch (e) { threw2 = e; } + ok(threw2, "and refuses a signed field that is not a signed-message block at all"); + + // the submit gate refuses first, before the connection gate spends thirty + // seconds probing a node whose submission cannot be accepted anyway + const noSig = await api("/api/dojo", "POST", { + network: "mainnet", name: "gate-mute", jurisdiction: "Testland", payload, + }); + ok(noSig.status === 400 && /signature gate/.test(noSig.body.error) + && /PayNym/.test(noSig.body.error), + "the submit gate refuses an unsigned submission and says how to sign: " + + JSON.stringify(noSig.body?.error || "")); + ok(!(await st.getSubmission("mainnet-gate-mute")), "and no record is created by the attempt"); + + // a record that predates the rule, injected past the store, is withheld from + // the published list rather than shown + const legacy = /** @type {any} */ ({ ...base, id: "mainnet-legacy-mute", name: "legacy-mute" }); + const loaded = await st.get(); + loaded.submissions["mainnet-legacy-mute"] = legacy; + await rb(); + const pub = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/dojos.json", "utf8")); + ok(!pub.nodes.some((n) => n.id === "mainnet-legacy-mute"), + "an unsigned record already in the store is withheld from the published list"); + + // and the audit calls it a failure now, rather than leaving it as a decision + const { auditRecord } = await import("./audit-signed.mjs"); + ok(auditRecord(legacy).bucket === "UNSIGNED", + "the auditor still buckets it as UNSIGNED, which now exits non-zero"); + + delete loaded.submissions["mainnet-legacy-mute"]; + await rb(); +} + +// 34a) signing in discards the cached update check. The cache lives in the +// process, not the session, so signing out and back in did not clear it +// and only a service restart would: an operator who had just pushed was +// told they were up to date with no way to say otherwise. +{ + const idx = await fsp.readFile(new URL("./index.ts", import.meta.url), "utf8"); + const cb = idx.slice(idx.indexOf('/api\\/auth47\\/callback'), idx.indexOf("/api\\/auth47\\/poll")); + ok(/UPDATES_CACHE = null/.test(cb), + "the Auth47 callback clears it, which is the moment somebody is about to look"); + // and the declaration precedes the use, so reordering the file cannot turn + // this into a dead-zone error at runtime + ok(idx.indexOf("let UPDATES_CACHE = null;") < idx.indexOf("UPDATES_CACHE = null;", + idx.indexOf("let UPDATES_CACHE = null;") + 10), + "and is declared above the code that clears it"); + // The TTL itself lives in updateCacheDecision's default, with the floor, and + // its behaviour is asserted in section 17 rather than by matching a literal. + // What matters here is that the route does not carry a second copy: two + // definitions of a cache window disagree eventually, and the disagreement is + // invisible until somebody wonders why a check is older than it should be. + const upd = await fsp.readFile(new URL("./updates.mjs", import.meta.url), "utf8"); + ok(/ttlMs = 6 \* 3600 \* 1000/.test(upd) && !/6 \* 3600 \* 1000/.test(idx), + "the unattended TTL is defined once, in updates.mjs: six hours is right for a background check over Tor"); + ok(/updateCacheDecision\(/.test(idx), + "and the route asks for the decision rather than reimplementing the window"); +} + +// 34b) the flag on a card is inferred from the one free-text answer an operator +// gives about where they are, and nothing is enforced. There used to be a +// separate code field, sliced to two characters and upper-cased, which +// rejected nothing: "FIN" silently became "FI" and was published as +// whichever country those letters name, while a single letter or half a +// pasted flag emoji were stored as given and rendered as letterboxes. +{ + const { countryFor } = await import("./dojo-version.ts"); + ok(countryFor("Finland") === "FI" && countryFor("Helsinki, Finland") === "FI", + "a country is recognised, in a sentence or on its own"); + ok(countryFor("UK") === "GB", "including the one that is not a code"); + ok(countryFor("Europe") === null && countryFor("Ancapistan") === null, + "and a generic or invented answer is allowed, with no flag and no complaint"); + ok(countryFor("XX") === null, "an unassigned pair is not a flag"); + + const idx = await fsp.readFile(new URL("./index.ts", import.meta.url), "utf8"); + ok(/country: countryFor\(body\.jurisdiction\)/.test(idx), + "the gate infers from the location answer rather than asking separately"); + ok(!/slice\(0, 2\)\.toUpperCase\(\)/.test(idx), "the old truncation is gone"); + + await api("/api/dojo", "POST", { network: "mainnet", name: "cc-node", + jurisdiction: "Ancapistan", payload, signed: signedBlock }); + const { store: st34b } = await import("./store.ts"); + const rec = await st34b.getSubmission("mainnet-cc-node"); + ok(!rec || rec.country === null, + "a location that names nowhere stores no code: " + JSON.stringify(rec && rec.country)); + ok(!rec || rec.jurisdiction === "Ancapistan", + "while the answer itself is kept exactly as written"); +} + +// 35a) a listing's endpoint must be for the network it claims. A Dojo serves +// testnet under a `test` path segment and mainnet without one, so the two +// are checkable against each other, and a crossed pair is wrong in a way +// nothing downstream catches: it answers, reports a height, and probes +// green forever. The installer applies the same rule from the same +// function; this is the other door into the same store. +{ + const { pairingNetwork } = await import("./dojo-version.ts"); + const onion = "b3krcphqdbrzkblvti2eiuogfrx6b5lynenv5dxjwsw7hq47dlrc4pid"; + ok(pairingNetwork(`http://${onion}.onion/test/v2`) === "testnet", "a /test/ segment reads as testnet"); + ok(pairingNetwork(`http://${onion}.onion/v2`) === "mainnet", "and its absence as mainnet"); + // a whole segment, never a substring: an onion address is base32 and can carry + // those four letters by chance, and /v2/testing is not a testnet endpoint + ok(pairingNetwork(`http://test${onion.slice(4)}.onion/v2`) === "mainnet", + "letters inside the onion address are not a path segment"); + ok(pairingNetwork(`http://${onion}.onion/v2/testing`) === "mainnet", + "nor is a segment that merely begins with them"); + + const testnetPayload = { pairing: { type: "dojo.api", version: "1.29.2", apikey: "k", + url: `http://${onion}.onion/test/v2` } }; + const crossed = await api("/api/dojo", "POST", + { network: "mainnet", name: "crossed-node", payload: testnetPayload, signed: signedBlock }); + ok(crossed.status === 400 && /testnet endpoint/.test(crossed.body.error || ""), + "a testnet endpoint submitted as mainnet is refused: " + JSON.stringify(crossed.body.error)); + + const otherWay = await api("/api/dojo", "POST", + { network: "testnet", name: "crossed-node", payload, signed: signedBlock }); + ok(otherWay.status === 400 && /mainnet endpoint/.test(otherWay.body.error || ""), + "and a mainnet endpoint submitted as testnet: " + JSON.stringify(otherWay.body.error)); + ok(/test\/v2/.test(otherWay.body.error || ""), + "with the shape the operator should be looking for, not just a refusal"); + + const { store: st35a } = await import("./store.ts"); + ok(!(await st35a.getSubmission("mainnet-crossed-node")) + && !(await st35a.getSubmission("testnet-crossed-node")), + "and neither attempt leaves a record behind"); +} + +// 35b) the archive download must ask for a media type the endpoint will serve. +// It asked for application/octet-stream, and GitHub's archive route +// answers 415 to that, so self-update failed on its first request and was +// never once seen to work. Checked against the live endpoint while fixing +// it: octet-stream 415, application/vnd.github+json 302, */* 302, and the +// 302 goes to codeload, which this transport already follows. +{ + const { githubRequestHead } = await import("./updates.mjs"); + const head = (opts) => githubRequestHead("/repos/Dojobay/dojobay/zipball/abc", "api.github.com", opts); + + const accept = (h) => (h.match(/\r\nAccept:\s*([^\r\n]+)/) || [])[1]; + ok(accept(head({ binary: true })) === "*/*", + "a download asks for anything the route serves: " + JSON.stringify(accept(head({ binary: true })))); + ok(!/octet-stream/.test(head({ binary: true })), + "and specifically not octet-stream, which this endpoint refuses outright"); + ok(accept(head({})) === "application/vnd.github+json", + "while metadata calls keep the type that pins the API version"); + + // the rest of the request has to stay a well-formed HTTP/1.1 head, since the + // reply parser depends on Connection: close and on identity encoding. + const h = head({ binary: true }); + ok(/^GET \/repos\/Dojobay\/dojobay\/zipball\/abc HTTP\/1\.1\r\n/.test(h), "request line intact"); + ok(/\r\nHost: api\.github\.com\r\n/.test(h), "Host is the hop's host, not a constant"); + ok(/\r\nAccept-Encoding: identity\r\n/.test(h) && /\r\nConnection: close\r\n\r\n$/.test(h), + "identity encoding and Connection: close, which the reply parser relies on"); +} + +// 36) the published file is produced by an allowlist, and only by that +// allowlist. The store holds moderation status, the owning payment codes, +// submission timestamps, the probe result recorded at submission and import +// provenance, none of which belong to the public. A redaction list would be +// wrong by default and would need updating every time the store grew a +// field; naming the output instead is right by default. These checks are +// what stop that property being lost quietly. +{ + const { store: st } = await import("./store.ts"); + const { rebuild: rb, PUBLIC_NODE_KEYS } = await import("./build-public.ts"); + const loaded = await st.get(); + + // A record carrying every field the store type knows about, plus four it does + // not. The extras are the point: store.json is JSON, so the TypeScript + // interface constrains what we WRITE and not what is there, and a field added + // by a future endpoint, a migration or a hand edit is exactly the case an + // allowlist has to survive. + loaded.submissions["mainnet-allowlist"] = /** @type {any} */ ({ + id: "mainnet-allowlist", network: "mainnet", name: "allowlist", status: "approved", + paymentCodes: [paymentCode, "PMsecondCodeNobodyShouldSee"], payload, signed: signedBlock, + jurisdiction: "Testland", country: "TL", hardware: "a box", paynym: "+al", name_url: null, + last_probe: { up: true, reason: "http", ms: 5 }, + created_at: "2026-01-01T00:00:00Z", updated_at: "2026-02-02T00:00:00Z", + source: "bootstrap-import:some.onion", + // not in StoreRecord at all + moderator_note: "operator was rude in DMs", + session_hint: "sid-should-never-be-published", + internal_score: 0.42, + admin_only: { reviewer: "max" }, + }); + await rb(); + const pub = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/dojos.json", "utf8")); + const node = pub.nodes.find((n) => n.id === "mainnet-allowlist"); + ok(node, "the record is published at all, so the rest of this is meaningful"); + + // The exact key set. Not a subset check: a field appearing in the output + // without being named here must fail, which is the whole request. + const keys = Object.keys(node).sort(); + ok(JSON.stringify(keys) === JSON.stringify([...PUBLIC_NODE_KEYS].sort()), + "the published node's keys are exactly the allowlist. Unexpected: " + + JSON.stringify(keys.filter((k) => !PUBLIC_NODE_KEYS.includes(k))) + + " missing: " + JSON.stringify(PUBLIC_NODE_KEYS.filter((k) => !keys.includes(k)))); + + // And named, so a failure says which secret escaped rather than only that the + // shape changed. + for (const k of ["moderator_note", "session_hint", "internal_score", "admin_only", + "last_probe", "created_at", "updated_at", "source", "paymentCodes"]) { + ok(!(k in node), `${k} is not published`); + } + // The moderation status is published as a FIELD, but never with a moderation + // VALUE: the public status is a liveness state written by the probe, and the + // store's pending/approved/rejected must not reach it. + ok(!["pending", "approved", "rejected"].includes(node.status), + "the public status is a liveness state, not a moderation state: " + node.status); + // Ownership is published as one display code, never the full set. + ok(typeof node.paymentCode === "string" && !JSON.stringify(pub).includes("PMsecondCodeNobodyShouldSee"), + "only the display payment code is published, not every code the owner holds"); + + // The seed anchor is held to the same allowlist. It used to be published as + // it sits in seed.json, so the file had two producers and one filter. + const seedPath = process.env.PUBLIC_DATA_DIR + "/seed.json"; + const seedDoc = JSON.parse(await fsp.readFile(seedPath, "utf8")); + seedDoc.nodes[0].operator_private_note = "not for publication"; + await fsp.writeFile(seedPath, JSON.stringify(seedDoc, null, 2) + "\n"); + await rb(); + const pub2 = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/dojos.json", "utf8")); + const anchor = pub2.nodes.find((n) => n.id === seedDoc.nodes[0].id); + ok(anchor, "the seed anchor is published"); + ok(!("operator_private_note" in anchor), + "a field added to seed.json is not published just because it is in seed.json"); + ok(JSON.stringify(Object.keys(anchor).sort()) === JSON.stringify([...PUBLIC_NODE_KEYS].sort()), + "and the anchor's keys are the same allowlist as any other node: " + + JSON.stringify(Object.keys(anchor).sort())); + + delete loaded.submissions["mainnet-allowlist"]; + delete seedDoc.nodes[0].operator_private_note; + await fsp.writeFile(seedPath, JSON.stringify(seedDoc, null, 2) + "\n"); + await rb(); +} + +// 37) one definition of the canonical pairing string. crypto.ts owns it, and +// every other file under server/ must import it rather than write the +// expression again. This has already gone wrong twice: the string lived in +// the submission gate and in audit-signed.mjs, the second carrying a comment +// warning it MUST mirror the first, and after that consolidation two more +// copies appeared in apply-signed-payload.ts and diagnose-signed.mjs. A +// canonical message with several definitions eventually disagrees with +// itself, and the disagreement is silent in the worst direction: a signature +// accepted at submission and reported invalid by a later audit. +// +// Scoped to non-test source under server/. The expression is deliberately +// reimplemented twice in this file, so a test does not check the code +// against itself, and assets/js/app.js writes its own because it cannot +// import server code and is building display text, not a message to verify. +{ + const dir = new URL("./", import.meta.url); + const sources = (await fsp.readdir(dir)) + .filter((f) => /\.(ts|mjs)$/.test(f) && f !== "selftest.mjs") + .sort(); + ok(sources.includes("crypto.ts") && sources.length >= 10, + `the scan sees the server source: ${sources.length} files`); + + // JSON.stringify over an object literal naming both keys, whatever the + // payload is called and whichever order they are in. Matching on the shape + // rather than an exact string is the point: a copy that renamed its argument + // is still a copy. + const stringifyLiterals = (src) => + [...src.matchAll(/JSON\.stringify\(\s*\{([^{}]*)\}/g)] + .map((m) => m[1]) + .filter((body) => /\bpairing\s*:/.test(body) && /\bexplorer\s*:/.test(body)); + + // Comment lines are dropped before any of this runs. The scan is about + // bindings and uses in code, and a file is entitled to discuss the canonical + // string in prose without being told to import it: build-public.ts explains + // in a comment that the signature does not cover the declared indexer, names + // canonicalPairing while doing so, and imports nothing. Only whole-line + // comments are removed, so a trailing comment can still hide a use from the + // scan; that direction is a missed alarm rather than a false one. + const codeOnly = (src) => { + const out = []; + let inBlock = false; + for (const line of src.split("\n")) { + const t = line.trim(); + if (inBlock) { if (t.includes("*/")) inBlock = false; continue; } + if (t.startsWith("/*")) { if (!t.includes("*/")) inBlock = true; continue; } + if (t.startsWith("//") || t.startsWith("*")) continue; + out.push(line); + } + return out.join("\n"); + }; + + const defines = [], redeclares = [], missingImport = []; + for (const f of sources) { + const src = codeOnly(await fsp.readFile(new URL(f, dir), "utf8")); + if (stringifyLiterals(src).length) defines.push(f); + if (!/\bcanonicalPairing\b/.test(src)) continue; + // A local binding of that name shadows the shared one and defeats the check + // above the moment it is written any other way. + if (f !== "crypto.ts" && /(?:const|let|var|function)\s+canonicalPairing\b/.test(src)) redeclares.push(f); + if (f !== "crypto.ts" && !/import\s*\{[^}]*\bcanonicalPairing\b[^}]*\}\s*from\s*["']\.\/crypto\.ts["']/.test(src)) missingImport.push(f); + } + + ok(defines.length === 1 && defines[0] === "crypto.ts", + `the canonical pairing expression is written once, in crypto.ts (found in: ${defines.join(", ") || "nothing"})`); + ok(!redeclares.length, + `no file redeclares canonicalPairing locally (offenders: ${redeclares.join(", ") || "none"})`); + ok(!missingImport.length, + `every user of canonicalPairing imports it from crypto.ts (offenders: ${missingImport.join(", ") || "none"})`); + + // And the shared definition is the one the gate actually verifies against, so + // the files above are not merely agreeing with each other about the wrong text. + const { canonicalPairing: cp } = await import("./crypto.ts"); + ok(cp(payload) === JSON.stringify({ pairing: payload.pairing, explorer: payload.explorer }), + "crypto.ts's canonicalPairing produces the text this suite signs"); +} + +await fsp.rm(process.env.PUBLIC_DATA_DIR, { recursive: true, force: true }); + +console.log(`\nall ${passed} checks passed`); +proxy.close(); +process.exit(0); diff --git a/docker/dojobay/server/store.ts b/docker/dojobay/server/store.ts new file mode 100644 index 00000000..b3cae0f2 --- /dev/null +++ b/docker/dojobay/server/store.ts @@ -0,0 +1,224 @@ +// Tiny JSON-file store for the backend. Single-writer (one server process), +// atomic writes, no external database. Holds submissions, live sessions and +// outstanding Auth47 nonces. +import { readFile, writeFile, rename, mkdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomBytes } from "node:crypto"; +import type { StoreRecord, DomainClaim } from "../types.js"; + +/** A short-lived, single-use Auth47 nonce. */ +export interface Nonce { expires: number; [k: string]: unknown } +/** A signed-in operator's session, keyed by a random cookie id. */ +export interface Session { paymentCode: string; expires: number; [k: string]: unknown } + +interface StoreShape { + submissions: Record; + sessions: Record; + nonces: Record; + domains: Record; +} + +const DIR = process.env.SERVER_DATA_DIR + || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "data"); +const FILE = path.join(DIR, "store.json"); + +const EMPTY: StoreShape = { submissions: {}, sessions: {}, nonces: {}, domains: {} }; +let cache: StoreShape | null = null; + +// Whether a record carries a signed pairing block at all. A shape check, not a +// verification: the submit gate decided whether the block verifies against the +// operator's own payment code, and re-deriving that at every read would mean +// the store and the rebuild silently dropping listings over a cryptographic +// judgement made elsewhere. This asks only what a caller is entitled to ask +// here, which is whether there is anything for a visitor to check. +// server/audit-signed.mjs is the tool that re-runs the real verification over +// the whole store. It lives in this file rather than beside the verifier so +// that store.ts stays on node builtins alone: remove-listing.ts and the +// migration scripts import the store, and should not have to pull in secp256k1 +// to ask a question about a string. +export function hasSignedBlock(rec: { signed?: string | null } | null | undefined): boolean { + const signed = typeof rec?.signed === "string" ? rec.signed.trim() : ""; + return signed.includes("BEGIN BITCOIN SIGNED MESSAGE") && signed.includes("BEGIN BITCOIN SIGNATURE"); +} + +// A submission's ownership is a paymentCodes ARRAY, because one PayNym often +// carries two BIP47 codes (segwit and legacy variants) and the wallet may sign +// Auth47 with either. Records written before this schema carried a scalar +// paymentCode; normalise those on read so old store files keep working. +function normaliseSubmission(rec: T): T { + if (!rec || typeof rec !== "object") return rec; + const r = rec as { paymentCodes?: unknown; paymentCode?: string }; + if (!Array.isArray(r.paymentCodes)) { + r.paymentCodes = r.paymentCode ? [r.paymentCode] : []; + } + r.paymentCodes = [...new Set((r.paymentCodes as unknown[]).filter((c): c is string => typeof c === "string" && !!c))]; + delete r.paymentCode; + return rec; +} + +async function load(): Promise { + if (cache) return cache; + await mkdir(DIR, { recursive: true }); + try { + cache = { ...EMPTY, ...JSON.parse(await readFile(FILE, "utf8")) }; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; + cache = structuredClone(EMPTY); + } + for (const rec of Object.values(cache.submissions)) normaliseSubmission(rec); + return cache; +} + +// A temporary name no other writer can take; see build-public.ts. The store has +// a single writer by design, but the backend and a maintenance script can both +// be pointed at it, and that is precisely when a shared temporary name bites. +let tmpSeq = 0; +async function persist() { + const tmp = `${FILE}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`; + await writeFile(tmp, JSON.stringify(cache, null, 2) + "\n"); + await rename(tmp, FILE); +} + +export const store = { + async get() { return load(); }, + async save() { await persist(); }, + + // --- nonces (single-use, short lived) --- + async putNonce(nonce: string, data: Nonce) { (await load()).nonces[nonce] = data; await persist(); }, + async takeNonce(nonce: string): Promise { + const s = await load(); + const n = s.nonces[nonce]; + if (n) { delete s.nonces[nonce]; await persist(); } + return n || null; + }, + async gcNonces(now: number = Date.now()) { + const s = await load(); + let changed = false; + for (const [k, v] of Object.entries(s.nonces)) { + if (!v || v.expires < now) { delete s.nonces[k]; changed = true; } + } + if (changed) await persist(); + }, + + // --- sessions --- + async putSession(data: Session): Promise { + const s = await load(); + const id = randomBytes(32).toString("hex"); + s.sessions[id] = data; + await persist(); + return id; + }, + async getSession(id: string | null | undefined): Promise { + if (!id) return null; + const s = await load(); + const sess = s.sessions[id]; + if (!sess) return null; + if (sess.expires < Date.now()) { delete s.sessions[id]; await persist(); return null; } + return sess; + }, + async dropSession(id: string) { + const s = await load(); + if (s.sessions[id]) { delete s.sessions[id]; await persist(); } + }, + + // --- submissions (keyed by network + name slug; owned by paymentCodes[]) --- + async listSubmissions(): Promise { return Object.values((await load()).submissions); }, + async submissionsFor(paymentCode: string): Promise { + return Object.values((await load()).submissions) + .filter((r) => Array.isArray(r.paymentCodes) && r.paymentCodes.includes(paymentCode)); + }, + // Every record must carry at least one BIP47 payment code and a signed + // pairing block. This is the single chokepoint through which every write to + // the store passes, so enforcing both here is what makes an unowned or + // unattested listing structurally impossible rather than merely discouraged: + // the payment code is the identity the directory rests on, and the signature + // is what lets a visitor check the pairing details against that identity + // without trusting this site at all. A listing without one cannot be owned, + // edited, verified or recognised by a visitor; a listing without the other + // asks the visitor to take our word for an onion address and an API key, + // which is the one thing this directory exists not to require. Historically a + // few pre-Auth47 records existed without a code, and rather more predate the + // signature gate; both doors are now closed. + async putSubmission(rec: StoreRecord): Promise { + const normalised = normaliseSubmission(rec); + const codes = (normalised as StoreRecord).paymentCodes; + // An emptiness guard, deliberately, not a validator: whether a code is a + // real BIP47 payment code is settled at the gates that admit it — an Auth47 + // session proves possession, and the signature checks derive its + // notification address. What must be impossible HERE is a listing with no + // owner at all. + if (!Array.isArray(codes) || !codes.some((c) => typeof c === "string" && /^PM\w{6,}/.test(c.trim()))) { + throw new Error(`refusing to store ${rec?.id}: a listing must carry a BIP47 payment code`); + } + // The same kind of guard for the signature: a shape check, not a + // verification. Whether the block verifies against the record's own code is + // settled at the submit and pairing-edit gates, which have the session and + // the canonical message to hand and can say precisely what is wrong. What + // must be impossible HERE is a record whose pairing details nobody has + // attested to, however it was assembled — by an admin action, an import, a + // migration or a future endpoint that has not been written yet. + if (!hasSignedBlock(normalised)) { + throw new Error(`refusing to store ${rec?.id}: a listing must carry a signed pairing block. ` + + `Ask the operator to sign their pairing payload, or remove the listing with server/remove-listing.ts.`); + } + const s = await load(); + s.submissions[rec.id] = normalised; + await persist(); + return rec; + }, + async getSubmission(id: string): Promise { + const rec = (await load()).submissions[id] || null; + return rec ? normaliseSubmission(rec) : null; + }, + // Retention: a rejected submission is kept briefly so a maintainer can reverse + // a mistake, then deleted. Nothing else ever removed one, so the store + // accumulated the payment code, pairing payload and signature of every + // operator ever turned down — including the apikey, which is a live + // credential to their Dojo, not merely metadata. Returns the ids removed. + async pruneRejected(days: number, now: number = Date.now()): Promise { + const s = await load(); + const cutoff = now - days * 86400 * 1000; + const gone: string[] = []; + for (const [id, rec] of Object.entries(s.submissions)) { + if (rec?.status !== "rejected") continue; + const stamp = Date.parse(rec.updated_at || rec.created_at || ""); + // A record with no usable timestamp is pruned rather than kept forever. + if (Number.isFinite(stamp) && stamp > cutoff) continue; + delete s.submissions[id]; + gone.push(id); + } + if (gone.length) await persist(); + return gone; + }, + + async deleteSubmission(id: string) { + const s = await load(); + if (s.submissions[id]) { delete s.submissions[id]; await persist(); } + }, + + // --- verified operator domains (keyed by payment code) --------------------- + // One claim per code. A record is kept even after it stops verifying, so + // restoring the TXT record restores the badge without a fresh signature. + async listDomains(): Promise { return Object.values((await load()).domains || {}); }, + async getDomain(paymentCode: string): Promise { return ((await load()).domains || {})[paymentCode] || null; }, + async putDomain(claim: DomainClaim): Promise { + const s = await load(); + s.domains = s.domains || {}; + s.domains[claim.paymentCode] = claim; + await persist(); + return claim; + }, + async deleteDomain(paymentCode: string) { + const s = await load(); + if (s.domains && s.domains[paymentCode]) { delete s.domains[paymentCode]; await persist(); } + }, + // Every verified domain, as a payment code -> domain map, for the rebuild. + async verifiedDomainMap(): Promise> { + const out = new Map(); + for (const c of Object.values((await load()).domains || {})) { + if (c && c.verified && c.domain) out.set(c.paymentCode, c.domain); + } + return out; + }, +}; diff --git a/docker/dojobay/server/updates.mjs b/docker/dojobay/server/updates.mjs new file mode 100644 index 00000000..17c01559 --- /dev/null +++ b/docker/dojobay/server/updates.mjs @@ -0,0 +1,236 @@ +// How far behind is this instance? Compares the local data/version.json +// commit against the GitHub repository, over Tor (TLS through the same SOCKS +// tunnel the probes use), and reports commits behind plus releases published +// since this instance was built. Consumed by GET /api/admin/updates for the +// admin console's update line. Everything degrades gracefully: if GitHub is +// unreachable over Tor, the endpoint says so rather than failing the panel. +// +// "Releases behind" resolves release tags to commits and compares identity, so +// an instance running the exact commit of the newest release reports zero. The +// earlier version counted releases published after the local build timestamp, +// which always reported one behind: a tag is created after the commit it points +// at has been built and deployed. When the running commit is not itself a +// released tag, it falls back to that timestamp guess and flags it as such. +import tls from "node:tls"; +import path from "node:path"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { socks5Connect } from "../scripts/update.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const GITHUB_REPO = process.env.GITHUB_REPO || "Dojobay/dojobay"; +const API_HOST = "api.github.com"; + +// The request line and headers, separated out so the Accept value is testable +// without a network or a TLS mock. It is not a detail: a download used to ask +// for `application/octet-stream`, and GitHub's archive route answers 415 +// Unsupported Media Type to that, so self-update never got past its first +// request and no operator ever saw it work. Verified against the live endpoint: +// octet-stream returns 415, while both `application/vnd.github+json` and `*/*` +// return the 302 to codeload that this transport already follows. +// +// `*/*` rather than the JSON type, because a download genuinely will take +// whatever the route serves and saying so is true; asking for JSON to obtain a +// zip works only by convention and would be the next thing to break quietly. +// The metadata calls keep the JSON type, which is what those routes serve and +// what pins the API version. +export function githubRequestHead(apiPath, host, { binary = false } = {}) { + return `GET ${apiPath} HTTP/1.1\r\nHost: ${host}\r\nUser-Agent: dojobay-update-check\r\n` + + `Accept: ${binary ? "*/*" : "application/vnd.github+json"}\r\n` + + `Accept-Encoding: identity\r\nConnection: close\r\n\r\n`; +} + +// One HTTPS GET over the Tor SOCKS proxy. Handles chunked replies, returns +// both a text `body` and a raw `bodyBuf`, and follows GitHub's redirect from +// api.github.com to codeload for zipball downloads (binary: true) up to a few +// hops. Host is derived per hop so codeload.github.com is reached correctly. +/** + * @param {string} apiPath + * @param {{ proxyHost?: string, proxyPort?: number, timeoutMs?: number, + * binary?: boolean, _host?: string, _hops?: number }} [opts] + */ +export async function githubGet(apiPath, { proxyHost, proxyPort, timeoutMs = 30000, binary = false, _host = API_HOST, _hops = 0 } = {}) { + const raw = await socks5Connect(proxyHost, proxyPort, _host, 443, timeoutMs); + const res = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { socket.destroy(); reject(new Error("timeout")); }, timeoutMs); + const socket = tls.connect({ socket: raw, servername: _host }, () => { + socket.write(githubRequestHead(apiPath, _host, { binary })); + }); + const chunks = []; + socket.on("data", (d) => chunks.push(d)); + socket.on("error", (e) => { clearTimeout(timer); reject(e); }); + socket.on("close", () => { + clearTimeout(timer); + try { + const all = Buffer.concat(chunks); + const headEnd = all.indexOf("\r\n\r\n"); + if (headEnd < 0) return reject(new Error("malformed reply")); + const headText = all.subarray(0, headEnd).toString("latin1"); + const m = headText.match(/^HTTP\/1\.[01] (\d{3})/); + if (!m) return reject(new Error("malformed reply")); + const status = +m[1]; + const locM = headText.match(/\r\nlocation:\s*([^\r\n]+)/i); + let bodyBuf = all.subarray(headEnd + 4); + if (/transfer-encoding:\s*chunked/i.test(headText)) { + const parts = []; let p = 0; + for (;;) { + const nl = bodyBuf.indexOf("\r\n", p); + if (nl < 0) break; + const size = parseInt(bodyBuf.subarray(p, nl).toString("latin1"), 16); + if (!size) break; + parts.push(bodyBuf.subarray(nl + 2, nl + 2 + size)); + p = nl + 2 + size + 2; + } + bodyBuf = Buffer.concat(parts); + } + resolve({ status, location: locM ? locM[1].trim() : null, bodyBuf }); + } catch (e) { reject(e); } + }); + }); + if ([301, 302, 307, 308].includes(res.status) && res.location && _hops < 4) { + const u = new URL(res.location); + return githubGet(u.pathname + u.search, { proxyHost, proxyPort, timeoutMs, binary, _host: u.hostname, _hops: _hops + 1 }); + } + return { status: res.status, body: res.bodyBuf.toString("utf8"), bodyBuf: res.bodyBuf }; +} + +/** What to tell an operator when GitHub refuses a request. + * + * A bare "HTTP 403" reads as something broken here, and it is not: GitHub + * allows sixty unauthenticated requests an hour PER IP ADDRESS, and a Tor exit + * is one address shared with everyone else using it, so an instance can arrive + * at an exit whose hour was already spent by strangers. Observed on a live + * instance: limit 60, remaining 0, used 60, for an exit nobody here had made a + * single request through. + * + * It clears by itself when the window rolls over, and often sooner on a new + * circuit, since the limit follows the exit rather than the client. Saying so + * is the difference between an operator waiting and an operator going looking + * for a fault that does not exist. 429 is included because GitHub uses it for + * secondary limits and means the same thing to a reader. + */ +export function githubRefusal(what, status) { + if (status === 403 || status === 429) { + // No call-site prefix. Which of the three requests hit the limit is of no + // use to an operator, and "compare: GitHub is rate-limiting..." reads as + // though "compare" were a thing that had gone wrong. + return `GitHub is rate-limiting this Tor exit (HTTP ${status}). ` + + "The limit is per exit address and shared with every other user of it, so it clears on its " + + "own within the hour, usually sooner on a new circuit. Updating from a peer .onion does not " + + "touch GitHub and works meanwhile."; + } + return `${what}: HTTP ${status}`; +} + +export async function checkUpdates({ repo = GITHUB_REPO, transport = githubGet, cfg = {} } = {}) { + const verPath = path.join(process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"), "version.json"); + const version = JSON.parse(await readFile(verPath, "utf8")); + if (!version.commit || version.commit === "dev") throw new Error("local version.json has no deployed commit"); + + const cmp = await transport(`/repos/${repo}/compare/${encodeURIComponent(version.commit)}...main`, cfg); + if (cmp.status !== 200) throw new Error(githubRefusal("compare", cmp.status)); + const compare = JSON.parse(cmp.body); + + const rel = await transport(`/repos/${repo}/releases?per_page=30`, cfg); + if (rel.status !== 200) throw new Error(githubRefusal("releases", rel.status)); + const releases = JSON.parse(rel.body); + + // Which release are we actually running? + // + // This used to count releases published after the local build timestamp, + // which is wrong in the ordinary case: a tag is created AFTER the commit it + // points at has been built and deployed, so an instance running the exact + // commit of the newest release always reported itself one release behind. + // + // Resolve each release's tag to a commit instead and compare identity. If our + // commit IS a released tag, the number of releases published after it is the + // honest answer (zero, when we are on the latest). Only when no tag matches do + // we fall back to the timestamp approximation, and say so. + let tagSha = new Map(); + let tagsError = null; + try { + const tg = await transport(`/repos/${repo}/tags?per_page=100`, cfg); + if (tg.status === 200) { + for (const t of JSON.parse(tg.body)) { + if (t?.name && t?.commit?.sha) tagSha.set(t.name, String(t.commit.sha)); + } + } else { + tagsError = githubRefusal("tag lookup", tg.status); + } + } catch (e) { + tagsError = "tag lookup: " + (e?.message || "failed"); + } + + // version.json carries a short commit, the API a full sha; match either way. + const sameCommit = (a, b) => { + if (!a || !b) return false; + const x = String(a).toLowerCase(), y = String(b).toLowerCase(); + return x.startsWith(y) || y.startsWith(x); + }; + + const runningIndex = releases.findIndex((r) => sameCommit(tagSha.get(r.tag_name), version.commit)); + const approximate = runningIndex < 0; + const builtAt = Date.parse(version.built || 0) || 0; + + // Three states, and the third used to be reported as the second. + // + // matched our commit IS a released tag: the count is exact. + // no match we are on an untagged commit mid-cycle: the timestamp count + // is a fair approximation, because we are genuinely not on a + // release. + // no tag data we could not look tags up at all, usually because a shared + // Tor exit hit GitHub's rate limit. The timestamp count is + // then WORSE than saying nothing: a tag is always created + // after its commit was built, so an instance running the very + // newest release scores one behind. Report null instead. + const releasesBehind = !approximate ? runningIndex + : tagsError ? null + : releases.filter((r) => Date.parse(r.published_at || 0) > builtAt).length; + + return { + commit: version.commit, + built: version.built || null, + commits_behind: compare.ahead_by ?? 0, // main is ahead of us by this many + status: compare.status || "unknown", // identical | behind | ahead | diverged + latest_release: releases[0] ? releases[0].tag_name : null, + /** The release we are running, when our commit is exactly a released tag. */ + current_release: approximate ? null : releases[runningIndex].tag_name, + releases_behind: releasesBehind, + /** True when releases_behind is the timestamp guess rather than an identity + * match, which happens when the running commit is not itself a released + * tag (mid-cycle, or a local build). */ + releases_behind_approx: approximate, + /** Why the release could not be identified, when it could not. */ + releases_note: tagsError, + repo, + checked_at: new Date().toISOString(), + }; +} + +/** Whether an update check may be answered from the cache, and what to tell the + * operator if a forced check was refused. + * + * Three rules, and the third is the only interesting one. An ordinary request + * takes the cache while it is fresh, because six hours is right for an + * unattended check over Tor where GitHub rate limits shared exit nodes. A + * forced request goes out. A forced request inside the floor is answered from + * the cache with the wait attached rather than refused, because the operator + * asked what the state is and the honest answer is the last one known plus how + * stale it is. + * + * Pure, and separate from the route, so the floor can be tested without a + * reachable GitHub: the route only fills its cache on success, so an + * unreachable GitHub means the cached path is never taken and the floor never + * runs. A rule that cannot be exercised is a rule nobody has checked. + */ +export function updateCacheDecision({ cachedAt = null, now = Date.now(), forced = false, + forcedAt = 0, ttlMs = 6 * 3600 * 1000, floorMs = 60 * 1000 } = {}) { + const fresh = cachedAt !== null && now - cachedAt < ttlMs; + if (!fresh) return { serveCached: false, waitS: 0 }; + if (!forced) return { serveCached: true, waitS: 0 }; + const since = now - forcedAt; + if (forcedAt && since < floorMs) { + return { serveCached: true, waitS: Math.ceil((floorMs - since) / 1000) }; + } + return { serveCached: false, waitS: 0 }; +} diff --git a/docker/dojobay/sw.js b/docker/dojobay/sw.js new file mode 100644 index 00000000..626e8d1a --- /dev/null +++ b/docker/dojobay/sw.js @@ -0,0 +1,75 @@ +// The Dojo Bay — service worker. +// Strategy: +// * app shell (html, css, js, fonts, icons, content) is precached and served +// cache-first, so the installed PWA opens instantly and works offline; +// * live data (data/*.json) and the Markdown text (content/*.md) are fetched +// network-first so a connected client always sees the latest snapshot, +// falling back to cache when offline. +// Bump CACHE when you ship new assets to retire the old cache. +const CACHE = "dojobay-v2"; + +const SHELL = [ + "./", + "index.html", + "assets/css/styles.css", + "assets/js/app.js", + "assets/js/markdown.js", + "assets/js/qrcode.js", + "assets/fonts/archivo.woff2", + "assets/fonts/hanken-grotesk.woff2", + "assets/fonts/jetbrains-mono.woff2", + "favicon.svg", + "manifest.json", + "assets/icons/192x192.png", + "assets/icons/512x512.png", + "content/about.md", + "content/faq.md", + "content/disclaimer.md", +]; + +self.addEventListener("install", (e) => { + e.waitUntil( + caches.open(CACHE) + .then((c) => Promise.allSettled(SHELL.map((u) => c.add(u)))) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (e) => { + e.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (e) => { + const req = e.request; + if (req.method !== "GET") return; + const url = new URL(req.url); + if (url.origin !== self.location.origin) return; // ignore cross-origin + + // Cache-first ONLY for large, rarely-changing static assets (fonts, icons, + // images). Everything else (HTML, JS, CSS, JSON data, Markdown) is + // network-first, so a deploy propagates on the next load and the cache is + // only an offline fallback. This avoids stale code surviving a deploy. + const cacheFirst = /\.(woff2|png|svg|ico)$/.test(url.pathname); + + if (!cacheFirst) { + e.respondWith( + fetch(req) + .then((res) => { const copy = res.clone(); caches.open(CACHE).then((c) => c.put(req, copy)); return res; }) + .catch(() => caches.match(req)) + ); + } else { + e.respondWith( + caches.match(req).then((hit) => + hit || fetch(req).then((res) => { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(req, copy)); + return res; + }) + ) + ); + } +}); diff --git a/docker/dojobay/types.d.ts b/docker/dojobay/types.d.ts new file mode 100644 index 00000000..f2ce2e70 --- /dev/null +++ b/docker/dojobay/types.d.ts @@ -0,0 +1,148 @@ +// ============================================================================= +// Shared shapes for the type-checked JavaScript pass. +// +// Nothing here is compiled or shipped: `npm run typecheck` reads it, Node never +// sees it. It exists because the record shapes in this project have drifted +// repeatedly — detected_version, indexer_url and operator_domain were each added +// in a different commit, in three different places — and there was no single +// statement of what a node record actually is. Referencing these from JSDoc +// (`@type {import("../types.js").PublicNode}`) makes a drift a type error. +// ============================================================================= + +/** A node as published in data/dojos.json and rendered on a card. */ +export interface PublicNode { + id: string; + network: "mainnet" | "testnet"; + name: string; + status: "active" | "inactive"; + paynym: string | null; + paymentCode: string | null; + jurisdiction: string | null; + country: string | null; + hardware: string | null; + /** Effective version: the live-probed reading, else the pairing payload's. */ + version: string | null; + /** Read from the node's X-Dojo-Version response header by the updater. */ + detected_version: string | null; + /** Read from the node's /support/services by the updater. */ + detected_indexer: string | null; + /** Published Electrum endpoint: detected, else declared. Null renders N/A. */ + indexer_url: string | null; + /** The operator's verified domain, if they have proved one. */ + operator_domain: string | null; + /** Everything a reader needs to check that claim themselves, without + * trusting this instance: the TXT record to look up, and the signed + * statement to verify. All of it is already public. */ + operator_domain_proof: { + domain: string; + paymentCode: string; + txt_name: string; + txt_value: string; + signed: string; + verified_at: string | null; + } | null; + checked_at: string | null; + block_height: number | null; + payload: PairingPayload; + signed: string | null; +} + +export interface PairingPayload { + pairing: { + type: string; + version?: string; + apikey?: string; + url: string; + }; + explorer?: { type?: string; url?: string }; + // A Dojo export may carry these; the gate stores neither, because the + // signature covers pairing and explorer only. Present here so a parsed export + // types cleanly, not because anything reads them. See build-public.ts on why + // the Electrum endpoint is probed rather than declared. + indexer?: { type?: string; url?: string }; + services?: Array<{ type?: string; kind?: string; url?: string }>; +} + +/** An operator's submission as held in the store (server/data/store.json). */ +export interface StoreRecord { + id: string; + network: "mainnet" | "testnet"; + name: string; + /** Moderation state; ids are immutable so history survives a rename. */ + status: "pending" | "approved" | "rejected"; + /** A PayNym usually has two BIP47 variants; either may have signed. */ + paymentCodes: string[]; + payload: PairingPayload; + // Everything below is genuinely optional: records written by different paths + // (submission, migration, bootstrap import) carry different subsets, and an + // absent field and an explicit null both occur in the live store. + /** Removed from the UI and never published. Older records may still carry it. */ + name_url?: string | null; + paynym?: string | null; + jurisdiction?: string | null; + country?: string | null; + hardware?: string | null; + signed?: string | null; + /** The probe result recorded when the submission was accepted. */ + last_probe?: ProbeResult; + created_at?: string; + updated_at?: string; + /** Provenance when the record arrived via scripts/bootstrap-import. */ + source?: string; +} + +/** A verified operator domain, keyed by payment code. */ +export interface DomainClaim { + paymentCode: string; + domain: string; + /** The wallet-signed statement; permanent, unlike the TXT record. */ + signed: string; + verified: boolean; + verified_at: string | null; + last_check: string | null; + last_result: string | null; + /** Set when a re-check first fails; the grace period runs from here. */ + fail_since: string | null; + created_at: string; + revoked?: boolean; + also_claimed_by?: string | null; +} + +/** + * Transport settings a probe cannot work without. Marked required deliberately: + * omitting them is the bug that broke the installer's anchor check, where + * net.connect was handed an undefined port. + */ +export interface ProbeCfg { + proxyHost: string; + proxyPort: number; + timeoutMs: number; + /** Simultaneous Tor circuits. Only the cycle runner reads it; a single probe ignores it. */ + concurrency?: number; + apikey?: string; + network?: string; + connectOnly?: boolean; + dojoVersionHeader?: string; +} + +export interface ProbeResult { + up: boolean; + reason: string; + ms: number; + height?: number; + blockTime?: number | null; + detectedVersion?: string | null; + detectedIndexer?: string | null; +} + +// Front-end globals: assets/js/app.js is a plain script, and these are provided +// by the separate