| 1 | diff --git a/.dockerignore b/.dockerignore |
| 2 | new file mode 100644 |
| 3 | index 0000000..762d66a |
| 4 | --- /dev/null |
| 5 | +++ b/.dockerignore |
| 6 | @@ -0,0 +1,9 @@ |
| 7 | +.git |
| 8 | +.DS_Store |
| 9 | +.playwright-cli |
| 10 | +output |
| 11 | +artifacts |
| 12 | +node_modules |
| 13 | +*.db |
| 14 | +*.db-shm |
| 15 | +*.db-wal |
| 16 | diff --git a/.gitignore b/.gitignore |
| 17 | new file mode 100644 |
| 18 | index 0000000..47576d6 |
| 19 | --- /dev/null |
| 20 | +++ b/.gitignore |
| 21 | @@ -0,0 +1,19 @@ |
| 22 | +.DS_Store |
| 23 | +.playwright-cli/ |
| 24 | +output/ |
| 25 | +.nzip-*/ |
| 26 | +/bin/ |
| 27 | +/tiny |
| 28 | +/data/ |
| 29 | +*.db |
| 30 | +*.db-shm |
| 31 | +*.db-wal |
| 32 | +*.test |
| 33 | +coverage.out |
| 34 | +node_modules/ |
| 35 | +/artifacts/ |
| 36 | +__pycache__/ |
| 37 | +*.py[cod] |
| 38 | +.env |
| 39 | +.env.* |
| 40 | +!.env.example |
| 41 | diff --git a/AGENTS.md b/AGENTS.md |
| 42 | new file mode 100644 |
| 43 | index 0000000..f5c1965 |
| 44 | --- /dev/null |
| 45 | +++ b/AGENTS.md |
| 46 | @@ -0,0 +1,2 @@ |
| 47 | +- The middle dot character (U+00B7) is disallowed in project text and interfaces. |
| 48 | +- Write all documentation in AP style, with clear, concise organization. Documentation is a user-facing surface; omit incidental implementation notes. |
| 49 | diff --git a/Dockerfile b/Dockerfile |
| 50 | new file mode 100644 |
| 51 | index 0000000..05fcae9 |
| 52 | --- /dev/null |
| 53 | +++ b/Dockerfile |
| 54 | @@ -0,0 +1,37 @@ |
| 55 | +# syntax=docker/dockerfile:1 |
| 56 | + |
| 57 | +FROM golang:1.27.1-bookworm AS build |
| 58 | +WORKDIR /src |
| 59 | +COPY go.mod go.sum ./ |
| 60 | +RUN go mod download |
| 61 | +COPY . . |
| 62 | + |
| 63 | +ARG TEST_PACKAGES=./... |
| 64 | +FROM build AS test |
| 65 | +RUN go test -mod=mod -race ${TEST_PACKAGES} |
| 66 | + |
| 67 | +ARG BENCH_PACKAGES=./internal/storage ./internal/relay |
| 68 | +ARG BENCHTIME=5s |
| 69 | +FROM build AS bench |
| 70 | +ARG BENCH_PACKAGES |
| 71 | +ARG BENCHTIME |
| 72 | +RUN go test -mod=mod -run '^$' -bench . -benchmem -benchtime=${BENCHTIME} ${BENCH_PACKAGES} |
| 73 | + |
| 74 | +FROM build AS binary |
| 75 | +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/tiny ./cmd/tiny |
| 76 | + |
| 77 | +FROM debian:bookworm-slim AS runtime |
| 78 | +RUN apt-get update \ |
| 79 | + && apt-get install --no-install-recommends -y ca-certificates git \ |
| 80 | + && rm -rf /var/lib/apt/lists/* \ |
| 81 | + && useradd --system --create-home --home-dir /home/relay --uid 10001 relay \ |
| 82 | + && mkdir -p /data \ |
| 83 | + && chown relay:relay /data |
| 84 | +COPY --from=binary /out/tiny /usr/local/bin/tiny |
| 85 | +COPY LICENSE THIRD_PARTY_NOTICES internal/webui/signer.js.license /usr/share/doc/tiny/ |
| 86 | +USER relay |
| 87 | +WORKDIR /home/relay |
| 88 | +VOLUME ["/data"] |
| 89 | +ENV TINY_DATA_DIR=/data |
| 90 | +ENTRYPOINT ["/usr/local/bin/tiny"] |
| 91 | +CMD ["serve"] |
| 92 | diff --git a/LICENSE b/LICENSE |
| 93 | new file mode 100644 |
| 94 | index 0000000..2c71f7f |
| 95 | --- /dev/null |
| 96 | +++ b/LICENSE |
| 97 | @@ -0,0 +1,21 @@ |
| 98 | +MIT License |
| 99 | + |
| 100 | +Copyright (c) 2026 FelineStateMachine |
| 101 | + |
| 102 | +Permission is hereby granted, free of charge, to any person obtaining a copy |
| 103 | +of this software and associated documentation files (the "Software"), to deal |
| 104 | +in the Software without restriction, including without limitation the rights |
| 105 | +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| 106 | +copies of the Software, and to permit persons to whom the Software is |
| 107 | +furnished to do so, subject to the following conditions: |
| 108 | + |
| 109 | +The above copyright notice and this permission notice shall be included in all |
| 110 | +copies or substantial portions of the Software. |
| 111 | + |
| 112 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| 113 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| 114 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| 115 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| 116 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| 117 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| 118 | +SOFTWARE. |
| 119 | diff --git a/Makefile b/Makefile |
| 120 | new file mode 100644 |
| 121 | index 0000000..10700c0 |
| 122 | --- /dev/null |
| 123 | +++ b/Makefile |
| 124 | @@ -0,0 +1,24 @@ |
| 125 | +.PHONY: test test-race test-internal-race benchmark docker-test docker-build slate |
| 126 | + |
| 127 | +TEST_PACKAGES ?= ./... |
| 128 | + |
| 129 | +test: |
| 130 | + go test -mod=mod ./... |
| 131 | + |
| 132 | +test-race: |
| 133 | + go test -mod=mod -race ./... |
| 134 | + |
| 135 | +test-internal-race: |
| 136 | + go test -mod=mod -race $(TEST_PACKAGES) |
| 137 | + |
| 138 | +benchmark: |
| 139 | + go test -mod=mod ./internal/storage ./internal/relay -run '^$$' -bench . -benchmem -benchtime=$${BENCHTIME:-1s} |
| 140 | + |
| 141 | +docker-test: |
| 142 | + docker build --target test --build-arg TEST_PACKAGES="$(TEST_PACKAGES)" -t tinyrelay-test:local . |
| 143 | + |
| 144 | +docker-build: |
| 145 | + docker build --target runtime -t tinyrelay:local . |
| 146 | + |
| 147 | +slate: |
| 148 | + ./scripts/test-slate.sh |
| 149 | diff --git a/README.md b/README.md |
| 150 | new file mode 100644 |
| 151 | index 0000000..1665769 |
| 152 | --- /dev/null |
| 153 | +++ b/README.md |
| 154 | @@ -0,0 +1,80 @@ |
| 155 | +# tiny |
| 156 | + |
| 157 | +`tiny` is a self-hosted, multitenant Nostr relay. Each tenant has durable local storage, an owner, relay policy, inbox and outbox delivery, hosted sites, file storage, and optional repository browsing. |
| 158 | + |
| 159 | +## Run locally |
| 160 | + |
| 161 | +Build and start the daemon: |
| 162 | + |
| 163 | +```sh |
| 164 | +go build -o tiny ./cmd/tiny |
| 165 | +./tiny serve --data-dir ./data --listen :7447 |
| 166 | +``` |
| 167 | + |
| 168 | +For a public deployment, set the relay URL and provision the owner key: |
| 169 | + |
| 170 | +```sh |
| 171 | +./tiny serve --data-dir /var/lib/tiny --listen 127.0.0.1:7447 \ |
| 172 | + --public-url https://relay.example \ |
| 173 | + --provision-owner <64-lowercase-hex-pubkey> |
| 174 | +``` |
| 175 | + |
| 176 | +The command is `tiny`. Useful commands include: |
| 177 | + |
| 178 | +```text |
| 179 | +tiny serve [--data-dir PATH] [--listen :7447] |
| 180 | +tiny tenant create --name NAME --owner PUBKEY [--template default] [--source wss://...] |
| 181 | +tiny tenant list|enable|disable|host [options] |
| 182 | +tiny templates |
| 183 | +tiny version |
| 184 | +``` |
| 185 | + |
| 186 | +Create a permanent tenant with a template: |
| 187 | + |
| 188 | +```sh |
| 189 | +tiny tenant create --data-dir /var/lib/tiny \ |
| 190 | + --name community --owner <64-lowercase-hex-pubkey> \ |
| 191 | + --template chat |
| 192 | +``` |
| 193 | + |
| 194 | +Run `tiny templates` to see available templates. Templates that read from an upstream relay accept `--source wss://relay.example`. |
| 195 | + |
| 196 | +## Configure and operate |
| 197 | + |
| 198 | +The web interface uses plain HTML pages and forms. Connect a Nostr signer with NIP-07 or Nostr Connect to sign in and authorize management actions. Use the management pages to configure policy, members, relay connections, inbox and outbox jobs, backups, hosted sites, and stored files. |
| 199 | + |
| 200 | +The browsing pages provide repository lists, source files, branches, history, diffs, file previews, raw downloads, synchronization status, and storage status. Access follows the tenant's current permissions. |
| 201 | + |
| 202 | +The `/tools` page reports whether the browser supports native WebMCP controls. See [WebMCP tools](docs/webmcp.md) for browser-agent integration and authorization behavior. |
| 203 | + |
| 204 | +## Deployment |
| 205 | + |
| 206 | +Build the container image: |
| 207 | + |
| 208 | +```sh |
| 209 | +docker build --target runtime -t tiny:local . |
| 210 | +docker run --rm -p 7447:7447 -v tiny-data:/data \ |
| 211 | + tiny:local serve --listen :7447 --public-url https://relay.example |
| 212 | +``` |
| 213 | + |
| 214 | +For systemd, install [`deploy/tiny.service`](deploy/tiny.service), create `/etc/tiny/tiny.env`, then run: |
| 215 | + |
| 216 | +```sh |
| 217 | +sudo systemctl daemon-reload |
| 218 | +sudo systemctl enable --now tiny |
| 219 | +``` |
| 220 | + |
| 221 | +Keep the data directory on local durable storage. The process exposes `/healthz` and `/readyz`. An optional diagnostics listener provides `/metrics` and protected pprof endpoints. Set `--otlp-endpoint` or `OTEL_EXPORTER_OTLP_ENDPOINT` when exporting traces. |
| 222 | + |
| 223 | +See [Personal relay deployment](docs/personal-relay.md) for the current Slate installation. |
| 224 | + |
| 225 | +## Test and diagnose |
| 226 | + |
| 227 | +```sh |
| 228 | +make test |
| 229 | +make test-race |
| 230 | +make docker-test |
| 231 | +make benchmark |
| 232 | +``` |
| 233 | + |
| 234 | +Run the Linux race suite on Slate with `SLATE_HOST=slate ./scripts/test-slate.sh`. Run the conformance harness against a started daemon with `node scripts/conformance/run.mjs`; results are written under `artifacts/conformance/`. |
| 235 | diff --git a/THIRD_PARTY_NOTICES b/THIRD_PARTY_NOTICES |
| 236 | new file mode 100644 |
| 237 | index 0000000..b912b6e |
| 238 | --- /dev/null |
| 239 | +++ b/THIRD_PARTY_NOTICES |
| 240 | @@ -0,0 +1,63 @@ |
| 241 | +tinyrelay third-party notices |
| 242 | + |
| 243 | +nostr-tools browser signer bundle |
| 244 | +--------------------------------- |
| 245 | +The generated internal/webui/signer.js bundle is built from nostr-tools and |
| 246 | +its browser dependencies. nostr-tools is released under the Unlicense: |
| 247 | + |
| 248 | +This is free and unencumbered software released into the public domain. |
| 249 | + |
| 250 | +Anyone is free to copy, modify, publish, use, compile, sell, or distribute |
| 251 | +this software, either in source code form or as a compiled binary, for any |
| 252 | +purpose, commercial or non-commercial, and by any means. |
| 253 | + |
| 254 | +The bundled cryptographic dependencies include @noble/hashes, |
| 255 | +@noble/ciphers, @noble/curves, and @scure/base. They are MIT licensed. The |
| 256 | +complete notice for these bundled dependencies is reproduced below: |
| 257 | + |
| 258 | +The MIT License (MIT) |
| 259 | + |
| 260 | +Copyright (c) 2022 Paul Miller (https://paulmillr.com) |
| 261 | +Copyright (c) 2016 Thomas Pornin <pornin@bolet.org> (for @noble/ciphers) |
| 262 | + |
| 263 | +Permission is hereby granted, free of charge, to any person obtaining a copy |
| 264 | +of this software and associated documentation files (the "Software"), to deal |
| 265 | +in the Software without restriction, including without limitation the rights |
| 266 | +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| 267 | +copies of the Software, and to permit persons to whom the Software is |
| 268 | +furnished to do so, subject to the following conditions: |
| 269 | + |
| 270 | +The above copyright notice and this permission notice shall be included in |
| 271 | +all copies or substantial portions of the Software. |
| 272 | + |
| 273 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| 274 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| 275 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| 276 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| 277 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| 278 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN |
| 279 | +THE SOFTWARE. |
| 280 | + |
| 281 | +Fixi BSD-0 notice |
| 282 | +----------------- |
| 283 | +BSD Zero Clause License |
| 284 | + |
| 285 | +Copyright (c) The Fixi Project contributors |
| 286 | + |
| 287 | +Permission to use, copy, modify, and/or distribute this software for any |
| 288 | +purpose with or without fee is hereby granted. |
| 289 | + |
| 290 | +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES |
| 291 | +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF |
| 292 | +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR |
| 293 | +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES |
| 294 | +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN |
| 295 | +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF |
| 296 | +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. |
| 297 | + |
| 298 | +Fixi Project |
| 299 | +------------ |
| 300 | +If the optional local Fixi bundle is vendored, it is sourced from |
| 301 | +https://fixiproject.org/ and the project states BSD-0 licensing for fixi, |
| 302 | +moxi, paxi, ssexi, and rexi. Preserve the upstream notice with the vendored |
| 303 | +files. |
| 304 | diff --git a/architecture.html b/architecture.html |
| 305 | new file mode 100644 |
| 306 | index 0000000..d7ae415 |
| 307 | --- /dev/null |
| 308 | +++ b/architecture.html |
| 309 | @@ -0,0 +1,433 @@ |
| 310 | +<!doctype html> |
| 311 | +<html lang="en"> |
| 312 | +<head> |
| 313 | +<meta charset="utf-8"> |
| 314 | +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| 315 | +<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%23245d43'/%3E%3Cpath d='M9 9h6v6H9zm8 0h6v6h-6zM9 17h6v6H9z' fill='%23dfebda'/%3E%3Cpath d='M17 17h6v6h-6z' fill='%23e89966'/%3E%3C/svg%3E"> |
| 316 | +<meta name="description" content="An architectural proposal for tinyrelay: templated, on-demand, self-hosted Nostr relays in Go."> |
| 317 | +<title>tinyrelay — Architecture for a relay you own</title> |
| 318 | +<style> |
| 319 | +:root{--bg:#f5f4ef;--paper:#fffef9;--ink:#182820;--muted:#56635b;--line:#ccd3c8;--green:#245d43;--mint:#dfebda;--orange:#a34724;--warm:#f6e6d5;--code:#192b23;--white:#f8faf5;--sidebar:222px} |
| 320 | +*{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:28px}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}a{color:var(--green);text-underline-offset:3px}a:hover{text-decoration-thickness:2px}button,select{font:inherit}button{cursor:pointer}button:focus-visible,select:focus-visible,a:focus-visible,summary:focus-visible{outline:3px solid var(--orange);outline-offset:4px}button{color:inherit}::selection{background:var(--mint)}.skip{position:absolute;left:12px;top:-100px;background:var(--paper);padding:10px;z-index:10}.skip:focus{top:12px} |
| 321 | +.rail{position:fixed;inset:0 auto 0 0;width:var(--sidebar);border-right:1px solid var(--line);padding:31px 25px;display:flex;flex-direction:column;background:var(--bg);overflow:auto}.brand{font-weight:760;font-size:23px;letter-spacing:-1.2px;color:var(--ink);text-decoration:none;display:flex;align-items:center;gap:10px}.mark{width:24px;height:24px;display:grid;grid-template-columns:repeat(2,8px);gap:3px;transform:rotate(-8deg)}.mark i{background:var(--green);border-radius:2px}.mark i:last-child{background:var(--orange)}.rail .sub{font-size:10px;letter-spacing:1.6px;text-transform:uppercase;margin:12px 0 38px;color:var(--muted)}nav{display:grid;gap:3px}nav a{display:flex;gap:13px;align-items:center;font-size:13px;line-height:1.35;text-decoration:none;padding:9px 0;color:var(--muted)}nav a span{font:10px ui-monospace,SFMono-Regular,monospace;color:var(--muted)}nav a.active,nav a:hover{color:var(--green);font-weight:700}.rail-foot{margin-top:auto;padding-top:35px;font-size:11px;color:var(--muted);line-height:1.6}.rail-foot b{display:block;color:var(--ink);font-size:12px}.rail-foot .dot{display:inline-block;width:6px;height:6px;background:var(--green);border-radius:50%;margin-right:5px} |
| 322 | +main{margin-left:var(--sidebar);max-width:1510px;padding:0 64px 50px}.topline{display:flex;justify-content:space-between;align-items:center;padding:27px 0;border-bottom:1px solid var(--line);font-size:11px;letter-spacing:1.3px;text-transform:uppercase;gap:15px}.topline button{border:0;background:transparent;padding:4px;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:1px}.hero{padding:67px 0 42px}.eyebrow{font:11px/1.4 ui-monospace,SFMono-Regular,monospace;letter-spacing:1.8px;text-transform:uppercase;color:var(--green);display:flex;align-items:center;gap:10px}.eyebrow:before{content:"";height:7px;width:7px;background:var(--orange);display:inline-block}.hero h1{font-size:clamp(42px,5.4vw,78px);line-height:1.06;letter-spacing:-3.3px;margin:23px 0 25px;max-width:900px;font-weight:680}.hero h1 em{font-family:Georgia,serif;font-weight:400;color:var(--green)}.lead{max-width:790px;color:var(--muted);font-size:20px;line-height:1.6}.hero-foot{display:flex;flex-wrap:wrap;gap:8px;margin:26px 0}.pill{border:1px solid var(--line);padding:5px 10px;font-size:11px;border-radius:3px;letter-spacing:.3px}.pill.strong{background:var(--green);border-color:var(--green);color:var(--white)}.principles{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.principle{padding:26px 22px 28px 0}.principle+.principle{border-left:1px solid var(--line);padding-left:24px}.principle .big{font-size:27px;letter-spacing:-1px;line-height:1.2;margin:7px 0 11px}.principle p{font-size:13px;margin:0;color:var(--muted)}.micro{font:10px ui-monospace,SFMono-Regular,monospace;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted)} |
| 323 | +section{padding:61px 0 7px;scroll-margin-top:14px}section+section{margin-top:35px;border-top:1px solid var(--line)}.section-head{display:flex;gap:22px;align-items:flex-start;margin-bottom:24px}.section-num{font:12px ui-monospace,SFMono-Regular,monospace;color:var(--orange);padding-top:12px}.section-head h2{font-size:34px;line-height:1.2;letter-spacing:-1.1px;margin:0 0 11px;font-weight:650}.section-head p{color:var(--muted);margin:0;max-width:800px;font-size:16px}h3{font-size:19px;line-height:1.35;letter-spacing:-.4px;margin:0 0 12px}h4{font-size:14px;margin:0 0 8px}p{margin:0 0 15px}.fine{font-size:12px;color:var(--muted)}.source{font-size:11px;line-height:1.65;color:var(--muted);margin-top:13px}.source a{overflow-wrap:anywhere}.note{padding:18px 22px;border-left:3px solid var(--green);background:var(--mint);font-size:14px;margin:23px 0}.note.warm{background:var(--warm);border-color:var(--orange)}.note p:last-child{margin:0}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:32px}.three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:25px}.block{background:var(--paper);border:1px solid var(--line);padding:23px}.block p,.block li{font-size:14px}.block p:last-child{margin-bottom:0}ul,ol{padding-left:20px;margin:10px 0 17px}li{padding-left:3px;margin:7px 0}code{font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;overflow-wrap:anywhere;background:#e8ece3;padding:2px 4px;border-radius:2px}pre{background:var(--code);color:#e1efde;padding:23px;overflow:auto;line-height:1.65;font-size:12px;margin:0;border-radius:3px;tab-size:2}pre code{background:none;color:inherit;padding:0;font-size:inherit;white-space:pre;overflow-wrap:normal} |
| 324 | +.table-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:13px;line-height:1.55}th{text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:1.4px;color:var(--muted);font-weight:600;padding:13px 12px;border-bottom:2px solid var(--ink)}td{padding:17px 12px;border-bottom:1px solid var(--line);vertical-align:top}td:first-child{font-weight:620;min-width:142px}thead th:first-child,tbody td:first-child{padding-left:0}tbody tr:last-child td{border-bottom:0}.phenotype td:nth-child(2){color:var(--muted);width:30%}.phenotype td:nth-child(3){width:45%}.tag{font:9px/1.3 ui-monospace,SFMono-Regular,monospace;text-transform:uppercase;letter-spacing:.7px;display:inline-block;padding:4px 6px;background:var(--mint);color:var(--green);margin-bottom:5px}.tag.drop{color:var(--orange);background:var(--warm)} |
| 325 | +.map{background:var(--paper);border:1px solid var(--line);padding:28px}.map-head{display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap;margin-bottom:25px}.map-head p{margin:0;font-size:11px;text-transform:uppercase;letter-spacing:1.2px}.path-buttons{display:flex;flex-wrap:wrap;gap:5px}.path-buttons button{background:transparent;border:1px solid var(--line);border-radius:3px;padding:7px 12px;font-size:12px}.path-buttons button[aria-pressed=true]{background:var(--green);color:var(--white);border-color:var(--green)}.actors{display:grid;grid-template-columns:1fr 32px 1fr 32px 1fr;align-items:stretch;gap:0}.actor{border:1px solid var(--line);padding:17px;min-width:0;background:var(--bg);position:relative;transition:background .15s,border-color .15s}.actor strong{display:block;font-size:15px;margin:5px 0}.actor small{font-size:12px;line-height:1.55;display:block;color:var(--muted)}.actor.hot{background:var(--mint);border-color:var(--green)}.actor.hot .micro{color:var(--green)}.arrow{display:flex;align-items:center;justify-content:center;font-size:21px;color:var(--muted)}.tier{margin-top:18px;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.tier-label{margin-top:23px;margin-bottom:9px}.telemetry-strip{margin-top:18px;padding:13px 15px;background:var(--code);color:var(--white);font:11px/1.6 ui-monospace,SFMono-Regular,monospace}.map-reading{border-top:1px solid var(--line);margin-top:23px;padding-top:21px;display:grid;grid-template-columns:160px 1fr;gap:20px;min-height:113px}.map-reading h3{font-size:16px}.map-reading p{font-size:13px;color:var(--muted);margin:0}.map-reading strong{color:var(--ink)}.flow{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:20px 0}.flow span{padding:8px 12px;border:1px solid var(--line);font:12px ui-monospace,SFMono-Regular,monospace}.flow b{font-size:17px;font-weight:400;color:var(--green)}.template-head{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.template-head label{font-size:12px;font-weight:600}.template-head select{background:var(--paper);border:1px solid var(--line);padding:8px;max-width:100%;color:var(--ink);font-size:13px}.template-info{padding:23px;background:var(--mint);border:1px solid var(--line)}.template-info dl{margin:18px 0 0;display:grid;grid-template-columns:85px 1fr;gap:11px 16px;font-size:13px}.template-info dt{color:var(--muted)}.template-info dd{margin:0}.pipeline{counter-reset:step;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:25px 0}.pipeline div{padding:21px 18px;border-top:2px solid var(--green);border-right:1px solid var(--line);position:relative}.pipeline div:last-child{border-right:0}.pipeline div:before{counter-increment:step;content:"0" counter(step);display:block;font:10px ui-monospace,SFMono-Regular,monospace;color:var(--green);margin-bottom:12px}.pipeline strong{display:block;font-size:14px;margin-bottom:8px}.pipeline p{font-size:12px;margin:0;color:var(--muted)}details{border-top:1px solid var(--line);padding:17px 0}details:last-child{border-bottom:1px solid var(--line)}summary{cursor:pointer;font-size:15px;font-weight:630;list-style-position:outside;margin-left:17px}details .detail-body{padding:18px 0 0 17px;font-size:14px}.metric{font-family:ui-monospace,SFMono-Regular,monospace;font-size:11px;font-weight:400!important;overflow-wrap:anywhere}.decision-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 35px}.decision{padding:24px 0;border-top:1px solid var(--line)}.decision .micro{color:var(--orange);margin-bottom:10px}.decision p{font-size:14px}.roadmap{display:grid;grid-template-columns:92px 1fr;gap:0 25px}.phase{padding:23px 0;border-top:1px solid var(--line)}.phase-code{color:var(--green);font:12px ui-monospace,SFMono-Regular,monospace;padding-top:27px;border-top:1px solid var(--line)}.phase p{font-size:14px}.gate{font-size:12px;border-left:2px solid var(--green);padding-left:12px;color:var(--muted)}.reference-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px 35px}.reference-grid p{font-size:12px;margin:0;border-bottom:1px solid var(--line);padding:0 0 13px}.reference-grid a{font-size:13px;font-weight:600}.foot{border-top:1px solid var(--line);margin-top:60px;padding-top:25px;display:flex;justify-content:space-between;gap:20px;font-size:11px;color:var(--muted)}.no-js{padding:12px;background:var(--warm);font-size:13px} |
| 326 | +@media(min-width:1600px){main{margin-right:auto}}@media(max-width:1150px){:root{--sidebar:185px}.rail{padding:28px 18px}main{padding:0 34px 40px}.hero h1{font-size:58px}.three{grid-template-columns:1fr}.pipeline{grid-template-columns:repeat(2,minmax(0,1fr))}.pipeline div:nth-child(2){border-right:0}.pipeline div:nth-child(n+3){border-top:1px solid var(--line)}}@media(max-width:850px){:root{--sidebar:0px}.rail{position:static;width:auto;border-right:0;border-bottom:1px solid var(--line);padding:18px 24px;display:block}.rail .sub,.rail-foot{display:none}.rail nav{display:flex;overflow-x:auto;margin-top:14px;gap:19px}.rail nav a{white-space:nowrap;font-size:12px;padding:5px 0}.rail nav a span{display:none}main{margin:0;padding:0 24px 30px}.topline{padding:20px 0}.hero{padding:45px 0 30px}.hero h1{font-size:55px}.two{gap:23px}.section-head h2{font-size:29px}.map{padding:22px}.map-reading{grid-template-columns:1fr;gap:5px}.reference-grid{gap:14px 22px}} |
| 327 | +@media(max-width:580px){body{font-size:15px}main{padding:0 19px 28px}.rail{padding:17px 19px}.topline{font-size:9px;letter-spacing:.8px}.topline button{font-size:10px}.hero h1{font-size:43px;letter-spacing:-2px}.hero .lead{font-size:17px}.principles{grid-template-columns:1fr}.principle,.principle+.principle{padding:20px 0;border-left:0}.principle+.principle{border-top:1px solid var(--line)}.principle .big{font-size:23px}.section-head{gap:14px}.section-head h2{font-size:28px}.section-head p{font-size:14px}section{padding-top:38px}section+section{margin-top:28px}.two,.decision-grid,.reference-grid{grid-template-columns:1fr}.actors{grid-template-columns:1fr}.arrow{height:30px;transform:rotate(90deg)}.tier{grid-template-columns:1fr;gap:12px}.map{padding:17px}.map-head{gap:13px}.path-buttons{gap:6px}.path-buttons button{padding:9px 10px}.pipeline{grid-template-columns:1fr}.pipeline div{border-right:0!important;border-top:1px solid var(--line)}.template-head{align-items:flex-start;flex-direction:column}.template-head select{width:100%}.block,.template-info{padding:18px}pre{font-size:11px;padding:18px}.roadmap{grid-template-columns:51px 1fr;gap:0 13px}.phase-code{font-size:10px}.foot{flex-direction:column;gap:5px}th,td{padding:12px 10px}.phenotype{min-width:610px}table.stack{min-width:610px}details .detail-body{padding-left:0}.note{padding:16px;font-size:13px}.pill{font-size:10px}.topline span{max-width:230px}} |
| 328 | +@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*{transition:none!important}} |
| 329 | +.two>*{min-width:0}pre{max-width:100%} |
| 330 | +@media print{body{font-size:10pt;background:white}.rail,.topline button,.path-buttons,.template-head select{display:none}main{margin:0;padding:0;max-width:none}.hero{padding:25px 0}.hero h1{font-size:38pt}.lead{font-size:13pt}section{padding-top:25px;break-inside:auto}section+section{margin-top:20px}.section-head h2{font-size:23pt}.block,.actor,.template-info,.note,pre{break-inside:avoid}pre{white-space:pre-wrap;background:#f3f3ef;color:#111}pre code{white-space:pre-wrap}.table-wrap{overflow:visible}td,th{font-size:9pt;padding:9px}a{color:inherit}details{break-inside:avoid}.hero-foot{margin:15px 0}.topline{padding:10px 0}.foot{margin-top:25px}} |
| 331 | +</style> |
| 332 | +</head> |
| 333 | +<body> |
| 334 | +<a href="#overview" class="skip">Skip to architecture</a> |
| 335 | +<aside class="rail"> |
| 336 | + <a class="brand" href="#overview"><span class="mark" aria-hidden="true"><i></i><i></i><i></i><i></i></span>tinyrelay</a> |
| 337 | + <div class="sub">Architecture proposal / 01</div> |
| 338 | + <nav aria-label="Plan contents"> |
| 339 | + <a href="#overview"><span>00</span>Direction</a> |
| 340 | + <a href="#phenotype"><span>01</span>Project phenotype</a> |
| 341 | + <a href="#architecture"><span>02</span>System architecture</a> |
| 342 | + <a href="#tenancy"><span>03</span>Names & templates</a> |
| 343 | + <a href="#delivery"><span>04</span>Inbox & outbox</a> |
| 344 | + <a href="#storage"><span>05</span>Storage & correctness</a> |
| 345 | + <a href="#o11y"><span>06</span>Observability</a> |
| 346 | + <a href="#performance"><span>07</span>Performance loop</a> |
| 347 | + <a href="#reuse"><span>08</span>SDKs & prior art</a> |
| 348 | + <a href="#operations"><span>09</span>Self-host operations</a> |
| 349 | + <a href="#delivery-plan"><span>10</span>Build & migration</a> |
| 350 | + <a href="#decisions"><span>11</span>Decision record</a> |
| 351 | + <a href="#sources"><span>12</span>Evidence & sources</a> |
| 352 | + </nav> |
| 353 | + <div class="rail-foot"><b><span class="dot"></span> Native implementation in progress</b>Based on bindws @ 5d31267<br>Research checked 07 Sep 2026<br>Designed for machines you own.</div> |
| 354 | +</aside> |
| 355 | +<main> |
| 356 | +<div class="topline"><span>Design study | Bindws → native Go</span><button type="button" id="print">Print / save PDF ↗</button></div> |
| 357 | +<header class="hero" id="overview"> |
| 358 | + <div class="eyebrow">Self-hosted Nostr infrastructure</div> |
| 359 | + <h1>Many relays.<br>One process.<br><em>Your machine.</em></h1> |
| 360 | + <p class="lead">Bring bindws’s full capabilities and user workflows to a native, self-hosted Go service. Preserve the experience of creating and running templated relays while replacing the Worker runtime with local services.</p> |
| 361 | + <div class="hero-foot"><span class="pill strong">Full functional & UX parity</span><span class="pill strong">No fuel or leases</span><span class="pill">No product quotas</span><span class="pill">Automatic inbox / outbox</span><span class="pill">Observability from day one</span></div> |
| 362 | + <p class="fine">Recommendation: a modular monolith with an embedded control plane, a SQLite database per named relay, and shared workers. A personal template provisions a family of relays. Public and management pages are plain HTML and basic forms, rebuilt without bindws branding or styling.</p> |
| 363 | + <div class="note"><strong>Measured Git results | 7 September 2026.</strong> Six repositories from the local Developer folder passed push, clone, fetch, and concurrent clone tests on Slate. Streaming reduced peak Go RSS from 6,094 MiB to 40.6 MiB; Atlas cloned in 9.15 seconds against an 8.92-second native Git baseline. <a href="artifacts/git-performance/git-performance.html">Open the report, failures, methodology and raw data.</a></div> |
| 364 | + <div class="note"><strong>Completion contract: work until parity.</strong> The finished Go project must cover every existing bindws feature, protocol behavior, management action and user journey, except the explicitly agreed changes below. Intermediate phases are implementation checkpoints; a working core relay is not completion. Features that owners can switch off still have to be implemented and tested.</div> |
| 365 | +</header> |
| 366 | +<div class="principles"> |
| 367 | + <div class="principle"><div class="micro">01 / Product</div><div class="big">Preserve the workflows.</div><p>All relay, community, hosting and management capabilities carry forward. Templates remain the API; visual simplicity does not reduce functionality.</p></div> |
| 368 | + <div class="principle"><div class="micro">02 / Runtime</div><div class="big">Local by default.</div><p>One daemon, local disk, long-lived sockets and restart-safe jobs. Capacity is determined by the machine and its workload.</p></div> |
| 369 | + <div class="principle"><div class="micro">03 / Engineering</div><div class="big">Measure before tuning.</div><p>Instrument acceptance, query, fan-out and delivery before optimization. Use traces and profiles to explain benchmark results.</p></div> |
| 370 | +</div> |
| 371 | +<noscript><p class="no-js">The full plan is readable without JavaScript. Interactive path and template previews require JavaScript; their default views remain visible.</p></noscript> |
| 372 | + |
| 373 | +<section id="phenotype"> |
| 374 | + <div class="section-head"><span class="section-num">01</span><div><h2>The runtime changes. The workflows carry forward.</h2><p>Target full bindws parity on a local host. Rework provider-shaped implementation details while preserving what users can do, how they authorize it, and the result they receive.</p></div></div> |
| 375 | + <div class="table-wrap"><table class="phenotype"> |
| 376 | + <thead><tr><th>Trait</th><th>Bindws today</th><th>Proposed tinyrelay</th></tr></thead> |
| 377 | + <tbody> |
| 378 | + <tr><td>Identity of the product</td><td>On-demand hosted relays with a console, fuel and many hosting features.</td><td><span class="tag">Full parity</span><br>A self-hosted relay factory with the complete relay, community, hosting and management surface. Personal and community relays remain template instances.</td></tr> |
| 379 | + <tr><td>Provisioning</td><td>Unclaimed → leased or claimed. A lease lasts 14 days and can be claimed in place.</td><td><span class="tag drop">Remove</span><br>Creation requires an owner and template. No lease endpoint, trial state, claim conversion, trial retention or expiry teardown.</td></tr> |
| 380 | + <tr><td>Economics & limits</td><td>Fuel credits, free allowances, paid overages, fixed job/queue ceilings and per-member caps.</td><td><span class="tag drop">Remove</span><br>No billing ledger, fuel gate, built-in tenant quotas or paid capacity tiers. Preserve explicit content policy and host-level backpressure.</td></tr> |
| 381 | + <tr><td>Unit of execution</td><td>One Durable Object per name; hibernating sockets and alarm rounds.</td><td><span class="tag">Replace</span><br>A lazily loaded relay runtime with owned goroutines; shared scheduling continues when no users are connected.</td></tr> |
| 382 | + <tr><td>Template model</td><td>JSONC presets compile into the Worker; omitted configuration sections stay unchanged.</td><td><span class="tag">Evolve</span><br>Embedded catalog + local files, immutable revisions, plan/apply/export and first-class inbox/outbox rules. No tenant-supplied executable code.</td></tr> |
| 383 | + <tr><td>Automatic delivery</td><td>Opt-in durable NIP-65 queue using locally stored lists. Pending queue capped at 512; four attempts; full queues can omit delivery.</td><td><span class="tag">Evolve</span><br>Template-driven routing; durable intent in the event transaction; list discovery, connection reuse, retry state and visible terminal outcomes. No fixed tenant delivery ceiling.</td></tr> |
| 384 | + <tr><td>Public inbox</td><td>Kind allowlist with 90-day retention. Template comments acknowledge that the owner-tag predicate is missing.</td><td><span class="tag">Complete</span><br>Require intended-recipient targeting. Continuously gather matching public mentions from configured sources; retention is an explicit content choice.</td></tr> |
| 385 | + <tr><td>Persistence</td><td>Durable Object SQLite + R2; SQL and storage shaped by provider restrictions.</td><td><span class="tag">Replace</span><br>Catalog database + tenant SQLite files and content-addressed blobs on local disk. Preserve events, files, sites, repositories, history and backups; S3 is an optional storage adapter.</td></tr> |
| 386 | + <tr><td>Names & domains</td><td>Wildcard host routing, HOSTS KV and Cloudflare for SaaS; celld has a manual hostname map.</td><td><span class="tag">Replace</span><br>Explicit catalog aliases, host or path routing, TLS at the operator’s proxy. Unknown hosts never allocate a relay.</td></tr> |
| 387 | + <tr><td>Owner & community</td><td>Nostr ownership, members, bans, moderators, invitations, group records and relay signing identity.</td><td><span class="tag">Preserve</span><br>Keep membership, invite trees, guest rules, moderation, groups, transfer and succession. Separate host administrator from relay owner; retain Nostr authorization.</td></tr> |
| 388 | + <tr><td>Site & management UI</td><td>Branded public pages and a generated console bundle with many feature tabs.</td><td><span class="tag">Rebuild presentation</span><br>Every existing action and role-specific workflow remains available through plain HTML/forms. Preserve sign-in, previews, progress, recovery, QR and app handoff. No inherited visual branding.</td></tr> |
| 389 | + <tr><td>Hosting & computed services</td><td>Blossom/NIP-96, sites, GRASP Git, Marmot, views, succession and NIP-9a callbacks.</td><td><span class="tag">Required for parity</span><br>Implement all of them on local storage and workers. Preserve feature toggles and their existing access rules. Stage the work, but do not finish with these features missing.</td></tr> |
| 390 | + <tr><td>Observability</td><td>Usage meters, JSON logs, Cloudflare traces and optional Analytics Engine.</td><td><span class="tag">Replace</span><br>Prometheus metrics, structured logs, optional OTel traces and protected pprof/runtime diagnostics. No telemetry feeds a fuel calculation.</td></tr> |
| 391 | + </tbody> |
| 392 | + </table></div> |
| 393 | + <p class="source">Local evidence: <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/docs/10-architecture.md">architecture</a>, <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/docs/16-hosting-without-cloudflare.md">celld hosting</a>, <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/src/delivery.ts">delivery.ts:7–113</a>, <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/relay-templates/03-inbox.jsonc">inbox template:1–42</a>. Inspected in the adjacent checkout; links identify the corresponding revision.</p> |
| 394 | + <div class="note"><strong>“No resource limits” is a product commitment.</strong> There is no maximum relay count, monthly event allowance, fuel balance or tenant storage tier in the domain model. The host still has finite RAM, disk and file descriptors. Flow control uses finite buffers, fair scheduling and connection deadlines; overload returns a clear retryable failure before acceptance. Retention, bans and authorized access remain owner-selected relay behavior.</div> |
| 395 | + <div class="note"><strong>Product UI layout.</strong> The rebuilt public and management pages use bare HTML, tables and basic forms. The fixed layout target is 960px on desktop and 360px on mobile, with no inherited bindws branding or visual system. When useful, a locally vendored Fixi project bundle (fixi, moxi, paxi, ssexi and rexi) may provide progressive form swaps, inline actions, morphing, streamed job progress and ergonomic requests. It remains an enhancement layer: signed NIP-98 mutations, accessible forms and server responses remain usable without it.</div> |
| 396 | + <details open><summary>Explicit changes to the parity baseline</summary><div class="detail-body"><ul><li><strong>Remove fuel and product capacity entitlements:</strong> no balance, top-up, billing ledger, paid tier or inherited fixed tenant quota. NIP-57 zap event support and non-fuel notifications remain in scope.</li><li><strong>Remove relay leases and trials:</strong> assign ownership at creation, including fork destinations. Preserve membership invitation codes and their claim/join workflows; those are unrelated to leasing a relay.</li><li><strong>Replace cloud infrastructure:</strong> Workers, Durable Objects, R2/KV bindings, alarms and provider domain provisioning become local Go services. Preserve their user-facing capabilities through local implementations.</li><li><strong>Rebuild presentation:</strong> plain HTML and forms, no bindws style or branding. Keep task flows, permissions, signers, feedback, sharing and accessible alternatives. The command is <code>tiny</code>.</li><li><strong>Improve inbox/outbox and observability:</strong> add targeted inbox admission, persistent automation and measurement-driven performance work. Document behavioral improvements explicitly; do not silently reinterpret imported policies.</li></ul><p>These changes do not authorize dropping unrelated features. Keep a precise source-to-target mapping for every exception and verify that its replacement workflow is complete.</p></div></details> |
| 397 | +</section> |
| 398 | + |
| 399 | +<section id="architecture"> |
| 400 | + <div class="section-head"><span class="section-num">02</span><div><h2>A small process with clear ownership.</h2><p>The tenant boundary is a named relay. Each runtime owns its policy, subscriptions and store. Cross-relay work goes through explicit services.</p></div></div> |
| 401 | + <div class="map"> |
| 402 | + <div class="map-head"><p>One host / one daemon</p><div class="path-buttons" role="group" aria-label="Highlight an architectural path"><button type="button" data-path="publish" aria-pressed="true">Publish</button><button type="button" data-path="subscribe" aria-pressed="false">Subscribe</button><button type="button" data-path="delivery" aria-pressed="false">Deliver</button><button type="button" data-path="create" aria-pressed="false">Create</button></div></div> |
| 403 | + <div class="actors" aria-label="Primary request path"> |
| 404 | + <div class="actor hot" data-node="edge"><div class="micro">Network boundary</div><strong>TLS proxy → HTTP / WS</strong><small>Clients, console and CLI<br>Canonical host or relay path</small></div> |
| 405 | + <div class="arrow" aria-hidden="true">→</div> |
| 406 | + <div class="actor hot" data-node="registry"><div class="micro">Process boundary</div><strong>Tenant registry</strong><small>Resolve name → stable ID<br>Load once, retain while active</small></div> |
| 407 | + <div class="arrow" aria-hidden="true">→</div> |
| 408 | + <div class="actor hot" data-node="runtime"><div class="micro">Tenant boundary</div><strong>Relay runtime × N</strong><small>Protocol | auth | policy<br>Queries | subscriptions | fan-out</small></div> |
| 409 | + </div> |
| 410 | + <div class="micro tier-label">Services inside the same Go process</div> |
| 411 | + <div class="tier"> |
| 412 | + <div class="actor" data-node="control"><div class="micro">Control plane</div><strong>Provision & configure</strong><small>Catalog DB, template revisions,<br>owners, aliases and audit trail</small></div> |
| 413 | + <div class="actor hot" data-node="store"><div class="micro">Durable boundary</div><strong>SQLite per relay</strong><small>Events + policy + delivery intent<br>One local transaction</small></div> |
| 414 | + <div class="actor" data-node="workers"><div class="micro">Background work</div><strong>Shared scheduler & dialer</strong><small>Inbox pulls ↔ remote relays<br>Outbox delivery → remote relays</small></div> |
| 415 | + </div> |
| 416 | + <div class="telemetry-strip">All boundaries → metrics + structured logs + optional OTel spans → local diagnostics / optional collector</div> |
| 417 | + <div class="map-reading" aria-live="polite"><h3 id="path-title">Publish an event</h3><p id="path-description"><strong>Resolve → validate → authorize → commit → acknowledge → fan-out.</strong> Persist the event and routing intent together. An OK confirms local durability; remote delivery proceeds independently. The event never crosses another tenant’s runtime implicitly.</p></div> |
| 418 | + </div> |
| 419 | + <div class="two" style="margin-top:26px"> |
| 420 | + <div><h3>Own the domain, reuse the wire protocol.</h3><p>Use a Khatru adapter for Nostr transport and hooks. Tinyrelay owns admission, transaction boundaries, lifecycle, routing and telemetry. The web surface uses Go HTML templates and ordinary form handlers. Start with one Khatru instance per active tenant; do not mutate framework hooks during concurrent requests.</p><p>Every task belongs to a context, has a stop path, and is joined on shutdown. The registry never holds a global lock during SQL or network I/O. Live subscriptions remain in memory and are rebuilt by reconnecting clients after restart.</p></div> |
| 421 | + <div><h3>Concurrency without a second platform.</h3><p>Readers run concurrently; each tenant serializes short writes. A shared scheduler fairly dispatches due jobs, backed by persistent tenant state. Each socket has a single writer and bounded pending output; a slow consumer is disconnected with a reason.</p><p>Runtime eviction requires zero connections, zero in-flight requests and no running task. Due jobs reacquire the tenant. Closing an idle database releases handles; it never deletes relay state.</p></div> |
| 422 | + </div> |
| 423 | + <details><summary>Proposed Go package boundaries</summary><div class="detail-body"><pre><code>cmd/tiny startup, serve, management CLI |
| 424 | +internal/host listeners, routing, registry, lifecycle |
| 425 | +internal/control creation, owners, aliases, template plan/apply |
| 426 | +internal/relay ingest/query services and policy snapshots |
| 427 | +internal/protocol Khatru adapter, AUTH, HTTP and NIP adapters |
| 428 | +internal/storage catalog + tenant SQLite; atomic ingest |
| 429 | +internal/delivery discovery, routing, durable attempts, dialer |
| 430 | +internal/jobs fair scheduler, pulls, expiry, backup tasks |
| 431 | +internal/media local Blossom/NIP-96 blobs, mirror, reports |
| 432 | +internal/sites manifests, isolated origins, verified serving |
| 433 | +internal/git GRASP policy, Git objects/refs, recovery journal |
| 434 | +internal/community members, invites, moderation, signed group state |
| 435 | +internal/records views, discovery, notifications, succession |
| 436 | +internal/telemetry metric vocabulary, spans, slog, diagnostics |
| 437 | +internal/migrate bindws reader, import report, reconciliation |
| 438 | +templates/ embedded versioned JSONC documents |
| 439 | +web/ plain HTML templates; optional signer script</code></pre><p class="source">This is a proposed layout. Use small interfaces at storage, clock, dialer and signer boundaries; avoid a general plugin engine or an interface for every struct. The public site and management pages have no frontend framework or inherited bindws assets.</p></div></details> |
| 440 | +</section> |
| 441 | + |
| 442 | +<section id="tenancy"> |
| 443 | + <div class="section-head"><span class="section-num">03</span><div><h2>Create owned relays on demand.</h2><p>Provisioning is a durable control operation. Activation is a transient runtime concern. Neither introduces a lease.</p></div></div> |
| 444 | + <div class="two"> |
| 445 | + <div class="block"><h3>Durable identity</h3><p>An authorized operator creates a relay with a stable internal ID, unique name, owner pubkey and template revision. A configured owner allowlist may self-provision through signed management requests. There is no open anonymous creation door by default.</p><div class="flow"><span>creating</span><b>→</b><span>ready</span><b>↔</b><span>disabled</span></div><p><strong>Deletion is explicit.</strong> Disabling closes access and pauses delivery while preserving data. No idle timeout, owner inactivity or calendar date deletes a relay. Content expiry under NIP-40 is separate.</p></div> |
| 446 | + <div class="block"><h3>Runtime lifecycle</h3><p>Resolve a registered host or path, acquire its runtime, and lazily open the tenant database. Single-flight activation prevents two runtimes from owning the same tenant. Unknown names return an error and allocate nothing.</p><div class="flow"><span>unloaded</span><b>↔</b><span>active</span><b>→</b><span>draining</span></div><p>A database handle cache is a host implementation detail, not a relay-count limit. Background jobs can activate a relay even with no connected clients.</p></div> |
| 447 | + </div> |
| 448 | + <p class="fine" style="margin-top:18px">Creation spans catalog and tenant files: write an idempotent “creating” catalog record, initialize the tenant database and owner, then mark ready. Only ready records are routable. Startup completes or reports interrupted creation. Unique names, idempotency keys and a data-directory process lock prevent duplicate ownership.</p> |
| 449 | + <h3 style="margin-top:30px">Keep configuration portable; make behavior explicit.</h3> |
| 450 | + <p>A personal bundle creates three ordinary named relays: <code>alice-outbox</code>, <code>alice-inbox</code> and <code>alice-dm</code>. Each has its own database and policy. A bundle is an additional provisioning convenience. Preserve the full existing template catalog: default, outbox, inbox, private, chat, media, search, articles, dm, quiet, site, marmot, grasp, marmot-members and home, together with their connection templates and source-dependent jobs.</p> |
| 451 | + <div class="template-head"><label for="template-select">Explore the proposed built-in roles</label><select id="template-select"><option value="outbox">Personal / public outbox</option><option value="inbox">Personal / public inbox</option><option value="dm">Personal / private DM inbox</option><option value="community">Community / members publish</option></select></div> |
| 452 | + <div class="two"> |
| 453 | + <div class="template-info" aria-live="polite"><div class="micro">Template behavior</div><h3 id="template-title" style="margin-top:10px">Public outbox</h3><p id="template-about">Your public events live here and automatically travel to the relays that should receive them.</p><dl><dt>Writes</dt><dd id="template-writes">Owner; signed events only</dd><dt>Reads</dt><dd id="template-reads">Public</dd><dt>Automation</dt><dd id="template-auto">Owner write relays + tagged recipients’ read relays + explicit mirrors</dd><dt>Retention</dt><dd id="template-retention">No automatic age-based purge</dd></dl></div> |
| 454 | + <pre aria-label="Illustrative template configuration"><code id="template-code">{ |
| 455 | + "format": "tinyrelay/template/1", |
| 456 | + "id": "personal-outbox", |
| 457 | + "revision": 1, |
| 458 | + "policy": { |
| 459 | + "writes": "owner", |
| 460 | + "reads": "public", |
| 461 | + "blockPrivateKinds": true |
| 462 | + }, |
| 463 | + "automation": { |
| 464 | + "delivery": "nip65-public", |
| 465 | + "origin": "local-client" |
| 466 | + }, |
| 467 | + "retention": [] |
| 468 | +}</code></pre> |
| 469 | + </div> |
| 470 | + <p class="fine" style="margin-top:14px">The selector illustrates four roles; it does not define the scope of the template catalog. This is a proposed schema, not an implemented API. Owner and name belong to the creation request. Both hostname endpoints and <code>wss://relay.example/r/alice-outbox</code> paths resolve to the same stable tenant ID.</p> |
| 471 | + <div class="three" style="margin-top:28px"> |
| 472 | + <div><h4>Compile once</h4><p>Validate JSONC against a strict schema. Resolve an immutable revision into a typed policy snapshot. No JavaScript, WASM, shell interpolation or arbitrary code execution inside templates.</p></div> |
| 473 | + <div><h4>Change deliberately</h4><p>Plan/apply/export remain first-class. On update, omitted sections stay unchanged; an empty list explicitly clears a list. Reject unknown fields and use a revision precondition to prevent lost updates.</p></div> |
| 474 | + <div><h4>Publish capabilities truthfully</h4><p>Template files do not hot-change existing tenants. Applying a revision records an audit entry and swaps the policy atomically. Generate NIP-11 from a build capability registry intersected with tenant-enabled features; only conformance-tested capabilities enter that registry.</p></div> |
| 475 | + </div> |
| 476 | + <p class="source">Preserved semantics: <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/src/config.ts">bindws config.ts</a>, <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/relay-templates/README.md">relay template contract</a>, <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/connection-templates/README.md">connection shortcuts</a>. App shortcuts stay presentation metadata; visibility never substitutes for authorization.</p> |
| 477 | +</section> |
| 478 | + |
| 479 | +<section id="delivery"> |
| 480 | + <div class="section-head"><span class="section-num">04</span><div><h2>Automatic inbox and outbox, precisely.</h2><p>Separate public event distribution, mention collection and encrypted-message receipt. The same worker infrastructure serves them; their authorization rules differ.</p></div></div> |
| 481 | + <div class="table-wrap"><table class="stack"><thead><tr><th>Role</th><th>Acceptance & visibility</th><th>Automatic behavior</th></tr></thead><tbody> |
| 482 | + <tr><td>Public outbox</td><td>Owner writes by default; public reads. Block private kinds, exclude ephemeral traffic from durable routing, and never forward NIP-70 protected events.</td><td>Forward newly accepted local public events to the author’s NIP-65 write relays, tagged recipients’ read relays and configured mirrors. Imported/pulled events do not trigger redistribution.</td></tr> |
| 483 | + <tr><td>Public inbox</td><td>Accept configured public kinds only when a valid <code>p</code> tag names the owner or an allowed recipient. Sender profiles may be fetched as related metadata; they do not get an unrestricted kind-0 admission bypass.</td><td>Maintain subscriptions to configured sources and the owner’s other public read relays for matching mentions. Recover gaps with overlapping windows and ID deduplication; use NIP-77 reconciliation where available.</td></tr> |
| 484 | + <tr><td>Private DM inbox</td><td>Verify the outer kind-1059 event ID/signature and its recipient <code>p</code> tag against local recipients. This does not verify the encrypted inner message. Require NIP-42 AUTH matching the outer recipient for queries, counts and live delivery; membership alone grants no access.</td><td>Advertise this endpoint through an owner-signed kind-10050 list. Clients deliver their recipient-specific wraps directly. The relay stores opaque ciphertext and does not discover, decrypt or blast private messages.</td></tr> |
| 485 | + </tbody></table></div> |
| 486 | + <p class="source"><a href="https://github.com/nostr-protocol/nips/blob/master/65.md">NIP-65</a> defines public relay-list discovery, not a server delivery guarantee. <a href="https://github.com/nostr-protocol/nips/blob/master/17.md">NIP-17</a> uses kind 10050 for DM relays and recipient-restricted gift-wrap reads. These are distinct from a public mentions inbox.</p> |
| 487 | + <div class="note"><strong>Automatic begins after explicit setup.</strong> Creating a personal bundle enables its local policies and workers. The console prepares merged kind-10002 and kind-10050 updates for the owner’s NIP-07/NIP-46 signer. The daemon never possesses the owner’s secret key or pretends its relay key is that owner. Display “discovery not published” until the signed lists are published.</div> |
| 488 | + <div class="pipeline"> |
| 489 | + <div><strong>Record intent</strong><p>In the event transaction, add durable routing intent with origin and policy revision. A cold discovery cache never blocks a local publish on network I/O.</p></div> |
| 490 | + <div><strong>Resolve destinations</strong><p>Read the newest verified signed lists; use configured indexers on cache misses. Persist each normalized destination independently. Missing lists remain visible as unresolved work.</p></div> |
| 491 | + <div><strong>Attempt delivery</strong><p>Reuse connections by endpoint and authentication context. Correlate each OK with its event ID. Record attempts, next-attempt time and structured outcome per target.</p></div> |
| 492 | + <div><strong>Recover & report</strong><p>Retry transient failures with jittered backoff. Duplicate remote acceptance is success. Permanent rejections remain inspectable and can be retried deliberately.</p></div> |
| 493 | + </div> |
| 494 | + <div class="two"> |
| 495 | + <div><h3>The delivery contract</h3><ul><li><strong>At-least-once attempts</strong>, with idempotency by tenant + event ID + destination. A crash after remote acceptance can cause a duplicate retry.</li><li><strong>Local OK ≠ remote delivery.</strong> Accepted means committed locally with routing intent when enabled. The console reports remote status separately.</li><li><strong>No silent queue eviction.</strong> Pending intent stays on disk through temporary outages. Optional expiry is an explicit operator retention policy, never a hidden tenant ceiling; expiry, deletion or cancellation records a terminal status.</li><li><strong>Policy is checked again before sending.</strong> A deletion, expiration, disabled relay, revoked author or newly private policy cancels now-ineligible work.</li></ul></div> |
| 496 | + <div><h3>Discovery and network boundaries</h3><ul><li>Verify IDs and signatures on list events; resolve replaceable-event ordering deterministically. A list refresh changes future routes and revalidates pending destinations. Send the author’s verified kind-10002 list alongside public distribution as NIP-65 recommends.</li><li>Track inbox progress per source and filter; NIP-01 time-window pulls cannot prove a complete history. Surface partial backfills; late/backdated events require reconciliation.</li><li>Block loopback, link-local, metadata and private-network egress by default, including resolved IPs on every dial. Operators may explicitly allow LAN relays. Normalize aliases to prevent self-delivery loops.</li><li>If an upstream requires owner AUTH, report an authentication requirement and use a scoped external signer only when explicitly configured. A relay’s service key is a different identity and cannot impersonate its owner.</li></ul></div> |
| 497 | + </div> |
| 498 | + <details><summary>Job ownership and inbox checkpoint rules</summary><div class="detail-body"><p>Unique delivery keys prevent duplicate queue rows. Workers claim due rows using an atomic conditional update that records a reservation token and expiry; long tasks renew ownership. Completion updates must match the token, fencing stale workers from overwriting newer state. Cancel expired work before reclaiming it locally. Remote relays do not support this fencing token, so remote duplicate sends remain possible and are part of the at-least-once contract.</p><p>One job owns each tenant/source/filter checkpoint. Commit ingested event batches and their replay checkpoint together where the source exposes a usable cursor. For time-window Nostr queries, advance the completed window only after all returned events are committed and EOSE is received; otherwise replay that window with overlap and ID deduplication. Never derive a completed checkpoint from the greatest timestamp seen so far.</p></div></details> |
| 499 | + <p class="fine" style="margin-top:18px">Remote availability and client discovery cannot be guaranteed. Local workers can use already verified lists or configured targets immediately after setup; new endpoints become discoverable to other clients only after the owner signs and publishes updated lists. Public inbox collection is limited to selected sources. Following an owner’s contact list to build a feed is a separate optional mirror job.</p> |
| 500 | + <p class="source">Prior behavior: <a href="https://github.com/barrydeen/haven">HAVEN</a> combines role-specific relays, inbox gathering and outbox broadcasting. Bindws already implements <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/docs/04-data-and-names.md">NIP-65 delivery and per-target push cursors</a>; its <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/src/push.ts">NIP-9a callbacks</a> are a distinct delivery service required for parity, with their own registration, authorization, retry and revocation behavior.</p> |
| 501 | +</section> |
| 502 | + |
| 503 | +<section id="storage"> |
| 504 | + <div class="section-head"><span class="section-num">05</span><div><h2>Durability is part of the fast path.</h2><p>Start with SQLite because events, policy changes and delivery intent can share one transaction. Choose a different engine only when measured costs justify a new consistency model.</p></div></div> |
| 505 | + <div class="two"> |
| 506 | + <div><pre><code>/var/lib/tinyrelay/ |
| 507 | + catalog.sqlite |
| 508 | + tenants/ |
| 509 | + <stable-id>/ |
| 510 | + relay.sqlite |
| 511 | + relay.sqlite-wal |
| 512 | + blobs/ # Blossom, NIP-96 and verified site assets |
| 513 | + repositories/ # GRASP Git data; layout behind adapter |
| 514 | + backups/ |
| 515 | + secrets/ # relay keys; restrictive permissions |
| 516 | + |
| 517 | +Tenant tables |
| 518 | + events, tags, replacement_keys, tombstones |
| 519 | + policy_revisions, members, bans, audit |
| 520 | + routing_intents, deliveries, sync_cursors, jobs</code></pre><p class="source">Use stable generated IDs for directories; relay names are catalog aliases. Event IDs deduplicate within a tenant, never globally across private tenants.</p></div> |
| 521 | + <div><h3>One transaction, one truthful acknowledgement.</h3><p>Validate the event’s canonical ID and signature, apply authorization and content rules, then begin a short write transaction. Recheck the policy revision. Atomically apply replacement/deletion semantics, indexes and required delivery intent; commit before sending a successful stored-event OK.</p><p>Use WAL, one write connection per tenant, short read transactions and a small read pool. Set durability pragmas explicitly on every applicable connection. Start with <code>synchronous=FULL</code>; make a weaker durability mode an explicit later choice, never a silent throughput tweak.</p><p>SQLite WAL supports concurrent readers with one writer per database. Long readers can delay checkpoints. A local filesystem is required for this layout; a shared NFS volume is not a multi-node scaling mechanism.</p><p class="source"><a href="https://www.sqlite.org/wal.html">SQLite WAL</a> | <a href="https://www.sqlite.org/pragma.html#pragma_synchronous">synchronous durability</a></p></div> |
| 522 | + </div> |
| 523 | + <details open><summary>Correctness invariants to test before optimization</summary><div class="detail-body"><div class="two"><ul><li>Every ingest source uses the same validation/storage service; internal imports carry an explicit authority and origin, not a blanket policy bypass.</li><li>Replaceable and addressable events obey timestamp and ID tie-breaking. Authorized deletion, deletion-before-arrival tombstones, expiry and index cleanup are transactional.</li><li>Ephemeral events are never persisted and have a distinct acceptance contract. Duplicates do not create extra routing intent.</li><li>Reads, COUNT, search, sync and live fan-out enforce the same visibility policy; private data cannot leak through counts or auxiliary paths.</li></ul><ul><li>Register a subscription against a commit sequence barrier; read its bounded snapshot, then drain buffered later commits. Deduplicate overlaps and send EOSE after the historical snapshot.</li><li>Policy tightening invalidates affected subscriptions. Slow readers cannot pin a SQL cursor while waiting on a network socket; page or materialize bounded batches.</li><li>Crash before commit yields no successful OK; crash after commit preserves the event and intent. Local live delivery is best effort; reconnect queries recover durable events.</li><li>Disk-full or I/O failure rejects the write before OK. Stop accepting affected writes and report storage health; never report durable success from an in-memory queue.</li></ul></div></div></details> |
| 524 | + <details><summary>Query strategy and the storage-adapter decision</summary><div class="detail-body"><p>Begin with indexes for ID, author/time, kind/time, author/kind/time and tag name/value/time. Order by creation time and ID, limit each filter as the protocol requires, deduplicate unions, and use keyset iteration for jobs. Record query plans and rows visited in developer benchmarks. Add FTS only with the search milestone.</p><p>The proposed SQLite store must own atomic event + intent writes. Khatru’s storage hooks can delegate to that service; do not enqueue from an after-save callback. Eventstore is useful as an adapter/reference, but its event-only interface does not establish a transaction spanning a separate job database. A split event engine would require a durable change journal and recovery protocol.</p><p>The first implementation spike must prove atomic replacement/deletion, truthful query errors, the historical/live handoff, per-event private visibility and runtime shutdown through Khatru. If hooks cannot preserve these invariants, keep the SDK and implement the small missing protocol/lifecycle adapter directly.</p></div></details> |
| 525 | + <details><summary>Backup, restore and tenant isolation</summary><div class="detail-body"><p>Use SQLite’s online backup API or a coordinated quiescent snapshot, never copy a live main database file alone. A manifest records schema, tenant IDs, config revisions, event counts, checksums and key references. Encrypt backups containing signing keys. Optional blob storage needs a manifest-consistent snapshot with garbage collection paused for the backup.</p><p>Restore into a new directory, validate hashes/schema, run integrity and protocol checks, and default restored outbound workers to paused so a rehearsal cannot rebroadcast. Catalog/tenant backups reconcile by stable ID. Logical isolation is enforced in code and files; one process is not a security sandbox against malicious extensions or host compromise.</p><p class="source"><a href="https://www.sqlite.org/backup.html">SQLite backup API</a>. Proposed reliability target: preserve every successfully acknowledged stored event across process restart and, with FULL sync and a correctly functioning storage stack, power loss; a single host is still a hardware failure domain.</p></div></details> |
| 526 | +</section> |
| 527 | + |
| 528 | +<section id="o11y"> |
| 529 | + <div class="section-head"><span class="section-num">06</span><div><h2>Observability is the first subsystem.</h2><p>Define the measurement vocabulary alongside the domain model. Instrument the first vertical slice and every subsequent service as it is added. Instrumentation is required; external telemetry export is optional.</p></div></div> |
| 530 | + <div class="three"> |
| 531 | + <div class="block"><div class="micro">Always available</div><h3 style="margin-top:10px">Metrics & logs</h3><p>Prometheus text exposition on the private diagnostics listener; structured <code>slog</code> records to stderr. Use one metric registry. The relay works normally without a scraper.</p></div> |
| 532 | + <div class="block"><div class="micro">Optional export</div><h3 style="margin-top:10px">OpenTelemetry traces</h3><p>Instrument with the OTel API and enable the SDK/OTLP exporter when configured. Short spans per operation, plus links across durable jobs; do not keep one span open for a socket’s lifetime.</p></div> |
| 533 | + <div class="block"><div class="micro">On demand</div><h3 style="margin-top:10px">Go diagnostics</h3><p>CPU, heap, goroutine, block and mutex profiles; short runtime traces for scheduling and blocking. Bind pprof to loopback or an authenticated administrative listener.</p></div> |
| 534 | + </div> |
| 535 | + <p class="source"><a href="https://opentelemetry.io/docs/languages/go/instrumentation/">OTel Go instrumentation</a> | <a href="https://opentelemetry.io/docs/languages/go/exporters/">exporters</a> | <a href="https://go.dev/doc/diagnostics">Go diagnostics</a> | <a href="https://pkg.go.dev/net/http/pprof">pprof</a></p> |
| 536 | + <div class="flow"><span>ingress</span><b>→</b><span>validate / policy</span><b>→</b><span>commit</span><b>→</b><span>fan-out</span><b>→</b><span>delivery attempt</span></div> |
| 537 | + <div class="table-wrap"><table class="stack"><thead><tr><th>Boundary</th><th>Signals to ship first</th><th>Question answered</th></tr></thead><tbody> |
| 538 | + <tr><td>Acceptance</td><td class="metric">relay_ingest_total{outcome}<br>relay_ingest_duration_seconds<br>relay_validation_duration_seconds</td><td>Is time spent validating, waiting for a writer or committing? Are rejections policy decisions or failures?</td></tr> |
| 539 | + <tr><td>Connections & queries</td><td class="metric">relay_connections_active<br>relay_subscriptions_active<br>relay_query_duration_seconds<br>relay_eose_duration_seconds</td><td>How much state does an idle connection cost? Which query shapes delay historical replay?</td></tr> |
| 540 | + <tr><td>Fan-out</td><td class="metric">relay_fanout_duration_seconds<br>relay_socket_queue_bytes<br>relay_slow_consumer_disconnects_total</td><td>Is subscriber matching expensive, or are downstream sockets slow?</td></tr> |
| 541 | + <tr><td>Delivery & gathering</td><td class="metric">relay_delivery_pending<br>relay_delivery_oldest_age_seconds<br>relay_delivery_attempts_total{outcome}<br>relay_sync_lag_seconds</td><td>Is an upstream unavailable, discovery missing, or the local scheduler saturated?</td></tr> |
| 542 | + <tr><td>Storage</td><td class="metric">relay_write_queue_wait_seconds<br>relay_commit_duration_seconds<br>relay_storage_busy_total<br>relay_wal_bytes / relay_checkpoint_duration_seconds</td><td>Do write serialization, fsync or long readers explain the tail?</td></tr> |
| 543 | + <tr><td>Lifecycle & runtime</td><td class="metric">relay_tenants_active / relay_tenants_registered<br>relay_activation_duration_seconds<br>Go/process collectors; disk free bytes; job recovery failures</td><td>Are idle tenants retaining handles or goroutines? Can the process drain, recover and keep headroom?</td></tr> |
| 544 | + <tr><td>Files, sites & Git</td><td class="metric">relay_blob_operations_total{operation,outcome}<br>relay_blob_io_bytes_total{direction}<br>relay_site_verify_duration_seconds<br>relay_git_operation_duration_seconds{operation,outcome}</td><td>Is hashing, pack processing, file I/O or publication delaying an upload, site request or Git operation? Do large transfers affect relay traffic?</td></tr> |
| 545 | + <tr><td>Derived records & management</td><td class="metric">relay_job_duration_seconds{job_class,outcome}<br>relay_job_oldest_due_age_seconds{job_class}<br>relay_management_duration_seconds{action_class,outcome}</td><td>Are views, callbacks, notifications, succession and owner actions making progress? Keep job/action classes finite and exclude payloads and identities.</td></tr> |
| 546 | + </tbody></table></div> |
| 547 | + <p class="fine">Names are proposed. All durations are histograms in seconds. Define p50/p95/p99 panels and buckets around agreed workloads. Export aggregate depths and maximum ages; do not create a time series per socket or tenant.</p> |
| 548 | + <div class="two" style="margin-top:25px"> |
| 549 | + <div><h3>Useful telemetry with bounded cardinality.</h3><p>Labels come from a fixed vocabulary: operation, transport, event class and outcome. No event IDs, pubkeys, tenant names, subscription IDs, arbitrary kinds, URLs or full filter text in metric labels. Hashing those values does not reduce cardinality.</p><p>Provide authenticated, on-demand tenant diagnostics for detailed state. Exclude event bodies, ciphertext, keys, tokens, full URLs and identifiers from default traces/logs. Management audit records have a separate access and retention policy.</p><p class="source"><a href="https://prometheus.io/docs/practices/naming/">Prometheus naming and label guidance</a></p></div> |
| 550 | + <div><h3>Sampling and overhead are design choices.</h3><p>Start with low-rate head sampling for traces. Record errors as counters/logs regardless of trace sampling; head sampling cannot promise to retain every slow/error trace. An optional collector with tail sampling can retain those traces when configured to receive them.</p><p>Exporter failure never blocks acceptance. Bound export queues, count dropped telemetry, and compare instrumentation on/off in the benchmark harness. Use low-rate diagnostic sampling and opt-in mutex/block profiling.</p><p class="source"><a href="https://opentelemetry.io/docs/concepts/sampling/">OTel sampling</a> | <a href="https://prometheus.io/docs/practices/histograms/">histogram guidance</a></p></div> |
| 551 | + </div> |
| 552 | + <div class="note"><strong>First operational view:</strong> traffic and outcomes; accept/query latency; fan-out pressure; delivery age and retries; SQLite/WAL/checkpoints; CPU, RSS and goroutines. Alert on sustained inability to make progress, disk headroom and recovery failure. Keep tenant debugging in the admin view.</div> |
| 553 | +</section> |
| 554 | + |
| 555 | +<section id="performance"> |
| 556 | + <div class="section-head"><span class="section-num">07</span><div><h2>Optimize the bottleneck you can show.</h2><p>Go and local disk enable a different runtime. Capacity claims require a defined workload and host. The linked Git report records measured repository transfers and relay responsiveness; broader capacity targets remain to be measured.</p></div></div> |
| 557 | + <div class="pipeline"><div><strong>Establish correctness</strong><p>Protocol conformance, tenant isolation, crash recovery, duplicate delivery and historical/live handoff.</p></div><div><strong>Record a baseline</strong><p>Fixed hardware, seed, dataset, versions and durability mode. Capture latency, throughput, memory and errors.</p></div><div><strong>Explain the cost</strong><p>Metrics locate the boundary; a sampled trace isolates the operation; a profile explains CPU, allocation or blocking.</p></div><div><strong>Change one thing</strong><p>Repeat the same workload. Keep gains only when correctness and tail latency remain acceptable.</p></div></div> |
| 558 | + <div class="table-wrap"><table class="stack"><thead><tr><th>Workload</th><th>Measure</th><th>Only then consider</th></tr></thead><tbody> |
| 559 | + <tr><td>Public publish / duplicates</td><td>ACK p99, signature CPU, commits/sec, allocs/event, fsync cost.</td><td>Decode once, reuse immutable encoded bytes, prepared statements, batch commits with a bounded delay.</td></tr> |
| 560 | + <tr><td>History + new subscriptions</td><td>REQ→EOSE p99, rows visited/returned, reader age and WAL growth.</td><td>Composite indexes, query-shape planning and bounded batch reads.</td></tr> |
| 561 | + <tr><td>One event → many subscribers</td><td>Match time, write-queue age, slow consumers, per-connection memory.</td><td>Index subscriptions by kind/author/tag; share encoded event bytes. Preserve per-subscription IDs and filters.</td></tr> |
| 562 | + <tr><td>Many mostly idle tenants</td><td>RSS per active tenant, open file descriptors, activation latency, idle goroutines.</td><td>Handle eviction, fewer background loops, cache sizing and lazy index warm-up.</td></tr> |
| 563 | + <tr><td>Outages and backfill</td><td>Backlog age, retry rate, sockets, fairness, live traffic latency during imports.</td><td>Connection reuse, destination backoff and separate priority for live ingest versus backfill.</td></tr> |
| 564 | + <tr><td>Crash / disk pressure / churn</td><td>Recovery duration, acknowledged-event loss, queue replay, memory after reconnect storms.</td><td>Shorter recovery scans and persisted due indexes; never weaker acknowledgement semantics.</td></tr> |
| 565 | + <tr><td>Files / sites / Git under relay load</td><td>Transfer throughput, peak RSS, hash/pack CPU, request tails, object/ref publication and backup duration.</td><td>Streaming, chunked I/O, checkpoint coordination and fair scheduling. Re-run upload, site verification and Git recovery cases after each change.</td></tr> |
| 566 | + <tr><td>Views / callbacks / management</td><td>Schedule lateness, signing cost, callback retries, form/API response time and concurrent job interference.</td><td>Incremental computation, debouncing and worker-class fairness while preserving triggers, visibility and task feedback.</td></tr> |
| 567 | + </tbody></table></div> |
| 568 | + <div class="two" style="margin-top:26px"> |
| 569 | + <div><h3>Reproducible load, not a marketing score.</h3><p>Report Go/module/SQLite versions, CPU count, OS, disk, sync settings, dataset size, event-size distribution, filter shapes, tenant skew and cache state. Sweep load and record the latency/error curve; avoid a load generator that hides queuing by slowing down with the server.</p><p>Archive seeds, raw results, profiles and configuration with each comparison. Use Go benchmarks and benchstat for hot-path comparisons, and a WebSocket load harness for whole-system behavior.</p><p class="source"><a href="https://pkg.go.dev/testing">Go testing benchmarks</a> | <a href="https://pkg.go.dev/golang.org/x/perf/cmd/benchstat">benchstat</a> | <a href="https://pkg.go.dev/runtime/trace">runtime tracing</a></p></div> |
| 570 | + <div><h3>Acceptance gates before capacity targets.</h3><p>Release gates: no cross-tenant/private visibility leak; no loss of acknowledged stored events in restart tests; no orphaned routing intent; duplicate retries converge; backlog drains when a healthy target recovers; RSS/goroutines stabilize after churn.</p><p>Choose numeric service objectives after the first representative baseline on the intended host. Gate a performance change on repeatable improvement in its target metric and no material regression in p99, memory, errors or durability. Run race tests separately from timing benchmarks.</p></div> |
| 571 | + </div> |
| 572 | +</section> |
| 573 | + |
| 574 | +<section id="reuse"> |
| 575 | + <div class="section-head"><span class="section-num">08</span><div><h2>Borrow proven pieces. Keep the seam small.</h2><p>Current package paths and licenses matter. The current Nostr toolkit is not simply the old go-nostr + khatru repository layout.</p></div></div> |
| 576 | + <div class="table-wrap"><table class="stack"><thead><tr><th>Candidate</th><th>What it contributes</th><th>Recommendation</th></tr></thead><tbody> |
| 577 | + <tr><td><a href="https://pkg.go.dev/fiatjaf.com/nostr">fiatjaf.com/nostr</a><br><span class="tag">Unlicense</span></td><td>Current event types, crypto, filters, relay/client utilities and NIP packages.</td><td>Preferred protocol toolkit. Pin an exact tested module revision; do not mix old and new event types across the application.</td></tr> |
| 578 | + <tr><td><a href="https://pkg.go.dev/fiatjaf.com/nostr/khatru">…/khatru</a><br><span class="tag">Unlicense</span></td><td>Custom relay hooks for auth, storage, query, broadcast and management.</td><td>Preferred transport candidate, subject to the storage/lifecycle spike. Audit inherited max-query/message defaults against the no-product-quota model.</td></tr> |
| 579 | + <tr><td><a href="https://pkg.go.dev/fiatjaf.com/nostr/eventstore">…/eventstore</a><br><span class="tag">Unlicense</span></td><td>Iterator-based store abstraction and embedded backends including LMDB, BoltDB and MMM.</td><td>Reference or adapter; not the transaction contract for event + delivery intent. Build a focused SQLite ingest store initially.</td></tr> |
| 580 | + <tr><td><a href="https://pkg.go.dev/github.com/nbd-wtf/go-nostr">nbd-wtf/go-nostr</a><br><span class="tag">MIT</span></td><td>Established Go SDK and ecosystem examples.</td><td>Compatibility reference. Older examples and indexed documentation may target this API; prefer a single current module family for new code.</td></tr> |
| 581 | + <tr><td><a href="https://pkg.go.dev/modernc.org/sqlite">modernc.org/sqlite</a><br><span class="tag">BSD-3-Clause</span></td><td>SQLite without a cgo requirement; compatible with database/sql.</td><td>Baseline choice for simpler builds. Verify embedded SQLite version, FTS/backup needs and WAL fixes in the pinned build.</td></tr> |
| 582 | + <tr><td><a href="https://github.com/mattn/go-sqlite3">mattn/go-sqlite3</a><br><span class="tag">MIT</span></td><td>Cgo SQLite driver; requires a C compiler at build time.</td><td>Benchmark alternative if storage profiles justify it. Cross-compilation and packaging are part of the tradeoff; no presumed speed ranking.</td></tr> |
| 583 | + <tr><td><a href="https://github.com/barrydeen/haven">HAVEN</a><br><span class="tag">MIT</span></td><td>Closest behavioral prior art: role-based personal relays, gathering and broadcasting. README describes maintenance as bug-fix-only.</td><td>Borrow UX, policy ideas and test scenarios. Generalize role instances into templates for arbitrary owners; avoid a dependency on the entire daemon.</td></tr> |
| 584 | + <tr><td><a href="https://github.com/hoytech/strfry">strfry</a><br><span class="tag drop">GPLv3</span></td><td>C++/LMDB relay; durable ACK behavior, specialized indexing, streaming and sync.</td><td>Operational/performance comparison and architectural prior art. Do not copy implementation into an intended permissive Go codebase.</td></tr> |
| 585 | + <tr><td><a href="https://github.com/scsibug/nostr-rs-relay">nostr-rs-relay</a><br><span class="tag">MIT</span></td><td>Rust relay using SQLite; PostgreSQL is described as experimental.</td><td>Study SQL and operational/conformance behavior. Reuse ideas or clearly attributed compatible portions only where they help.</td></tr> |
| 586 | + </tbody></table></div> |
| 587 | + <div class="note"><strong>Current upstream, 07 Sep 2026.</strong> Khatru has moved to the ngit/Nostr-hosted <a href="https://basspistol.org/npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6/nostrlib.git">nostrlib repository</a>, with source browsing through <a href="https://viewsource.win/npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6/nostrlib/_/master/~/khatru">viewsource</a> and the module path <code>fiatjaf.com/nostr/khatru</code>. The archived GitHub repository is the legacy location. Use the current upstream and pin revisions for reproducible builds, as with other dependencies. Verify each dependency’s license and retain the required notices.</div> |
| 588 | +</section> |
| 589 | + |
| 590 | +<section id="operations"> |
| 591 | + <div class="section-head"><span class="section-num">09</span><div><h2>A service an operator can understand.</h2><p>The executable is <code>tiny</code>: run the daemon with <code>tiny serve</code> and use the same command for management. Ship plain HTML management pages, a systemd example and an optional container image. The default deployment is one host and one data directory.</p></div></div> |
| 592 | + <div class="note"><strong>Preserve UX through simpler presentation.</strong> Every retained bindws task must be achievable from the web interface with equivalent authorization, inputs, preview, progress, outcome and recovery. Rebuild with basic HTML and forms; no bindws branding, CSS or generated console bundle carries over. Simplifying markup does not permit removing a workflow or forcing an existing web task into the CLI. This document’s presentation is not the product design.</div> |
| 593 | + <div class="two" style="margin-bottom:27px"> |
| 594 | + <div><h3>All workflows, ordinary pages</h3><p>Keep the full People, Moderation, Rules, Identity, Connect, Data, Sync, Views, Health and Owner workflow families. Include files, sites, Git, invitations, list recovery, ownership/succession and callback management. Navigation may become links and forms rather than tabs; role-specific actions remain discoverable.</p><p>Preserve public event/article pages, feeds, cards, QR handoff, connection shortcuts and sign-in. Use GET for reads and POST for mutations, followed by clear results. Render plan/preview and validation on the server. Long-running work keeps progress and retry/cancel actions; small scripts or polling can preserve time-sensitive feedback where plain refresh would worsen the task.</p></div> |
| 595 | + <div><h3>Keep extension and remote signing</h3><p>Rebuild the NIP-07/NIP-46 helper so existing extension, phone pairing and remote-signer flows remain supported, including their relay transport and discovery behavior. Never ask users to paste a private key. Any browser session bridge uses an origin-bound, expiring one-time challenge, Secure/HttpOnly/SameSite cookies, CSRF protection and per-action authorization.</p><p>Preserve the signed NIP-98/NIP-86 contracts for scripts and clients, with documented exceptions for removed fuel/lease operations. Keep signer scripts isolated from page rendering. The CLI is an additional complete interface; it is not a substitute for missing web functionality.</p></div> |
| 596 | + </div> |
| 597 | + <div class="two"> |
| 598 | + <div class="block"><h3>Serving and access</h3><ul><li>TLS proxy fronts a loopback-bound HTTP/WS listener. Use explicit hosts or wildcard DNS/certificates, or a single domain with relay paths.</li><li>Trust forwarding headers only from configured proxies. Match canonical host/path against the registry; validate custom-domain ownership before alias activation.</li><li>Host admin uses a local socket or separately authenticated control endpoint. Relay owners manage only their tenant via signed NIP-98/NIP-86 operations.</li><li>Diagnostics are private. Tenant signing keys are distinct from owner keys and stored outside exported template files.</li></ul></div> |
| 599 | + <div class="block"><h3>Restarts, backups and capacity</h3><ul><li>On shutdown, stop admission, drain accepted writes, cancel/join workers, close sockets and release databases. Clients reconnect and reissue subscriptions.</li><li>On startup, check schema versions, reconcile incomplete creation and recover expired task reservations. Persist due times; the scheduler’s heap is only a cache.</li><li>Expose liveness separately from readiness and per-tenant storage health. A remote relay outage does not make local ingest unready.</li><li>A single host does not provide automatic failover. Scale vertically first; multi-host placement is a separate topology choice after application parity. Never run two owners over the same tenant files.</li></ul></div> |
| 600 | + </div> |
| 601 | + <div class="note"><strong>Task reservations are internal crash-recovery state.</strong> They are short worker ownership records, unrelated to relay lifetime. User relays remain owned and persistent; there is no leased-relay feature anywhere in the runtime model.</div> |
| 602 | + <h3 style="margin-top:28px">Move every service to a local implementation.</h3> |
| 603 | + <p>Blossom/NIP-96 use local content-addressed files, streaming uploads and atomic publication. Sites retain manifest verification, mirroring, authenticated access and isolated origins. GRASP retains its signed authorization and event/object publication boundary while its storage adapter may use native Git. Marmot, views, succession, owner notifications and callbacks run through durable local jobs. All are required implementation work, even where a tenant can disable them.</p> |
| 604 | + <div class="table-wrap"><table class="stack"><thead><tr><th>Worker mechanism</th><th>Local replacement</th><th>UX contract to preserve</th></tr></thead><tbody> |
| 605 | + <tr><td>Named Durable Object + RPC</td><td>Catalog, single-flight tenant runtime, typed in-process services and tenant transactions.</td><td>A named relay behaves independently; create, configure and fork remain direct actions. Fork assigns a permanent owner without a lease.</td></tr> |
| 606 | + <tr><td>Hibernating sockets / attachments</td><td>Owned connection state, cancellation and reconnect recovery in Go.</td><td>AUTH, private visibility, subscriptions, signer transport and sync remain interoperable; process restarts recover stored work.</td></tr> |
| 607 | + <tr><td>Alarm + waitUntil</td><td>Durable scheduler, due records, reservation tokens and joined workers.</td><td>Imports, mirrors, backups, delivery, expiry, views and succession continue without a connected browser; progress and failures remain visible.</td></tr> |
| 608 | + <tr><td>R2 blobs / multipart objects</td><td>Streaming local file writes, verified hashes, atomic rename and durable metadata; optional S3 adapter.</td><td>The same upload, mirror, download, report, delete and backup tasks; no provider-shaped file-size ceiling.</td></tr> |
| 609 | + <tr><td>KV + Cloudflare domain provisioning</td><td>Transactional alias map, local resolver, domain verification and TLS-proxy integration with status.</td><td>Owner can Add, Check and Remove relay/site domains from the web interface and signed API. Show the DNS action and readiness; do not replace this with a manual operator-only map.</td></tr> |
| 610 | + <tr><td>SQLite Git chunks / admission token</td><td>Repository service with per-repository ownership, staged objects, ref transactions and a recovery journal spanning Git and SQL.</td><td>Smart HTTP, signed state admission, inventory and backup behavior persist. Never expose an accepted event before required objects are durable, or lose ref/event consistency on restart.</td></tr> |
| 611 | + <tr><td>Generated console + edge pages</td><td>Go HTML templates, standard forms, server-side previews and narrowly scoped signer/progress helpers.</td><td>Every role’s tasks, sharing and app handoff remain available; only visual design and the agreed removed concepts change.</td></tr> |
| 612 | + <tr><td>Provider meters / traces</td><td>Local operational counters, audit history, Prometheus, OTel and Go diagnostics.</td><td>Keep health, usage visibility and job diagnostics while removing pricing/fuel. Expand evidence for local performance work.</td></tr> |
| 613 | + </tbody></table></div> |
| 614 | + <p class="fine">The durable data directory and its backup policy are the operator’s responsibility. “Unlimited” describes removal of platform entitlements, not infinite hardware. Fairness and backpressure protect useful service without inventing another fuel system. Local deployment can remain a single daemon while delivering the complete application; matching Cloudflare’s global infrastructure is distinct from application and UX parity.</p> |
| 615 | +</section> |
| 616 | + |
| 617 | +<section id="delivery-plan"> |
| 618 | + <div class="section-head"><span class="section-num">10</span><div><h2>Continue until every parity requirement is met.</h2><p>Full functional and UX parity is the finish line. The phases order the engineering work; they are not alternative stopping points or permission to leave features for an unspecified future.</p></div></div> |
| 619 | + <div class="note"><strong>A core-only build is an intermediate checkpoint.</strong> Completion requires all retained protocol/API behavior, all existing template and user workflows, complete data migration and local recovery. A feature being disabled by default does not remove it from scope. Keep working through the matrix until every required row is verified.</div> |
| 620 | + <h3>Source-to-target parity matrix</h3> |
| 621 | + <p class="fine">Seed the implementation ledger from bindws revision 5d31267, then enumerate individual cases from the source registries and tests. Each ledger entry records source route/method/UI action, input and role, observable behavior, local implementation, test evidence and status. Allowed statuses are planned, in progress, verified or an explicitly documented intentional change. Unmapped or unverified retained behavior blocks completion.</p> |
| 622 | + <div class="table-wrap"><table class="stack"><thead><tr><th>Required family</th><th>Behavior & UX to preserve</th><th>Evidence of completion</th></tr></thead><tbody> |
| 623 | + <tr><td>Relay protocol & queries</td><td>EVENT/REQ/CLOSE/AUTH, HTTP event/query/count bridge, filters, EOSE, replaceables/addressables, deletion, vanish, expiry, protected events, search modes, COUNT/HLL and negentropy. Preserve policy changes on existing subscriptions.</td><td>Port all corresponding black-box cases; compare normal, error and privacy behavior on both backends. Run reconnect, concurrent query/write and restart tests.</td></tr> |
| 624 | + <tr><td>Ownership, auth & discovery</td><td>NIP-42/98, extension and remote signer flows, relay identity, NIP-05 names, NIP-11 capability data, NIP-66 records and web-address discovery. Transfer and succession retain their signed-record effects.</td><td>Existing client sign-in and discovery journeys succeed against local URLs. Identity preservation or an explicitly accepted rotation is verified through restore and transfer.</td></tr> |
| 625 | + <tr><td>People, membership & groups</td><td>Members, names/notes, guest kinds and one-hop replies, member/follow write rules, join terms, invite trees and membership claim codes, join/leave, directory privacy, NIP-29/43 records and role hierarchy.</td><td>Owner, moderator, member and guest journey matrix; tests for tree removal, invite revocation, signed roster/group updates and immediate read revocation. Relay trials remain removed.</td></tr> |
| 626 | + <tr><td>Moderation</td><td>Reports, hide-after-report thresholds, dismiss/delete/ban, ban-and-erase including files, blocked words/regex/tag matching, IP blocks, pins, recent-event search and paginated audit history.</td><td>Hidden data is absent from queries, counts, sync, pages and delivery. Owner recovery from a self-IP-block stays available. Do not silently replace supported regex semantics with a narrower engine.</td></tr> |
| 627 | + <tr><td>Templates & configuration</td><td>All 15 relay templates, every connection template, source/replica behavior, owner-list exceptions, plan/apply/export, partial-section semantics, feature defaults and visibility placeholders.</td><td>Each template has an end-to-end case; config round-trips and diffs preserve behavior. Reapplying a replica template replaces its job rather than duplicating it.</td></tr> |
| 628 | + <tr><td>Lists, sharing & app handoff</td><td>List history and restore/re-sign, Check/Add/Remove relay-list actions, kinds 10002/10050/10007/10063, app shortcut editing/order/visibility, copyable URLs/commands, QR codes and cards.</td><td>Owner can recover a list and complete phone/app handoff from plain HTML pages. Private shortcuts and public visibility follow the existing authorization model.</td></tr> |
| 629 | + <tr><td>Data, jobs & forks</td><td>Data-by-kind and retention, purge exceptions, JSONL/JSON import, progress and skip reasons, pull/mirror/history/rebroadcast, run-now/remove, per-target cursors, fork and copy.</td><td>Work resumes after restart; failed targets never advance successful-target state incorrectly. Fork creates a permanent owner-assigned relay and preserves the intended data/membership copy workflow.</td></tr> |
| 630 | + <tr><td>Automatic delivery & callbacks</td><td>NIP-65 public distribution, NIP-9a registration/revocation and callback privacy, per-target status, retries, deduplication, owner notifications and notification test/preferences.</td><td>Network-failure and policy-change tests; public/private routing separation; no lost intent after local OK. Automatic inbox/outbox improvements also pass their additional requirements.</td></tr> |
| 631 | + <tr><td>Files</td><td>Blossom BUDs, NIP-96/NIP-94 descriptors, upload/preflight/list/read/delete/mirror, signed tokens, reports and banned hashes, file access under relay policy.</td><td>Existing client and web upload/download/report flows work with local disk; interrupted upload/mirror leaves no falsely published descriptor; backup/restore retains blobs and authorization.</td></tr> |
| 632 | + <tr><td>Pages & static sites</td><td>Notes/articles, feed and link metadata; NIP-5A root/named/snapshot manifests, path rules, verified cache fill, mirrors, sign-in, custom domains and origin isolation.</td><td>Browse all page/site modes; verify hash/path failure behavior, private site access and deletion/expiry cleanup. Site origins cannot access relay management or event doors.</td></tr> |
| 633 | + <tr><td>Git / GRASP</td><td>NIP-34 authorization, Smart HTTP, signed repository state, maintainers and refs, currently implemented GRASP profiles and preview paths, inventory, synchronization and backup/recovery.</td><td>Push/clone/fetch/state-update workflows, admission failures and crash tests across object/ref/event publication. Preserve each profile’s documented maturity and dependency gates.</td></tr> |
| 634 | + <tr><td>Marmot</td><td>KeyPackages, opaque MLS envelopes, public/member templates, authenticated account admission, gift-wrap recipient checks, feature and retention behavior.</td><td>Both Marmot templates pass client transport and unauthorized-access cases across restart; the server never needs client encryption keys.</td></tr> |
| 635 | + <tr><td>Views, presence & succession</td><td>All registered views, audience-aware storage/on-demand signing, dirty/debounced/hourly/daily triggers, fingerprints, run history, presence, heir configuration, warnings, cancellation and transfer.</td><td>Clock-controlled tests preserve schedules and signed outputs. Member-only derived data stays private. Owner activity cancels succession as before; the relay itself never expires.</td></tr> |
| 636 | + <tr><td>Domains & management surface</td><td>All retained ROUTES and METHODS, CORS/content negotiation, signed API schemas and error behavior. Full ten-family console workflow coverage, including domain Add/Check/Remove, site mapping and destructive-action previews.</td><td>Every retained registry entry has a local handler and tests. Browser journeys exercise all roles, validation/error states, pagination, progress and recovery without requiring the CLI.</td></tr> |
| 637 | + <tr><td>Backup, migration & operations</td><td>Dumps, portable backup list/create/download/delete, preview/restore, complete metadata and keys, files/sites/Git, status/health, audit and local startup/shutdown recovery.</td><td>A whole-feature relay restores into an empty local data directory and passes the same journeys. Final cutover reconciles all stores and queues; an unsupported data family blocks cutover.</td></tr> |
| 638 | + </tbody></table></div> |
| 639 | + <p class="source">Primary inventories: <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/src/routes.ts">ROUTES</a>, <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/src/manage.ts">METHODS</a>, <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/docs/01-relay-configuration.md">console workflows</a>, <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/docs/14-http-reference.md">HTTP reference</a>, <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/src/views.ts">view registry</a>, and both template catalogs. This family matrix organizes the work; individual source entries must still be enumerated so no method is lost inside a broad category.</p> |
| 640 | + <details><summary>Protocol baseline and treatment of previews</summary><div class="detail-body"><p>Inventory the source’s implemented NIP-01, 05, 09, 11, 13, 17, 29, 40, 42, 43, 45, 46 transport, 50, 56, 57, 59, 62, 66, 67, 70, 77, 86, 94, 96 and 98 behavior, together with Blossom, Marmot, NIP-5A, NIP-AD, NIP-9a and GRASP. Include helper behavior such as NIP-65 routing even when it is not represented by a supported-NIP flag.</p><p>Preserve currently implemented preview features and dependency rules at their existing level of support; do not promote them to standards-complete claims or exclude them because they are previews. Derive advertised capabilities from verified enabled implementations. Tests for fuel/lease endpoints become explicit removal tests; all other exclusions need a documented agreed reason. Reconcile source changes before declaring parity rather than silently using a stale checklist.</p></div></details> |
| 641 | + <h3 style="margin-top:30px">Implementation sequence — all phases are required</h3> |
| 642 | + <div class="roadmap"> |
| 643 | + <div class="phase-code">P0 / BASELINE</div><div class="phase"><h3>Enumerated parity contract and instrumented slice</h3><p>Inventory every source route, management method, feature flag, view, template, persisted entity and UI journey. Pin SDK/Khatru/SQLite revisions. Build the first ingest/query slice with metrics, spans and pprof; validate the transaction and lifecycle integration.</p><div class="gate">Checkpoint: every source item has a parity ledger entry; crash/OK and historical/live tests pass; a reproducible benchmark exists. The project continues through every remaining phase.</div></div> |
| 644 | + <div class="phase-code">P1 / CORE</div><div class="phase"><h3>Owned, templated multitenancy</h3><p>Add catalog, owner-at-creation, idempotent provisioning, stable IDs, host/path aliases, policy revisions and lazy runtime lifecycle. Implement plan/apply/export and plain HTML pages with basic forms; reuse no bindws UI assets. Add persistent jobs and backup/restore.</p><div class="gate">Exit: identical event IDs and concurrent requests remain isolated across tenants; restart and handle-eviction tests pass; no lease/trial/billing concepts in the runtime schema; form actions enforce authorization and CSRF protection.</div></div> |
| 645 | + <div class="phase-code">P2 / PERSONAL</div><div class="phase"><h3>Automatic public delivery and private receipt</h3><p>Ship outbox, targeted inbox and DM templates, plus the personal provisioning bundle. Add signed-list discovery, durable routing intent, independent target outcomes, inbox reconnect/backfill and external-signing setup.</p><div class="gate">Exit: failed targets do not stall healthy targets; restart cannot lose intent; policy changes stop forbidden sends; gift wraps are recipient-readable only; duplicate retries converge.</div></div> |
| 646 | + <div class="phase-code">P3 / COMMUNITY</div><div class="phase"><h3>Complete relay and community behavior</h3><p>Finish the protocol/query surface, search/COUNT/negentropy, guest and follow policies, invitation trees, groups, moderation, audit, list history/recovery and all associated web/API workflows. Port the source conformance cases as each behavior lands.</p><div class="gate">Checkpoint: relay/community entries are verified, including private visibility and role-specific browser journeys. Begin migration rehearsals early; this checkpoint does not authorize full cutover.</div></div> |
| 647 | + <div class="phase-code">P4 / HOSTING</div><div class="phase"><h3>Complete files, sites, Git and Marmot</h3><p>Implement all file protocols, verified site hosting/mirroring, custom-domain management, GRASP repository behavior and Marmot transport on local backends. Complete media/site/grasp/marmot/home templates and corresponding connection handoffs.</p><div class="gate">Checkpoint: every hosting family passes its API, browser/client and restart/recovery cases. Objects, refs, signed events and metadata remain consistent; no hosting feature is left as a stub.</div></div> |
| 648 | + <div class="phase-code">P5 / WORKFLOWS</div><div class="phase"><h3>Complete background services and UX coverage</h3><p>Finish callbacks, owner notifications, all views/presence, succession, transfer, forks, backup/restore and any remaining management paths. Check all ten console workflow families, all relay/connection templates, sharing/signers and long-running job feedback.</p><div class="gate">Checkpoint: no retained route, method, feature, template or user journey is unmapped or unimplemented. A plain-HTML interface completes the same tasks without CLI-only substitutions.</div></div> |
| 649 | + <div class="phase-code">P6 / PARITY</div><div class="phase"><h3>Verify full parity and complete local cutover</h3><p>Run the complete retained conformance and workflow matrix; reconcile intentional changes; rehearse migration and rollback with every data family enabled. Profile representative whole-product workloads and fix measured regressions while retaining correctness.</p><div class="gate">Completion: every required ledger entry is verified, full-feature restore/cutover passes, no unexplained data or UX gap remains, and the local deployment is documented. Until then, continue implementation.</div></div> |
| 650 | + </div> |
| 651 | + <details open><summary>Migration contract: preserve what exists, report every change</summary><div class="detail-body"><ol> |
| 652 | + <li><strong>Inventory and dry-run.</strong> Read bindws config exports, portable backup manifests, events, lists/history, memberships, bans, aliases, invites, audit, jobs/callbacks, blobs, site manifests and Git state. Produce mappings of every retained field and data family. Extend the exporter/reader wherever an event-only JSONL or existing portable backup cannot preserve required state.</li> |
| 653 | + <li><strong>Map ownership explicitly.</strong> Claimed relays map to the existing owner. Existing leased/unclaimed data requires an explicit owner assignment or stays in the migration report for manual handling. Do not create ownerless running tenants, trial timers or automatic purge rules.</li> |
| 654 | + <li><strong>Remove economics with a report.</strong> Fuel balances, payment configuration, trial state and hosted quota fields have no runtime destination. Retention remains content policy; show inherited 14/90-day rules and require deliberate selection of the intended final rule.</li> |
| 655 | + <li><strong>Import into a fresh directory.</strong> Verify signatures, retain original event IDs, preserve deletion/vanish state and latest replaceables, rebuild indexes, then compare counts and checksums. Translate policy vocabulary and template revisions. Preserve relay signing identity only through an explicit secure export. If unavailable, record the old/new key mapping, update relay metadata, and regenerate supported NIP-43/NIP-29 records under the new key; disclose the continuity break rather than re-signing historical events.</li> |
| 656 | + <li><strong>Quiesce and cut over after parity.</strong> Rehearse the complete feature set first. Missing support for an enabled feature or stored data family blocks final cutover and remains implementation work. Freeze source management and writes, export final state, reconcile every store, and switch proxy/DNS while preserving public URLs where possible. If URLs change, prepare owner-signed relay-list updates. Start destination workers after queue/origin reconciliation.</li> |
| 657 | + <li><strong>Retain a rollback artifact.</strong> Keep the source read-only and its complete backup. A switch back after new Go writes requires reconciling those writes first; DNS rollback alone is not data rollback. A source backup is recovery material, not a substitute for migrating files, sites, Git or any other retained feature.</li> |
| 658 | + </ol><p class="source">Existing test investment: <a href="https://github.com/FelineStateMachine/bindws/tree/5d31267/test/conformance">bindws black-box conformance suite</a>. Existing backup behavior: <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/src/backups.ts">backups.ts</a>. Full parity requires the retained protocol tests and complete end-to-end workflow/data matrix; protocol advertising alone is insufficient.</p></div></details> |
| 659 | +</section> |
| 660 | + |
| 661 | +<section id="decisions"> |
| 662 | + <div class="section-head"><span class="section-num">11</span><div><h2>Decisions this plan makes.</h2><p>These defaults are concrete enough to implement. A few choices remain experiments, with evidence required to change direction.</p></div></div> |
| 663 | + <div class="decision-grid"> |
| 664 | + <div class="decision"><div class="micro">D01 / Adopt</div><h3>Single daemon, logical tenant isolation.</h3><p>Reduces deployment and coordination cost. Tradeoff: a process or host failure affects every resident relay. Strong process isolation or multiple hosts is a later deployment choice.</p></div> |
| 665 | + <div class="decision"><div class="micro">D02 / Adopt</div><h3>No fuel, trials or relay leases.</h3><p>Owner is assigned at creation; lifetime is indefinite. Remove economic and lease concepts entirely. Keep operator flow control separate from tenant content rules.</p></div> |
| 666 | + <div class="decision"><div class="micro">D03 / Start here</div><h3>SQLite per tenant, FULL sync.</h3><p>Enables event + intent atomicity and simple tenant backup/movement. Tradeoff: one writer per tenant, file-handle management and fsync cost. Compare modernc/cgo only under the same durability contract.</p></div> |
| 667 | + <div class="decision"><div class="micro">D04 / Prove in P0</div><h3>Khatru adapter + current Nostr toolkit.</h3><p>Reuse protocol work while preserving ownership of the domain. Keep the adapter contingent on lifecycle, transaction and visibility tests; do not inherit framework limits without inspection.</p></div> |
| 668 | + <div class="decision"><div class="micro">D05 / Adopt</div><h3>Declarative templates with immutable revisions.</h3><p>A template changes policy and services; it never runs tenant code. A personal bundle is a family of standard relay instances, each independently manageable.</p></div> |
| 669 | + <div class="decision"><div class="micro">D06 / Adopt</div><h3>Local acceptance; asynchronous distribution.</h3><p>Durable routing intent is committed with the event. Delivery is at least once and independently observable. Private DM routing remains separate from public fan-out.</p></div> |
| 670 | + <div class="decision"><div class="micro">D07 / Adopt</div><h3>Metrics now; optimization when measured.</h3><p>Ship a stable low-cardinality metric vocabulary, logs and optional tracing with the first slice. Numeric capacity targets follow baseline measurements on the intended host.</p></div> |
| 671 | + <div class="decision"><div class="micro">D08 / Required</div><h3>Full functional and UX parity.</h3><p>All retained bindws features, APIs, templates, data and user journeys are required. Blobs, sites, Git, Marmot, views, callbacks and succession are part of completion. Phases sequence this work; a subset does not satisfy the project.</p></div> |
| 672 | + <div class="decision"><div class="micro">D09 / Adopt</div><h3>Plain HTML with complete workflows.</h3><p>Carry over no bindws branding or styling. Preserve tasks, permissions, input/output behavior, signing, previews, progress, sharing and recovery through simple pages/forms and focused helpers. Keep all existing web tasks available on the web.</p></div> |
| 673 | + <div class="decision"><div class="micro">D10 / Separate concern</div><h3>Local application parity; optional clustering.</h3><p>Replace Worker mechanisms with local services and finish the complete application on one host. Multi-host placement or automatic failover can be separate deployment work; neither is a reason to omit application capabilities.</p></div> |
| 674 | + </div> |
| 675 | +</section> |
| 676 | + |
| 677 | +<section id="sources"> |
| 678 | + <div class="section-head"><span class="section-num">12</span><div><h2>Evidence behind the proposal.</h2><p>Repository observations are separated from proposed behavior. External references are primary project or specification sources checked on 07 Sep 2026.</p></div></div> |
| 679 | + <div class="reference-grid"> |
| 680 | + <p><a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/docs/10-architecture.md">Bindws / current architecture</a><br>Inspected local revision 5d31267. Routing, DO lifecycle, SQLite/R2, alarms, jobs, auth and optional hosting.</p> |
| 681 | + <p><a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/src/delivery.ts">Bindws / durable NIP-65 delivery</a><br>Queue schema, target selection, fixed queue/attempt bounds, policy revalidation and delivery status.</p> |
| 682 | + <p><a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/relay-templates/README.md">Bindws / relay and connection templates</a><br>First-class configuration documents and omitted-section semantics. Inbox predicate gap is documented in 03-inbox.jsonc.</p> |
| 683 | + <p><a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/docs/11-hosting-bindws.md">Bindws / existing telemetry</a><br>Usage logs, optional metrics and provider traces. The proposal replaces their billing/runtime coupling.</p> |
| 684 | + <p><a href="https://github.com/nostr-protocol/nips">Nostr / NIP specifications</a><br>Implement selected NIPs against their exact checked revision; test storage, AUTH, deletion, expiration and relay-list semantics.</p> |
| 685 | + <p><a href="https://pkg.go.dev/fiatjaf.com/nostr/khatru">Current Khatru API</a><br>Current module path, relay hooks and Unlicense classification. SDK/store APIs and legacy paths are linked in the comparison above.</p> |
| 686 | + <p><a href="https://github.com/barrydeen/haven">HAVEN / personal relay roles</a><br>Closest product prior art for public/private role separation and automated gather/broadcast behavior.</p> |
| 687 | + <p><a href="https://github.com/hoytech/strfry">strfry / operational reference</a><br>Durable acknowledgements, storage specialization and relay synchronization; GPLv3 implementation.</p> |
| 688 | + <p><a href="https://www.sqlite.org/wal.html">SQLite / WAL</a><br>Concurrency, local-filesystem requirement and checkpoint behavior. Inspect embedded-version fixes when pinning a driver.</p> |
| 689 | + <p><a href="https://opentelemetry.io/docs/languages/go/">OpenTelemetry / Go</a><br>Instrumentation and optional export. Tail sampling is an additional collector choice, not a property of head sampling.</p> |
| 690 | + <p><a href="https://prometheus.io/docs/practices/naming/">Prometheus / metric design</a><br>Units, metric names and label cardinality. Detailed identifiers belong in protected diagnostics.</p> |
| 691 | + <p><a href="https://go.dev/doc/diagnostics">Go / profiling and diagnostics</a><br>Use metrics, profiles and execution traces to explain costs before changing implementation.</p> |
| 692 | + </div> |
| 693 | + <p class="fine" style="margin-top:23px">Research scope: adjacent source checkout, current package documentation, relay repositories and protocol/observability documentation. This document records the original architectural decisions. Native implementation and retained conformance tests now run in this workspace. See the <a href="artifacts/git-performance/git-performance.html">real-repository Git performance report</a> and <a href="docs/parity.md">parity ledger</a> for current evidence; the original proposals below are not a claim of completed acceptance.</p> |
| 694 | +</section> |
| 695 | +<footer class="foot"><span>tinyrelay / Architectural proposal 01</span><span>Full functional & UX parity. Native Go. No fuel. No leases.</span></footer> |
| 696 | +</main> |
| 697 | +<script> |
| 698 | +(() => { |
| 699 | + const paths = { |
| 700 | + publish: {title:"Publish an event",nodes:["edge","registry","runtime","store"],text:"<strong>Resolve → validate → authorize → commit → acknowledge → fan-out.</strong> Persist the event and routing intent together. An OK confirms local durability; remote delivery proceeds independently. The event never crosses another tenant’s runtime implicitly."}, |
| 701 | + subscribe: {title:"Open a subscription",nodes:["edge","registry","runtime","store"],text:"<strong>Resolve → authenticate → authorize → snapshot → EOSE → live events.</strong> A commit-sequence barrier bridges the historical query and live stream. Per-event visibility applies in both paths. Slow sockets are disconnected without holding a database reader open."}, |
| 702 | + delivery: {title:"Deliver in the background",nodes:["registry","runtime","store","workers"],text:"<strong>Due intent → acquire tenant → resolve lists → recheck policy → send → persist outcome.</strong> Shared workers continue with no connected clients. Each target has independent progress. Inbox pulls enter the same ingest service with an origin that prevents rebroadcast loops."}, |
| 703 | + create: {title:"Create an owned relay",nodes:["edge","registry","control","store"],text:"<strong>Authenticate creator → reserve name → initialize owner + template → mark ready.</strong> The catalog publishes the route only after the tenant database is initialized. An idempotent operation recovers interrupted creation. No lease, trial or claim conversion exists."} |
| 704 | + }; |
| 705 | + document.querySelectorAll("[data-path]").forEach(button => button.addEventListener("click", () => { |
| 706 | + const selected = paths[button.dataset.path]; |
| 707 | + document.querySelectorAll("[data-path]").forEach(item => item.setAttribute("aria-pressed",String(item === button))); |
| 708 | + document.querySelectorAll("[data-node]").forEach(node => node.classList.toggle("hot",selected.nodes.includes(node.dataset.node))); |
| 709 | + document.getElementById("path-title").textContent = selected.title; |
| 710 | + document.getElementById("path-description").innerHTML = selected.text; |
| 711 | + })); |
| 712 | + const roles = { |
| 713 | + outbox:{title:"Public outbox",about:"Your public events live here and automatically travel to the relays that should receive them.",writes:"Owner; signed events only",reads:"Public",auto:"Owner write relays + tagged recipients’ read relays + explicit mirrors",retention:"No automatic age-based purge",config:{format:"tinyrelay/template/1",id:"personal-outbox",revision:1,policy:{writes:"owner",reads:"public",blockPrivateKinds:true},automation:{delivery:"nip65-public",origin:"local-client"},retention:[]}}, |
| 714 | + inbox:{title:"Public mentions inbox",about:"A targeted home for mentions, replies, reactions and other configured public events addressed to you.",writes:"Valid public events with p tag naming an allowed local recipient",reads:"Public",auto:"Gather matching mentions from configured sources and owner read relays",retention:"Explicit content policy; no implicit trial expiry",config:{format:"tinyrelay/template/1",id:"personal-inbox",revision:1,policy:{writes:"recipient-tagged",reads:"public",recipients:"owner",blockPrivateKinds:true},automation:{gather:"public-mentions",sources:"owner-read-relays"},retention:[]}}, |
| 715 | + dm:{title:"Private DM inbox",about:"Store opaque gift wraps for local recipients. Public discovery and private readability are different concerns.",writes:"Valid kind 1059 addressed to a local recipient",reads:"Authenticated recipient, checked per event",auto:"Owner-signed kind 10050 discovery; clients deliver wraps directly",retention:"No automatic age-based purge",config:{format:"tinyrelay/template/1",id:"personal-dm",revision:1,policy:{writes:"recipient-tagged",reads:"authenticated-recipient",allowKinds:[1059],recipients:"owner"},automation:{discovery:"nip17",delivery:"disabled"},retention:[]}}, |
| 716 | + community:{title:"Community relay",about:"A named place for members to publish, with a portable policy and clear moderator ownership.",writes:"Admitted members; optional moderator rules",reads:"Public by this template; private is an explicit policy change",auto:"None by default; owner can configure public delivery or mirrors",retention:"Owner-selected content policy",config:{format:"tinyrelay/template/1",id:"community",revision:1,policy:{writes:"members",reads:"public",blockPrivateKinds:true},automation:{delivery:"disabled"},retention:[]}} |
| 717 | + }; |
| 718 | + document.getElementById("template-select").addEventListener("change",event => { |
| 719 | + const role = roles[event.target.value]; |
| 720 | + ["title","about","writes","reads","auto","retention"].forEach(key => document.getElementById("template-"+key).textContent = role[key]); |
| 721 | + document.getElementById("template-code").textContent = JSON.stringify(role.config,null,2); |
| 722 | + }); |
| 723 | + const navLinks = [...document.querySelectorAll("nav a")]; |
| 724 | + if ("IntersectionObserver" in window) { |
| 725 | + const observer = new IntersectionObserver(entries => { |
| 726 | + entries.forEach(entry => { |
| 727 | + if(entry.isIntersecting) navLinks.forEach(link => link.classList.toggle("active",link.getAttribute("href")==="#"+entry.target.id)); |
| 728 | + }); |
| 729 | + },{rootMargin:"-5% 0px -70% 0px",threshold:0}); |
| 730 | + document.querySelectorAll("main section, #overview").forEach(section => observer.observe(section)); |
| 731 | + } |
| 732 | + let printDetails = []; |
| 733 | + window.addEventListener("beforeprint",() => { |
| 734 | + printDetails = [...document.querySelectorAll("details")].map(element => ({element,open:element.open})); |
| 735 | + printDetails.forEach(item => item.element.open = true); |
| 736 | + }); |
| 737 | + window.addEventListener("afterprint",() => printDetails.forEach(item => item.element.open = item.open)); |
| 738 | + document.getElementById("print").addEventListener("click",() => window.print()); |
| 739 | +})(); |
| 740 | +</script> |
| 741 | +</body> |
| 742 | +</html> |
| 743 | diff --git a/cmd/tiny/main.go b/cmd/tiny/main.go |
| 744 | new file mode 100644 |
| 745 | index 0000000..2cc54c4 |
| 746 | --- /dev/null |
| 747 | +++ b/cmd/tiny/main.go |
| 748 | @@ -0,0 +1,324 @@ |
| 749 | +package main |
| 750 | + |
| 751 | +import ( |
| 752 | + "context" |
| 753 | + "encoding/json" |
| 754 | + "errors" |
| 755 | + "flag" |
| 756 | + "fmt" |
| 757 | + "io" |
| 758 | + "net" |
| 759 | + "net/http" |
| 760 | + "net/url" |
| 761 | + "os" |
| 762 | + "os/signal" |
| 763 | + "path/filepath" |
| 764 | + "strings" |
| 765 | + "syscall" |
| 766 | + "time" |
| 767 | + |
| 768 | + "github.com/FelineStateMachine/tinyrelay/internal/daemon" |
| 769 | + "github.com/FelineStateMachine/tinyrelay/internal/templates" |
| 770 | +) |
| 771 | + |
| 772 | +const version = "0.1.0-dev" |
| 773 | + |
| 774 | +func main() { |
| 775 | + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) |
| 776 | + err := run(ctx, os.Args[1:], os.Stdout) |
| 777 | + stop() |
| 778 | + if err != nil { |
| 779 | + fmt.Fprintln(os.Stderr, "tiny:", err) |
| 780 | + os.Exit(1) |
| 781 | + } |
| 782 | +} |
| 783 | + |
| 784 | +func run(ctx context.Context, args []string, out io.Writer) error { |
| 785 | + if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" { |
| 786 | + _, err := fmt.Fprintln(out, "tiny serve [--data-dir PATH] [--listen :7447]\ntiny tenant create --name NAME --owner PUBKEY [--template default]\ntiny tenant list|enable|disable|host [options]\ntiny templates\ntiny version") |
| 787 | + return err |
| 788 | + } |
| 789 | + switch args[0] { |
| 790 | + case "version": |
| 791 | + _, err := fmt.Fprintln(out, "tiny", version) |
| 792 | + return err |
| 793 | + case "templates": |
| 794 | + return json.NewEncoder(out).Encode(templates.Names()) |
| 795 | + case "serve": |
| 796 | + return serve(ctx, args[1:], out) |
| 797 | + case "tenant": |
| 798 | + return tenantCommand(ctx, args[1:], out) |
| 799 | + default: |
| 800 | + return fmt.Errorf("unknown command %q; run tiny help", args[0]) |
| 801 | + } |
| 802 | +} |
| 803 | + |
| 804 | +func defaultDataDir() string { |
| 805 | + if value := os.Getenv("TINY_DATA_DIR"); value != "" { |
| 806 | + return value |
| 807 | + } |
| 808 | + if value := os.Getenv("XDG_DATA_HOME"); value != "" { |
| 809 | + return filepath.Join(value, "tiny") |
| 810 | + } |
| 811 | + return "data" |
| 812 | +} |
| 813 | + |
| 814 | +func tenantCommand(ctx context.Context, args []string, out io.Writer) error { |
| 815 | + if len(args) == 0 { |
| 816 | + return errors.New("tenant subcommand required") |
| 817 | + } |
| 818 | + fs := flag.NewFlagSet("tiny tenant "+args[0], flag.ContinueOnError) |
| 819 | + dataDir := fs.String("data-dir", defaultDataDir(), "local data directory") |
| 820 | + name := fs.String("name", "", "tenant name") |
| 821 | + owner := fs.String("owner", "", "owner public key (64 lowercase hex characters)") |
| 822 | + templateName := fs.String("template", "default", "relay template") |
| 823 | + source := fs.String("source", "", "upstream relay for a replica template") |
| 824 | + host := fs.String("host", "", "custom hostname") |
| 825 | + if err := fs.Parse(args[1:]); err != nil { |
| 826 | + return err |
| 827 | + } |
| 828 | + app, err := daemon.New(ctx, daemon.Config{DataDir: *dataDir, Version: version}) |
| 829 | + if err != nil { |
| 830 | + return err |
| 831 | + } |
| 832 | + defer func() { |
| 833 | + if err := app.Close(ctx); err != nil { |
| 834 | + fmt.Fprintln(os.Stderr, "close tenant catalog:", err) |
| 835 | + } |
| 836 | + }() |
| 837 | + if args[0] == "create" { |
| 838 | + tenant, err := app.Create(ctx, daemon.CreateOptions{Name: *name, Owner: *owner, Template: *templateName, Source: *source}) |
| 839 | + if err != nil { |
| 840 | + return err |
| 841 | + } |
| 842 | + return json.NewEncoder(out).Encode(tenant) |
| 843 | + } |
| 844 | + if args[0] == "list" { |
| 845 | + tenants, err := app.Catalog().List(ctx) |
| 846 | + if err != nil { |
| 847 | + return err |
| 848 | + } |
| 849 | + return json.NewEncoder(out).Encode(tenants) |
| 850 | + } |
| 851 | + meta, err := app.Catalog().GetByName(ctx, *name) |
| 852 | + if err != nil { |
| 853 | + return err |
| 854 | + } |
| 855 | + switch args[0] { |
| 856 | + case "enable": |
| 857 | + err = app.Catalog().Enable(ctx, meta.ID) |
| 858 | + case "disable": |
| 859 | + err = app.Catalog().Disable(ctx, meta.ID) |
| 860 | + case "host": |
| 861 | + if *host == "" { |
| 862 | + err = app.Catalog().ClearHost(ctx, meta.ID) |
| 863 | + } else { |
| 864 | + err = app.Catalog().SetHost(ctx, meta.ID, *host) |
| 865 | + } |
| 866 | + default: |
| 867 | + return fmt.Errorf("unknown tenant command %q", args[0]) |
| 868 | + } |
| 869 | + if err != nil { |
| 870 | + return err |
| 871 | + } |
| 872 | + meta, err = app.Catalog().GetByID(ctx, meta.ID) |
| 873 | + if err != nil { |
| 874 | + return err |
| 875 | + } |
| 876 | + return json.NewEncoder(out).Encode(meta) |
| 877 | +} |
| 878 | + |
| 879 | +type serveOptions struct { |
| 880 | + cfg daemon.Config |
| 881 | + listen string |
| 882 | + diagnostics string |
| 883 | + peers []string |
| 884 | + peerInterval time.Duration |
| 885 | + peerTimeout time.Duration |
| 886 | + peerPublish bool |
| 887 | + peerPublishTenant string |
| 888 | +} |
| 889 | + |
| 890 | +func parseServe(args []string) (serveOptions, error) { |
| 891 | + var opts serveOptions |
| 892 | + fs := flag.NewFlagSet("tiny serve", flag.ContinueOnError) |
| 893 | + fs.StringVar(&opts.cfg.DataDir, "data-dir", defaultDataDir(), "local data directory") |
| 894 | + fs.StringVar(&opts.listen, "listen", ":7447", "public HTTP listener") |
| 895 | + fs.StringVar(&opts.cfg.PublicURL, "public-url", os.Getenv("TINY_PUBLIC_URL"), "external https URL (reverse proxy)") |
| 896 | + fs.StringVar(&opts.cfg.DefaultTenant, "default-tenant", "main", "tenant served on the root path") |
| 897 | + fs.StringVar(&opts.diagnostics, "diagnostics", "", "separate diagnostics listener, e.g.127.0.0.1:9090") |
| 898 | + fs.StringVar(&opts.cfg.Telemetry.DebugToken, "diagnostics-token", os.Getenv("TINY_DIAGNOSTICS_TOKEN"), "diagnostics bearer token") |
| 899 | + fs.StringVar(&opts.cfg.Telemetry.OTLPEndpoint, "otlp-endpoint", os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"), "optional OTLP HTTP trace export") |
| 900 | + fs.Float64Var(&opts.cfg.Telemetry.TraceSampleRatio, "trace-sample", 0.01, "trace sampling ratio") |
| 901 | + fs.Int64Var(&opts.cfg.MaxMessageBytes, "max-message-bytes", 0, "optional host input byte guard; 0 unlimited") |
| 902 | + fs.BoolVar(&opts.cfg.AllowPrivateRelays, "allow-private-relays", false, "allow outbound relay connections to localhost, LAN, and Tailscale addresses") |
| 903 | + fs.Func("provision-owner", "operator-approved pubkey allowed to create tenants through the browser (repeatable)", func(value string) error { |
| 904 | + value = strings.TrimSpace(value) |
| 905 | + if len(value) != 64 || value != strings.ToLower(value) || !hexOwner(value) { |
| 906 | + return errors.New("provision owner must be a lowercase 64-character hex pubkey") |
| 907 | + } |
| 908 | + opts.cfg.ProvisionOwners = append(opts.cfg.ProvisionOwners, value) |
| 909 | + return nil |
| 910 | + }) |
| 911 | + fs.Func("discovery-relay", "relay used to discover NIP-65 and inbox lists (repeatable)", func(value string) error { |
| 912 | + if !strings.HasPrefix(value, "ws://") && !strings.HasPrefix(value, "wss://") { |
| 913 | + return errors.New("discovery relay must use ws:// or wss://") |
| 914 | + } |
| 915 | + opts.cfg.DiscoveryRelays = append(opts.cfg.DiscoveryRelays, value) |
| 916 | + return nil |
| 917 | + }) |
| 918 | + fs.Func("push-callback-origin", "operator-approved HTTPS callback origin (repeatable)", func(value string) error { |
| 919 | + u, err := url.Parse(value) |
| 920 | + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { |
| 921 | + return errors.New("callback origin must be https://hostname[:port]") |
| 922 | + } |
| 923 | + opts.cfg.PushCallbackOrigins = append(opts.cfg.PushCallbackOrigins, "https://"+strings.ToLower(u.Host)) |
| 924 | + return nil |
| 925 | + }) |
| 926 | + fs.IntVar(&opts.cfg.MaxPendingBytes, "max-pending-bytes", 4<<20, "connection output backpressure guard") |
| 927 | + fs.Func("peer", "configured NIP-66 peer URL to probe (repeatable)", func(value string) error { |
| 928 | + opts.peers = append(opts.peers, value) |
| 929 | + return nil |
| 930 | + }) |
| 931 | + fs.DurationVar(&opts.peerInterval, "peer-interval", time.Minute, "NIP-66 peer probe interval") |
| 932 | + fs.DurationVar(&opts.peerTimeout, "peer-timeout", 5*time.Second, "NIP-66 peer probe timeout") |
| 933 | + fs.BoolVar(&opts.peerPublish, "peer-publish", false, "publish signed NIP-66 status for the explicitly selected tenant") |
| 934 | + fs.StringVar(&opts.peerPublishTenant, "peer-publish-tenant", "", "tenant whose relay identity publishes NIP-66 status (required with --peer-publish)") |
| 935 | + opts.cfg.Version = version |
| 936 | + if err := fs.Parse(args); err != nil { |
| 937 | + return opts, err |
| 938 | + } |
| 939 | + if opts.diagnostics != "" && strings.TrimSpace(opts.cfg.Telemetry.DebugToken) == "" { |
| 940 | + return opts, errors.New("diagnostics listener requires --diagnostics-token or TINY_DIAGNOSTICS_TOKEN") |
| 941 | + } |
| 942 | + if opts.peerPublish && strings.TrimSpace(opts.peerPublishTenant) == "" { |
| 943 | + return opts, errors.New("--peer-publish requires --peer-publish-tenant") |
| 944 | + } |
| 945 | + if opts.peerPublish && len(opts.peers) == 0 { |
| 946 | + return opts, errors.New("--peer-publish requires at least one --peer peer URL") |
| 947 | + } |
| 948 | + if len(opts.peers) > 0 { |
| 949 | + opts.cfg.PeerMonitor = &daemon.PeerMonitorConfig{Peers: opts.peers, Interval: opts.peerInterval, Timeout: opts.peerTimeout, Publish: opts.peerPublish} |
| 950 | + } |
| 951 | + return opts, nil |
| 952 | +} |
| 953 | + |
| 954 | +func hexOwner(value string) bool { |
| 955 | + for _, r := range value { |
| 956 | + if r >= '0' && r <= '9' || r >= 'a' && r <= 'f' { |
| 957 | + continue |
| 958 | + } |
| 959 | + return false |
| 960 | + } |
| 961 | + return true |
| 962 | +} |
| 963 | + |
| 964 | +func serve(ctx context.Context, args []string, out io.Writer) error { |
| 965 | + opts, err := parseServe(args) |
| 966 | + if err != nil { |
| 967 | + return err |
| 968 | + } |
| 969 | + app, err := daemon.New(ctx, opts.cfg) |
| 970 | + if err != nil { |
| 971 | + return err |
| 972 | + } |
| 973 | + defer func() { |
| 974 | + closeCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) |
| 975 | + defer cancel() |
| 976 | + if err := app.Close(closeCtx); err != nil { |
| 977 | + fmt.Fprintln(os.Stderr, "close daemon:", err) |
| 978 | + } |
| 979 | + }() |
| 980 | + listener, err := net.Listen("tcp", opts.listen) |
| 981 | + if err != nil { |
| 982 | + return fmt.Errorf("listen: %w", err) |
| 983 | + } |
| 984 | + baseURL := opts.cfg.PublicURL |
| 985 | + if baseURL == "" { |
| 986 | + host, port, err := net.SplitHostPort(listener.Addr().String()) |
| 987 | + if err != nil { |
| 988 | + return errors.Join(err, listener.Close()) |
| 989 | + } |
| 990 | + if ip := net.ParseIP(host); host == "" || (ip != nil && ip.IsUnspecified()) { |
| 991 | + host = "127.0.0.1" |
| 992 | + } |
| 993 | + baseURL = "http://" + net.JoinHostPort(host, port) |
| 994 | + } |
| 995 | + if err := app.Start(ctx, baseURL); err != nil { |
| 996 | + return errors.Join(err, listener.Close()) |
| 997 | + } |
| 998 | + if opts.peerPublish { |
| 999 | + if err := app.ConfigurePeerPublication(ctx, opts.peerPublishTenant); err != nil { |
| 1000 | + return errors.Join(err, listener.Close()) |
| 1001 | + } |
| 1002 | + } |
| 1003 | + peerCtx, stopPeer := context.WithCancel(ctx) |
| 1004 | + defer stopPeer() |
| 1005 | + peerDone := make(chan error, 1) |
| 1006 | + if app.PeerMonitor() != nil { |
| 1007 | + go func() { peerDone <- app.PeerMonitorRun(peerCtx) }() |
| 1008 | + } else { |
| 1009 | + close(peerDone) |
| 1010 | + } |
| 1011 | + server := &http.Server{Handler: app, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 90 * time.Second} |
| 1012 | + servers := []*http.Server{server} |
| 1013 | + results := make(chan error, 1) |
| 1014 | + go func() { results <- server.Serve(listener) }() |
| 1015 | + if opts.diagnostics != "" { |
| 1016 | + diag, err := net.Listen("tcp", opts.diagnostics) |
| 1017 | + if err != nil { |
| 1018 | + closeErr := server.Close() |
| 1019 | + return errors.Join(err, closeErr) |
| 1020 | + } |
| 1021 | + debugServer := &http.Server{Handler: app.Diagnostics(), ReadHeaderTimeout: 10 * time.Second} |
| 1022 | + servers = append(servers, debugServer) |
| 1023 | + go func() { results <- debugServer.Serve(diag) }() |
| 1024 | + } |
| 1025 | + if _, err := fmt.Fprintf(out, "tiny listening on %s; data in %s\n", listener.Addr(), opts.cfg.DataDir); err != nil { |
| 1026 | + for _, srv := range servers { |
| 1027 | + if closeErr := srv.Close(); closeErr != nil { |
| 1028 | + err = errors.Join(err, closeErr) |
| 1029 | + } |
| 1030 | + } |
| 1031 | + for range servers { |
| 1032 | + <-results |
| 1033 | + } |
| 1034 | + return err |
| 1035 | + } |
| 1036 | + serverErr := awaitServers(ctx, servers, results) |
| 1037 | + stopPeer() |
| 1038 | + if app.PeerMonitor() != nil { |
| 1039 | + if err := <-peerDone; err != nil && !errors.Is(err, context.Canceled) { |
| 1040 | + serverErr = errors.Join(serverErr, err) |
| 1041 | + } |
| 1042 | + } |
| 1043 | + return serverErr |
| 1044 | +} |
| 1045 | + |
| 1046 | +func awaitServers(ctx context.Context, servers []*http.Server, results <-chan error) error { |
| 1047 | + var first error |
| 1048 | + received := 0 |
| 1049 | + select { |
| 1050 | + case <-ctx.Done(): |
| 1051 | + case first = <-results: |
| 1052 | + received = 1 |
| 1053 | + } |
| 1054 | + if errors.Is(first, http.ErrServerClosed) { |
| 1055 | + first = nil |
| 1056 | + } |
| 1057 | + shutdown, cancel := context.WithTimeout(context.Background(), 15*time.Second) |
| 1058 | + defer cancel() |
| 1059 | + for _, server := range servers { |
| 1060 | + if err := server.Shutdown(shutdown); err != nil { |
| 1061 | + first = errors.Join(first, err, server.Close()) |
| 1062 | + } |
| 1063 | + } |
| 1064 | + for received < len(servers) { |
| 1065 | + err := <-results |
| 1066 | + received++ |
| 1067 | + if !errors.Is(err, http.ErrServerClosed) { |
| 1068 | + first = errors.Join(first, err) |
| 1069 | + } |
| 1070 | + } |
| 1071 | + return first |
| 1072 | +} |
| 1073 | diff --git a/cmd/tiny/main_test.go b/cmd/tiny/main_test.go |
| 1074 | new file mode 100644 |
| 1075 | index 0000000..0569106 |
| 1076 | --- /dev/null |
| 1077 | +++ b/cmd/tiny/main_test.go |
| 1078 | @@ -0,0 +1,38 @@ |
| 1079 | +package main |
| 1080 | + |
| 1081 | +import ( |
| 1082 | + "bytes" |
| 1083 | + "context" |
| 1084 | + "errors" |
| 1085 | + "net/http" |
| 1086 | + "strings" |
| 1087 | + "testing" |
| 1088 | +) |
| 1089 | + |
| 1090 | +func TestCommandNameAndPermanentTenant(t *testing.T) { |
| 1091 | + var out bytes.Buffer |
| 1092 | + if err := run(context.Background(), []string{"help"}, &out); err != nil { |
| 1093 | + t.Fatal(err) |
| 1094 | + } |
| 1095 | + if !strings.Contains(out.String(), "tiny serve") || strings.Contains(out.String(), "tinyrelay serve") { |
| 1096 | + t.Fatal(out.String()) |
| 1097 | + } |
| 1098 | + args := []string{"tenant", "create", "--data-dir", t.TempDir(), "--name", "main", "--owner", strings.Repeat("a", 64)} |
| 1099 | + if err := run(context.Background(), args, &out); err != nil { |
| 1100 | + t.Fatal(err) |
| 1101 | + } |
| 1102 | + if !strings.Contains(out.String(), "ready") { |
| 1103 | + t.Fatal(out.String()) |
| 1104 | + } |
| 1105 | +} |
| 1106 | + |
| 1107 | +func TestServerShutdownDoesNotHideAnotherFailure(t *testing.T) { |
| 1108 | + results := make(chan error, 1) |
| 1109 | + results <- http.ErrServerClosed |
| 1110 | + want := errors.New("diagnostics listener failed") |
| 1111 | + go func() { results <- want }() |
| 1112 | + err := awaitServers(context.Background(), []*http.Server{{}, {}}, results) |
| 1113 | + if !errors.Is(err, want) { |
| 1114 | + t.Fatalf("got %v, want %v", err, want) |
| 1115 | + } |
| 1116 | +} |
| 1117 | diff --git a/deploy/Tailscale.Dockerfile b/deploy/Tailscale.Dockerfile |
| 1118 | new file mode 100644 |
| 1119 | index 0000000..d4a5c31 |
| 1120 | --- /dev/null |
| 1121 | +++ b/deploy/Tailscale.Dockerfile |
| 1122 | @@ -0,0 +1,35 @@ |
| 1123 | +# Builds the official Tailscale v1.102.3 source at its verified commit with the |
| 1124 | +# narrow PortlistServices eventbus fix. Upstream issue: #18297. |
| 1125 | + |
| 1126 | +FROM golang:1.26-alpine AS build |
| 1127 | +ARG TAILSCALE_COMMIT=53a0d659afa51835dd7a9283873cca44261454f8 |
| 1128 | +ARG VERSION_LONG=1.102.3 |
| 1129 | +ARG VERSION_SHORT=1.102.3 |
| 1130 | +ARG VERSION_GIT_HASH=53a0d659afa51835dd7a9283873cca44261454f8 |
| 1131 | +WORKDIR /go/src/tailscale |
| 1132 | +RUN apk add --no-cache git ca-certificates |
| 1133 | +RUN git init . \ |
| 1134 | + && git remote add origin https://github.com/tailscale/tailscale.git \ |
| 1135 | + && git fetch --depth=1 origin ${TAILSCALE_COMMIT} \ |
| 1136 | + && git checkout --detach ${TAILSCALE_COMMIT} |
| 1137 | +COPY tailscale-portlist.patch /tmp/tailscale-portlist.patch |
| 1138 | +RUN git apply /tmp/tailscale-portlist.patch |
| 1139 | +RUN go mod download |
| 1140 | +RUN go install \ |
| 1141 | + github.com/aws/aws-sdk-go-v2/aws \ |
| 1142 | + github.com/aws/aws-sdk-go-v2/config \ |
| 1143 | + gvisor.dev/gvisor/pkg/tcpip/adapters/gonet \ |
| 1144 | + gvisor.dev/gvisor/pkg/tcpip/stack \ |
| 1145 | + golang.org/x/crypto/ssh \ |
| 1146 | + golang.org/x/crypto/acme \ |
| 1147 | + github.com/coder/websocket \ |
| 1148 | + github.com/mdlayher/netlink |
| 1149 | +ARG TARGETARCH |
| 1150 | +RUN GOARCH=${TARGETARCH} go install \ |
| 1151 | + -tags=ts_kube,ts_package_container \ |
| 1152 | + -ldflags="-X tailscale.com/version.longStamp=${VERSION_LONG} -X tailscale.com/version.shortStamp=${VERSION_SHORT} -X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \ |
| 1153 | + ./cmd/tailscale ./cmd/tailscaled ./cmd/containerboot |
| 1154 | + |
| 1155 | +# Keep the official container filesystem, entry command, and runtime support. |
| 1156 | +FROM tailscale/tailscale@sha256:8c42c4574ab066384fcb72f69e086a2ff1dd3652eb6f56856cee34bcf0d2f680 |
| 1157 | +COPY --from=build /go/bin/* /usr/local/bin/ |
| 1158 | diff --git a/deploy/compose.yaml b/deploy/compose.yaml |
| 1159 | new file mode 100644 |
| 1160 | index 0000000..7e31f3f |
| 1161 | --- /dev/null |
| 1162 | +++ b/deploy/compose.yaml |
| 1163 | @@ -0,0 +1,17 @@ |
| 1164 | +services: |
| 1165 | + tiny: |
| 1166 | + build: |
| 1167 | + context: .. |
| 1168 | + target: runtime |
| 1169 | + command: ["serve", "--listen", ":7447", "--public-url", "${TINY_PUBLIC_URL:-http://localhost:7447}"] |
| 1170 | + ports: |
| 1171 | + - "7447:7447" |
| 1172 | + volumes: |
| 1173 | + - tiny-data:/data |
| 1174 | + environment: |
| 1175 | + TINY_DATA_DIR: /data |
| 1176 | + TINY_PUBLIC_URL: ${TINY_PUBLIC_URL:-http://localhost:7447} |
| 1177 | + restart: unless-stopped |
| 1178 | + |
| 1179 | +volumes: |
| 1180 | + tiny-data: |
| 1181 | diff --git a/deploy/renew-tiny-certificates.sh b/deploy/renew-tiny-certificates.sh |
| 1182 | new file mode 100644 |
| 1183 | index 0000000..8e908c2 |
| 1184 | --- /dev/null |
| 1185 | +++ b/deploy/renew-tiny-certificates.sh |
| 1186 | @@ -0,0 +1,26 @@ |
| 1187 | +#!/usr/bin/env bash |
| 1188 | +set -euo pipefail |
| 1189 | +umask 077 |
| 1190 | + |
| 1191 | +test "$(findmnt -n -o UUID --target /srv/tiny)" = 98f49cba-bd8c-432d-a91a-41db66920e90 |
| 1192 | +install -d -m 700 /srv/tiny/certs /srv/tiny/tailscale/certs |
| 1193 | +docker exec tiny-tailscale tailscale cert --min-validity=336h \ |
| 1194 | + --cert-file=/var/lib/tailscale/certs/tailnet.crt \ |
| 1195 | + --key-file=/var/lib/tailscale/certs/tailnet.key tiny.tailbe516a.ts.net |
| 1196 | +install -m 644 /srv/tiny/tailscale/certs/tailnet.crt /srv/tiny/certs/tailnet.crt |
| 1197 | +install -m 600 /srv/tiny/tailscale/certs/tailnet.key /srv/tiny/certs/tailnet.key |
| 1198 | + |
| 1199 | +if ! openssl x509 -checkend 1209600 -noout -in /srv/tiny/certs/tiny.crt >/dev/null 2>&1; then |
| 1200 | + openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \ |
| 1201 | + -keyout /srv/tiny/certs/tiny.key -out /srv/tiny/certs/tiny.csr \ |
| 1202 | + -subj /CN=tiny -addext subjectAltName=DNS:tiny |
| 1203 | + openssl x509 -req -in /srv/tiny/certs/tiny.csr \ |
| 1204 | + -CA /var/lib/sandboxd/certs/ca.crt -CAkey /var/lib/sandboxd/certs/ca.key \ |
| 1205 | + -set_serial "0x$(openssl rand -hex 16)" -days 90 -sha256 \ |
| 1206 | + -copy_extensions copy -out /srv/tiny/certs/tiny.crt |
| 1207 | + chmod 644 /srv/tiny/certs/tiny.crt |
| 1208 | +fi |
| 1209 | + |
| 1210 | +if [ "$(docker inspect -f '{{.State.Running}}' tiny-https 2>/dev/null || true)" = true ]; then |
| 1211 | + docker exec tiny-https caddy reload --config /etc/caddy/Caddyfile --force |
| 1212 | +fi |
| 1213 | diff --git a/deploy/slate-tiny.nix b/deploy/slate-tiny.nix |
| 1214 | new file mode 100644 |
| 1215 | index 0000000..f6218ea |
| 1216 | --- /dev/null |
| 1217 | +++ b/deploy/slate-tiny.nix |
| 1218 | @@ -0,0 +1,54 @@ |
| 1219 | +{ pkgs, ... }: |
| 1220 | +{ |
| 1221 | + fileSystems."/srv/tiny" = { |
| 1222 | + device = "/dev/disk/by-uuid/98f49cba-bd8c-432d-a91a-41db66920e90"; |
| 1223 | + fsType = "ext4"; |
| 1224 | + options = [ "noatime" "nofail" "x-systemd.device-timeout=15s" ]; |
| 1225 | + }; |
| 1226 | + |
| 1227 | + services.fstrim.enable = true; |
| 1228 | + |
| 1229 | + systemd.services.tiny-certificates = { |
| 1230 | + description = "Renew tiny HTTPS certificates"; |
| 1231 | + after = [ "tiny.service" ]; |
| 1232 | + requires = [ "tiny.service" ]; |
| 1233 | + unitConfig.RequiresMountsFor = "/srv/tiny"; |
| 1234 | + path = [ pkgs.bash pkgs.coreutils pkgs.docker pkgs.openssl pkgs.util-linux ]; |
| 1235 | + serviceConfig = { |
| 1236 | + Type = "oneshot"; |
| 1237 | + ExecStart = "${pkgs.bash}/bin/bash /srv/tiny/deploy/renew-certificates.sh"; |
| 1238 | + }; |
| 1239 | + }; |
| 1240 | + |
| 1241 | + systemd.timers.tiny-certificates = { |
| 1242 | + wantedBy = [ "timers.target" ]; |
| 1243 | + timerConfig = { |
| 1244 | + OnCalendar = "daily"; |
| 1245 | + Persistent = true; |
| 1246 | + RandomizedDelaySec = "1h"; |
| 1247 | + }; |
| 1248 | + }; |
| 1249 | + |
| 1250 | + systemd.services.tiny = { |
| 1251 | + description = "Personal tiny relay"; |
| 1252 | + wantedBy = [ "multi-user.target" ]; |
| 1253 | + after = [ "docker.service" "network-online.target" "srv-tiny.mount" ]; |
| 1254 | + requires = [ "docker.service" ]; |
| 1255 | + bindsTo = [ "srv-tiny.mount" ]; |
| 1256 | + wants = [ "network-online.target" ]; |
| 1257 | + unitConfig.RequiresMountsFor = "/srv/tiny"; |
| 1258 | + path = [ pkgs.docker pkgs.docker-compose pkgs.util-linux ]; |
| 1259 | + preStart = '' |
| 1260 | + test "$(findmnt -n -o UUID --target /srv/tiny)" = 98f49cba-bd8c-432d-a91a-41db66920e90 |
| 1261 | + ''; |
| 1262 | + serviceConfig = { |
| 1263 | + Type = "oneshot"; |
| 1264 | + RemainAfterExit = true; |
| 1265 | + WorkingDirectory = "/srv/tiny/deploy"; |
| 1266 | + ExecStart = "${pkgs.docker-compose}/bin/docker-compose -f compose.yaml up -d --no-build"; |
| 1267 | + ExecStop = "${pkgs.docker-compose}/bin/docker-compose -f compose.yaml down --timeout 30"; |
| 1268 | + TimeoutStartSec = 180; |
| 1269 | + TimeoutStopSec = 90; |
| 1270 | + }; |
| 1271 | + }; |
| 1272 | +} |
| 1273 | diff --git a/deploy/tailscale-compose.yaml b/deploy/tailscale-compose.yaml |
| 1274 | new file mode 100644 |
| 1275 | index 0000000..34008ab |
| 1276 | --- /dev/null |
| 1277 | +++ b/deploy/tailscale-compose.yaml |
| 1278 | @@ -0,0 +1,89 @@ |
| 1279 | +name: tiny |
| 1280 | + |
| 1281 | +services: |
| 1282 | + tailscale: |
| 1283 | + # Local 1.102.3 build with the typed PortlistServices subscription fix. |
| 1284 | + image: tiny-tailscale:dogfood |
| 1285 | + container_name: tiny-tailscale |
| 1286 | + hostname: tiny |
| 1287 | + environment: |
| 1288 | + TS_HOSTNAME: tiny |
| 1289 | + TS_STATE_DIR: /var/lib/tailscale |
| 1290 | + TS_AUTH_ONCE: "true" |
| 1291 | + TS_USERSPACE: "false" |
| 1292 | + TS_ACCEPT_DNS: "false" |
| 1293 | + TS_EXTRA_ARGS: --shields-up=false |
| 1294 | + TS_ENABLE_HEALTH_CHECK: "true" |
| 1295 | + TS_ENABLE_METRICS: "true" |
| 1296 | + TS_LOCAL_ADDR_PORT: 127.0.0.1:9002 |
| 1297 | + volumes: |
| 1298 | + - type: bind |
| 1299 | + source: /srv/tiny/tailscale |
| 1300 | + target: /var/lib/tailscale |
| 1301 | + bind: |
| 1302 | + create_host_path: false |
| 1303 | + devices: |
| 1304 | + - /dev/net/tun:/dev/net/tun |
| 1305 | + cap_add: [NET_ADMIN] |
| 1306 | + healthcheck: |
| 1307 | + test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1:9002/healthz] |
| 1308 | + interval: 30s |
| 1309 | + timeout: 5s |
| 1310 | + retries: 3 |
| 1311 | + restart: unless-stopped |
| 1312 | + |
| 1313 | + relay: |
| 1314 | + image: tiny:dogfood |
| 1315 | + container_name: tiny |
| 1316 | + network_mode: service:tailscale |
| 1317 | + pid: service:tailscale |
| 1318 | + depends_on: [tailscale] |
| 1319 | + command: |
| 1320 | + - serve |
| 1321 | + - --listen=127.0.0.1:7447 |
| 1322 | + - --public-url=https://tiny.tailbe516a.ts.net |
| 1323 | + - --default-tenant=tiny |
| 1324 | + - --provision-owner=c01503e8035f5a98eb2004721dc4eeddda498c7871927ae7edb4525ab6ccf2d7 |
| 1325 | + - --allow-private-relays |
| 1326 | + - --diagnostics=127.0.0.1:9091 |
| 1327 | + env_file: /srv/tiny/deploy/tiny.env |
| 1328 | + volumes: |
| 1329 | + - type: bind |
| 1330 | + source: /srv/tiny/data |
| 1331 | + target: /data |
| 1332 | + bind: |
| 1333 | + create_host_path: false |
| 1334 | + restart: unless-stopped |
| 1335 | + stop_grace_period: 30s |
| 1336 | + |
| 1337 | + https: |
| 1338 | + image: caddy@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648 |
| 1339 | + container_name: tiny-https |
| 1340 | + network_mode: service:tailscale |
| 1341 | + pid: service:tailscale |
| 1342 | + depends_on: [tailscale, relay] |
| 1343 | + volumes: |
| 1344 | + - type: bind |
| 1345 | + source: /srv/tiny/deploy/Caddyfile |
| 1346 | + target: /etc/caddy/Caddyfile |
| 1347 | + read_only: true |
| 1348 | + bind: |
| 1349 | + create_host_path: false |
| 1350 | + - type: bind |
| 1351 | + source: /srv/tiny/certs |
| 1352 | + target: /certs |
| 1353 | + read_only: true |
| 1354 | + bind: |
| 1355 | + create_host_path: false |
| 1356 | + - type: bind |
| 1357 | + source: /srv/tiny/logs |
| 1358 | + target: /logs |
| 1359 | + bind: |
| 1360 | + create_host_path: false |
| 1361 | + tmpfs: [/data, /config] |
| 1362 | + healthcheck: |
| 1363 | + test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1:7447/readyz] |
| 1364 | + interval: 30s |
| 1365 | + timeout: 5s |
| 1366 | + retries: 3 |
| 1367 | + restart: unless-stopped |
| 1368 | diff --git a/deploy/tailscale-portlist.patch b/deploy/tailscale-portlist.patch |
| 1369 | new file mode 100644 |
| 1370 | index 0000000..5008579 |
| 1371 | --- /dev/null |
| 1372 | +++ b/deploy/tailscale-portlist.patch |
| 1373 | @@ -0,0 +1,16 @@ |
| 1374 | +--- a/ipn/ipnlocal/local.go |
| 1375 | ++++ b/ipn/ipnlocal/local.go |
| 1376 | +@@ -707,7 +707,12 @@ |
| 1377 | + eventbus.SubscribeFunc(ec, b.onHealthChange) |
| 1378 | + } |
| 1379 | + if buildfeatures.HasPortList { |
| 1380 | +- eventbus.SubscribeFunc(ec, b.setPortlistServices) |
| 1381 | ++ // PortlistServices is a named eventbus topic. Register the exact named |
| 1382 | ++ // type; subscribing with []tailcfg.Service silently misses every event. |
| 1383 | ++ // See https://github.com/tailscale/tailscale/issues/18297. |
| 1384 | ++ eventbus.SubscribeFunc(ec, func(sl PortlistServices) { |
| 1385 | ++ b.setPortlistServices([]tailcfg.Service(sl)) |
| 1386 | ++ }) |
| 1387 | + } |
| 1388 | + eventbus.SubscribeFunc(ec, b.onAppConnectorRouteUpdate) |
| 1389 | + eventbus.SubscribeFunc(ec, b.onAppConnectorStoreRoutes) |
| 1390 | diff --git a/deploy/tiny.Caddyfile b/deploy/tiny.Caddyfile |
| 1391 | new file mode 100644 |
| 1392 | index 0000000..7fd8ee7 |
| 1393 | --- /dev/null |
| 1394 | +++ b/deploy/tiny.Caddyfile |
| 1395 | @@ -0,0 +1,27 @@ |
| 1396 | +{ |
| 1397 | + auto_https disable_redirects |
| 1398 | + admin 127.0.0.1:2019 |
| 1399 | +} |
| 1400 | + |
| 1401 | +http://tiny, http://tiny.tailbe516a.ts.net { |
| 1402 | + redir https://tiny.tailbe516a.ts.net{uri} permanent |
| 1403 | +} |
| 1404 | + |
| 1405 | +https://tiny { |
| 1406 | + tls /certs/tiny.crt /certs/tiny.key |
| 1407 | + redir https://tiny.tailbe516a.ts.net{uri} permanent |
| 1408 | +} |
| 1409 | + |
| 1410 | +https://tiny.tailbe516a.ts.net { |
| 1411 | + tls /certs/tailnet.crt /certs/tailnet.key |
| 1412 | + reverse_proxy 127.0.0.1:7447 |
| 1413 | + @signerQR path /qr.svg /r/*/qr.svg |
| 1414 | + log_skip @signerQR |
| 1415 | + log { |
| 1416 | + output file /logs/access.json { |
| 1417 | + roll_size 10MiB |
| 1418 | + roll_keep 7 |
| 1419 | + } |
| 1420 | + format json |
| 1421 | + } |
| 1422 | +} |
| 1423 | diff --git a/deploy/tiny.service b/deploy/tiny.service |
| 1424 | new file mode 100644 |
| 1425 | index 0000000..9d736a2 |
| 1426 | --- /dev/null |
| 1427 | +++ b/deploy/tiny.service |
| 1428 | @@ -0,0 +1,22 @@ |
| 1429 | +[Unit] |
| 1430 | +Description=tiny self-hosted Nostr relay daemon |
| 1431 | +After=network-online.target |
| 1432 | +Wants=network-online.target |
| 1433 | + |
| 1434 | +[Service] |
| 1435 | +Type=simple |
| 1436 | +User=relay |
| 1437 | +Group=relay |
| 1438 | +EnvironmentFile=-/etc/tiny/tiny.env |
| 1439 | +ExecStart=/usr/local/bin/tiny serve --data-dir /var/lib/tiny --listen 127.0.0.1:7447 --public-url ${TINY_PUBLIC_URL} |
| 1440 | +Restart=on-failure |
| 1441 | +RestartSec=2s |
| 1442 | +NoNewPrivileges=true |
| 1443 | +PrivateTmp=true |
| 1444 | +ProtectSystem=strict |
| 1445 | +ProtectHome=true |
| 1446 | +ReadWritePaths=/var/lib/tiny |
| 1447 | +LimitNOFILE=65536 |
| 1448 | + |
| 1449 | +[Install] |
| 1450 | +WantedBy=multi-user.target |
| 1451 | diff --git a/docs/fixi.md b/docs/fixi.md |
| 1452 | new file mode 100644 |
| 1453 | index 0000000..08e8eb6 |
| 1454 | --- /dev/null |
| 1455 | +++ b/docs/fixi.md |
| 1456 | @@ -0,0 +1,15 @@ |
| 1457 | +# Vendored Fixi integration |
| 1458 | + |
| 1459 | +The web UI vendors the all-in-one Fixi bundle at `internal/webui/fixi.js`. It is copied from [the-fixi-project](https://github.com/bigskysoftware/the-fixi-project) commit `71307c9694dff3cc7f2aef087cb57b44db4bdd17` (retrieved 2026-09-07). The upstream project documents the bundle and APIs at [fixiproject.org/llms.txt](https://fixiproject.org/llms.txt). Upstream declares BSD-0 (Zero-Clause BSD) for all five libraries; see its [README license section](https://github.com/bigskysoftware/the-fixi-project#license). |
| 1460 | + |
| 1461 | +The vendored file's SHA-256 is: |
| 1462 | + |
| 1463 | +```text |
| 1464 | +b70458c212d0409f8a6319c286351865d92587292ad4f9bc0dfd1c46ffcad855 |
| 1465 | +``` |
| 1466 | + |
| 1467 | +`/fixi.js` is served locally with no CDN dependency. The page loads it before the existing signer bundle. The Connect page uses Fixi's `fx-action` fragment request for a read-only connection preview; `/connect/fragment` renders server-escaped table HTML, while `/connect.json` remains a JSON API. Mutation forms remain handled by the existing signer bridge because every request body must be serialized once, hashed exactly, signed as NIP-98, and submitted once. Fixi is not allowed to issue those signed mutations or retry them. |
| 1468 | + |
| 1469 | +The existing signer bridge still owns NIP-07, NIP-46, Nostr Connect, Amber links, session login, and management RPC. Fixi's fragment swaps are optional progressive enhancement: links and read-only navigation continue to work if JavaScript is unavailable; signed management actions require the signer bridge JavaScript. Paxi's focus-preserving `morph` swap is used by the read-only connection fragment. Ssexi consumes the authenticated `/manage/jobs/status` stream; the handler resolves the actor before opening it, passes that actor to every `listjobs` query, emits only changed escaped HTML table fragments into the existing job table, and closes on request cancellation. Moxi disables signed form buttons while they submit and the existing bridge re-enables them after completion. The current backend contract remains `Query(ctx, method, params, actor)`. |
| 1470 | + |
| 1471 | +The layout remains a semantic table with fixed 960px desktop and 360px mobile variants. Fixi does not add branding or layout CSS. |
| 1472 | diff --git a/docs/git-performance.md b/docs/git-performance.md |
| 1473 | new file mode 100644 |
| 1474 | index 0000000..df78a88 |
| 1475 | --- /dev/null |
| 1476 | +++ b/docs/git-performance.md |
| 1477 | @@ -0,0 +1,146 @@ |
| 1478 | +# Git performance |
| 1479 | + |
| 1480 | +Phase wall times come from the recorded harness. Missing or failed phases remain visible. This report does not compare against the bindws Worker runtime. |
| 1481 | + |
| 1482 | +Method: Linux Slate, 16 CPUs, 10 GiB test-container ceiling, no CPU cap, no swap; client/server loopback; Git 2.50.1 client and 2.39.5 server; fixed local heads and tags with no cold page-cache reset. Phase CPU deltas are total container CPU inclusive of Git children and background probes, and values below sampler resolution are shown as missing. |
| 1483 | + |
| 1484 | +Methods: native Git HTTP baseline, buffered Tiny, and streaming Tiny where result artifacts exist. |
| 1485 | + |
| 1486 | +## Findings |
| 1487 | + |
| 1488 | +| finding | before | after | |
| 1489 | +| --- | --- | --- | |
| 1490 | +| Peak Go memory (MiB) | 6,094 | 40.6 | |
| 1491 | +| Tag-heavy strudel incremental push (ms) | 1,510 | 94.1 | |
| 1492 | + |
| 1493 | +| repo | before clone | before concurrent4 | before state ACK | before push | after clone | after concurrent4 | after state ACK | after push | |
| 1494 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | |
| 1495 | +| atlas.git | 12,971 | — | FAILED: Nostr request timed out after 30 seconds | — | 9,148 | 14,383 | 26.8 | 67 | |
| 1496 | +| bindws.git | — | — | — | — | 178.0 | 267.5 | 21.3 | 59.4 | |
| 1497 | +| diagramzip.git | 1,844 | 2,391 | 27.7 | 58.8 | 1,559 | 2,118 | 21.9 | 58.3 | |
| 1498 | +| doorbearer.git | 5,669 | 7,782 | 18.6 | 60.4 | 4,444 | 6,795 | 24.5 | 59.6 | |
| 1499 | +| nzip.git | 100.1 | 114.8 | 33.4 | 99.4 | 73.4 | 79.3 | 19.4 | 59.2 | |
| 1500 | +| strudel.git | 3,052 | 4,933 | 615.6 | 1,510 | 2,035 | 3,747 | 24.3 | 94.1 | |
| 1501 | + |
| 1502 | +## Remaining relay latency |
| 1503 | + |
| 1504 | +Every probe succeeded, but durable event acknowledgements still stalled occasionally during large Git transfers, including native Git baseline phases on the same host. These results do not identify the cause. Capture disk latency and scheduling before selecting another optimization. |
| 1505 | + |
| 1506 | +| workload | ACK P95 ms | ACK max ms | query P95 ms | query max ms | |
| 1507 | +| --- | --- | --- | --- | --- | |
| 1508 | +| Public Atlas | 68.3 | 719.3 | 1.71 | 331.1 | |
| 1509 | +| Native Git Atlas phases | 53.7 | 656.4 | 1.69 | 4.51 | |
| 1510 | +| Private atlas.git | 86.7 | 1,384 | 2.98 | 33.5 | |
| 1511 | + |
| 1512 | +## Phase results |
| 1513 | + |
| 1514 | +| repo | method | clone n | initial push ms | clone median ms | fetch ms | incremental state ACK ms | incremental push ms | concurrent ×2 wall ms | concurrent ×4 wall ms | |
| 1515 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | |
| 1516 | +| nzip.git | streaming Tiny | 3 | 111.0 | 73.4 | 26.9 | 19.4 | 59.2 | 75.3 | 79.3 | |
| 1517 | +| nzip.git | native Git HTTP (after) | 3 | 84 | 67.6 | 23.9 | — | 33.6 | 73.1 | 77.6 | |
| 1518 | +| bindws.git | streaming Tiny | 3 | 249.2 | 178.0 | 27.9 | 21.3 | 59.4 | 203.8 | 267.5 | |
| 1519 | +| bindws.git | native Git HTTP (after) | 3 | 171.7 | 172.1 | 24.6 | — | 33.4 | 205.5 | 256.1 | |
| 1520 | +| diagramzip.git | streaming Tiny | 3 | 1,557 | 1,559 | 26.3 | 21.9 | 58.3 | 1,698 | 2,118 | |
| 1521 | +| diagramzip.git | native Git HTTP (after) | 3 | 1,470 | 1,538 | 24.5 | — | 35.8 | 1,720 | 2,112 | |
| 1522 | +| strudel.git | streaming Tiny | 3 | 2,595 | 2,035 | 36.1 | 24.3 | 94.1 | 2,625 | 3,747 | |
| 1523 | +| strudel.git | native Git HTTP (after) | 3 | 2,499 | 2,054 | 31.4 | — | 55.6 | 2,458 | 3,645 | |
| 1524 | +| doorbearer.git | streaming Tiny | 3 | 4,009 | 4,444 | 28.8 | 24.5 | 59.6 | 4,847 | 6,795 | |
| 1525 | +| doorbearer.git | native Git HTTP (after) | 3 | 4,039 | 4,430 | 24.3 | — | 35.3 | 5,095 | 9,770 | |
| 1526 | +| atlas.git | streaming Tiny | 3 | 10,233 | 9,148 | 27.7 | 26.8 | 67 | 10,290 | 14,383 | |
| 1527 | +| atlas.git | native Git HTTP (after) | 3 | 9,600 | 8,918 | 26.4 | — | 36.8 | 14,813 | 12,208 | |
| 1528 | +| nzip.git | buffered Tiny | 3 | 186.7 | 100.1 | 56.7 | 33.4 | 99.4 | 113.8 | 114.8 | |
| 1529 | +| nzip.git | native Git HTTP (before) | 3 | 79.9 | 68.7 | 24.5 | — | 31.1 | 76.5 | 78.3 | |
| 1530 | +| bindws.git | buffered Tiny | 0 | FAILED: Command failed: git -C /tmp/tiny-git-benchmark-pCF5Bp push http://127.0.0.1:17447/npub1t23e93dv97z5k320gm3tjp65z6ga249tt76c2pjwlx0rxr7wwvss9tuxhn/bindws-1.git r | — | — | — | — | — | — | |
| 1531 | +| diagramzip.git | buffered Tiny | 3 | 1,578 | 1,844 | 33.8 | 27.7 | 58.8 | 1,888 | 2,391 | |
| 1532 | +| diagramzip.git | native Git HTTP (before) | 3 | 1,401 | 1,658 | 24.6 | — | 35.9 | 1,717 | 1,958 | |
| 1533 | +| strudel.git | buffered Tiny | 3 | 4,639 | 3,052 | 783.7 | 615.6 | 1,510 | 3,499 | 4,933 | |
| 1534 | +| strudel.git | native Git HTTP (before) | 3 | 2,470 | 1,935 | 32.3 | — | 56.2 | 2,616 | 3,889 | |
| 1535 | +| doorbearer.git | buffered Tiny | 3 | 4,024 | 5,669 | 33.3 | 18.6 | 60.4 | 5,876 | 7,782 | |
| 1536 | +| doorbearer.git | native Git HTTP (before) | 3 | 3,951 | 4,111 | 25.3 | — | 34.6 | 5,543 | 5,448 | |
| 1537 | +| atlas.git | buffered Tiny | 3 | 9,906 | 12,971 | 47.1 | FAILED: Nostr request timed out after 30 seconds | — | — | — | |
| 1538 | +| atlas.git | native Git HTTP (before) | 3 | 9,275 | 9,229 | 28.6 | — | — | — | — | |
| 1539 | +| atlas.git | buffered Tiny | 0 | FAILED: Command failed: git -C /tmp/tiny-git-benchmark-szM6s6 push http://127.0.0.1:17447/npub15a63zxpyhpz85v554d6f24gctr840ytc3xsaq7pk3xd076nrhacqxc79mu/atlas-0.git re | — | — | — | — | — | — | |
| 1540 | +| atlas.git | private Tiny (NIP-98) | 3 | 13,537 | 9,005 | 39.6 | 23.2 | 79 | 10,601 | 18,337 | |
| 1541 | + |
| 1542 | +## Resource results |
| 1543 | + |
| 1544 | +| repo | method | Go RSS high-water MiB | cgroup anon peak MiB | cgroup file peak MiB | cgroup total peak MiB | CPU initial µs | CPU incremental µs | CPU concurrent2 µs | CPU concurrent4 µs | container | |
| 1545 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | |
| 1546 | +| nzip.git | streaming Tiny | 34.4 | 13.3 | 5.46 | 30.9 | 204,937 | — | — | — | tiny-git-perf-final | |
| 1547 | +| nzip.git | native Git HTTP (after) | 8.03 | 2.69 | 0.516 | 10.1 | — | — | — | — | tiny-git-perf-final-baseline | |
| 1548 | +| bindws.git | streaming Tiny | 35.3 | 23.4 | 14.8 | 57.1 | 395,910 | — | 531,919 | — | tiny-git-perf-final | |
| 1549 | +| bindws.git | native Git HTTP (after) | 10.3 | 12.6 | 9.66 | 42.7 | — | — | 469,261 | — | tiny-git-perf-final-baseline | |
| 1550 | +| diagramzip.git | streaming Tiny | 37.1 | 122.3 | 82.1 | 209.6 | 1,531,645 | — | 837,444 | 2,024,123 | tiny-git-perf-final | |
| 1551 | +| diagramzip.git | native Git HTTP (after) | 11.7 | 49.5 | 76.5 | 190.4 | 1,745,696 | — | 860,621 | 1,412,613 | tiny-git-perf-final-baseline | |
| 1552 | +| strudel.git | streaming Tiny | 39.3 | 179.2 | 174.1 | 372.7 | 6,636,176 | — | 1,604,850 | 3,353,508 | tiny-git-perf-final | |
| 1553 | +| strudel.git | native Git HTTP (after) | 12.2 | 165.0 | 167.7 | 357.4 | 6,434,735 | — | 1,323,538 | 2,209,831 | tiny-git-perf-final-baseline | |
| 1554 | +| doorbearer.git | streaming Tiny | 40.3 | 40.7 | 548.1 | 621.3 | 4,892,027 | — | 5,021,984 | 10,789,638 | tiny-git-perf-final | |
| 1555 | +| doorbearer.git | native Git HTTP (after) | 12.6 | 29.6 | 540.8 | 601.6 | 4,548,602 | — | 4,224,315 | 10,196,891 | tiny-git-perf-final-baseline | |
| 1556 | +| atlas.git | streaming Tiny | 40.6 | 179.8 | 1,477 | 1,765 | 11,991,370 | — | 11,659,980 | 28,932,729 | tiny-git-perf-final | |
| 1557 | +| atlas.git | native Git HTTP (after) | 12.8 | 178.2 | 1,469 | 1,756 | 11,375,064 | — | 11,745,735 | 27,511,063 | tiny-git-perf-final-baseline | |
| 1558 | +| nzip.git | buffered Tiny | 39.7 | 18.7 | 10.1 | 36.6 | 319,330 | — | — | 511,003 | tiny-git-perf-before | |
| 1559 | +| nzip.git | native Git HTTP (before) | 9.43 | 4.03 | 0.52 | 15.9 | — | — | — | — | tiny-git-perf-baseline | |
| 1560 | +| bindws.git | buffered Tiny | 39.9 | 18.9 | 10.2 | 36.6 | 417,278 | — | — | — | tiny-git-perf-before | |
| 1561 | +| diagramzip.git | buffered Tiny | 813.4 | 792.4 | 77.6 | 889.9 | 1,910,533 | — | 1,905,437 | 1,340,161 | tiny-git-perf-before | |
| 1562 | +| diagramzip.git | native Git HTTP (before) | 11.1 | 63.6 | 67.4 | 180.7 | 1,471,525 | — | 1,238,327 | 1,567,270 | tiny-git-perf-baseline | |
| 1563 | +| strudel.git | buffered Tiny | 1,566 | 1,566 | 170.1 | 1,766 | 8,893,943 | 1,629,815 | 4,022,310 | 9,036,055 | tiny-git-perf-before | |
| 1564 | +| strudel.git | native Git HTTP (before) | 11.7 | 108.0 | 158.6 | 340.7 | 6,261,466 | — | 1,354,373 | 3,098,503 | tiny-git-perf-baseline | |
| 1565 | +| doorbearer.git | buffered Tiny | 3,732 | 3,729 | 544.2 | 4,300 | 4,823,998 | — | 4,355,754 | 10,081,976 | tiny-git-perf-before | |
| 1566 | +| doorbearer.git | native Git HTTP (before) | 11.6 | 29.4 | 532.0 | 580.1 | 4,500,482 | — | 4,283,006 | 10,758,873 | tiny-git-perf-baseline | |
| 1567 | +| atlas.git | buffered Tiny | 6,094 | 6,097 | 1,365 | 7,256 | 11,809,027 | — | — | — | tiny-git-perf-before | |
| 1568 | +| atlas.git | native Git HTTP (before) | 10.8 | 143.4 | 1,414 | 1,663 | 11,397,964 | — | — | — | tiny-git-perf-baseline | |
| 1569 | +| atlas.git | buffered Tiny | 6,094 | 226.7 | 2,125 | 7,256 | 11,603,962 | — | — | — | tiny-git-perf-before | |
| 1570 | +| atlas.git | private Tiny (NIP-98) | 38.9 | 180.1 | 1,860 | 2,158 | 12,983,504 | — | 11,985,201 | 29,095,672 | tiny-git-perf-private | |
| 1571 | + |
| 1572 | +## Probe results |
| 1573 | + |
| 1574 | +| repo | method | idle publish P50 ms | idle publish P95 ms | idle publish max ms | idle query P50 ms | idle query P95 ms | idle query max ms | busy publish P50 ms | busy publish P95 ms | busy publish max ms | busy query P50 ms | busy query P95 ms | busy query max ms | idle errors | busy errors | |
| 1575 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | |
| 1576 | +| nzip.git | streaming Tiny | 6.4 | 11.7 | 19.5 | 1.73 | 4.12 | 20.8 | 6.04 | 15.3 | 18.6 | 1.33 | 2.28 | 2.69 | 0 | 0 | |
| 1577 | +| nzip.git | native Git HTTP (after) | — | — | — | — | — | — | 7.44 | 10.7 | 10.8 | 1.16 | 1.55 | 1.65 | 0 | 0 | |
| 1578 | +| bindws.git | streaming Tiny | 6.11 | 7.38 | 10.3 | 1.63 | 2.05 | 5.05 | 5.88 | 12.8 | 23 | 1.43 | 1.92 | 5.43 | 0 | 0 | |
| 1579 | +| bindws.git | native Git HTTP (after) | — | — | — | — | — | — | 5.96 | 17.8 | 20 | 1.32 | 1.72 | 2.33 | 0 | 0 | |
| 1580 | +| diagramzip.git | streaming Tiny | 6.16 | 10.3 | 12 | 1.56 | 2.05 | 4.81 | 5.7 | 17.3 | 102.2 | 1.33 | 1.73 | 3.13 | 0 | 0 | |
| 1581 | +| diagramzip.git | native Git HTTP (after) | — | — | — | — | — | — | 5.81 | 17.6 | 84.7 | 1.33 | 1.7 | 2.24 | 0 | 0 | |
| 1582 | +| strudel.git | streaming Tiny | 6.1 | 10.2 | 15.2 | 1.54 | 1.98 | 4.3 | 6.08 | 12.7 | 330.9 | 1.36 | 2.47 | 5.88 | 0 | 0 | |
| 1583 | +| strudel.git | native Git HTTP (after) | — | — | — | — | — | — | 5.61 | 9.49 | 100.3 | 1.35 | 2.03 | 5.91 | 0 | 0 | |
| 1584 | +| doorbearer.git | streaming Tiny | 4.16 | 6.79 | 9.06 | 1.52 | 1.88 | 3.21 | 5.64 | 47.1 | 299.4 | 1.25 | 1.68 | 3.49 | 0 | 0 | |
| 1585 | +| doorbearer.git | native Git HTTP (after) | — | — | — | — | — | — | 5.83 | 47.5 | 1,117 | 1.23 | 1.62 | 3.7 | 0 | 0 | |
| 1586 | +| atlas.git | streaming Tiny | 5.86 | 10.5 | 26.2 | 1.47 | 1.9 | 5.18 | 5.64 | 68.3 | 719.3 | 1.26 | 1.71 | 331.1 | 0 | 0 | |
| 1587 | +| atlas.git | native Git HTTP (after) | — | — | — | — | — | — | 5.71 | 53.7 | 656.4 | 1.28 | 1.69 | 4.51 | 0 | 0 | |
| 1588 | +| nzip.git | buffered Tiny | 6.29 | 9.96 | 11 | 1.64 | 2.26 | 5.71 | 6.11 | 14.2 | 35.6 | 1.38 | 1.63 | 5.34 | 0 | 0 | |
| 1589 | +| nzip.git | native Git HTTP (before) | — | — | — | — | — | — | 6.26 | 7.43 | 7.57 | 1.28 | 2.82 | 3.84 | 0 | 0 | |
| 1590 | +| bindws.git | buffered Tiny | 6.27 | 11.1 | 15.7 | 1.63 | 2.25 | 21.7 | 5.79 | 6.13 | 6.18 | 1.45 | 4.77 | 5.36 | 0 | 0 | |
| 1591 | +| diagramzip.git | buffered Tiny | 6.39 | 10.7 | 12.6 | 1.71 | 2.33 | 2.52 | 6.2 | 18.6 | 201.0 | 1.34 | 1.66 | 2.13 | 0 | 0 | |
| 1592 | +| diagramzip.git | native Git HTTP (before) | — | — | — | — | — | — | 6.56 | 23.4 | 209.8 | 1.35 | 1.71 | 3.8 | 0 | 0 | |
| 1593 | +| strudel.git | buffered Tiny | 4.37 | 6.34 | 8.87 | 1.58 | 2.09 | 3.7 | 3.76 | 9.38 | 567.1 | 1.26 | 1.91 | 24.2 | 0 | 0 | |
| 1594 | +| strudel.git | native Git HTTP (before) | — | — | — | — | — | — | 4.04 | 9.99 | 179.1 | 1.3 | 2.62 | 8.03 | 0 | 0 | |
| 1595 | +| doorbearer.git | buffered Tiny | 5.9 | 9.14 | 12.2 | 1.45 | 1.8 | 2.11 | 5.72 | 46.3 | 426.0 | 1.2 | 1.59 | 2.77 | 0 | 0 | |
| 1596 | +| doorbearer.git | native Git HTTP (before) | — | — | — | — | — | — | 5.58 | 23.1 | 288.1 | 1.25 | 1.63 | 5.11 | 0 | 0 | |
| 1597 | +| atlas.git | buffered Tiny | 5.86 | 9.99 | 13.9 | 1.51 | 1.78 | 3.48 | 5.28 | 11.5 | 1,323 | 1.41 | 2.06 | 3.72 | 0 | 0 | |
| 1598 | +| atlas.git | native Git HTTP (before) | — | — | — | — | — | — | 5.53 | 19.2 | 242.5 | 1.26 | 1.72 | 4.38 | 0 | 0 | |
| 1599 | +| atlas.git | buffered Tiny | 4.58 | 6.84 | 9.25 | 1.94 | 2.41 | 6.25 | 3.43 | 6 | 91.4 | 1.1 | 1.58 | 2.14 | 0 | 0 | |
| 1600 | +| atlas.git | private Tiny (NIP-98) | 6.65 | 9.31 | 12.8 | 1.83 | 2.28 | 5.93 | 5.95 | 86.7 | 1,384 | 1.35 | 2.98 | 33.5 | 0 | 0 | |
| 1601 | + |
| 1602 | +## Status |
| 1603 | + |
| 1604 | +| repo | method | status | failed phases | |
| 1605 | +| --- | --- | --- | --- | |
| 1606 | +| nzip.git | streaming Tiny | complete | — | |
| 1607 | +| bindws.git | streaming Tiny | complete | — | |
| 1608 | +| diagramzip.git | streaming Tiny | complete | — | |
| 1609 | +| strudel.git | streaming Tiny | complete | — | |
| 1610 | +| doorbearer.git | streaming Tiny | complete | — | |
| 1611 | +| atlas.git | streaming Tiny | complete | — | |
| 1612 | +| nzip.git | buffered Tiny | complete | — | |
| 1613 | +| bindws.git | buffered Tiny | failed | initial_push | |
| 1614 | +| diagramzip.git | buffered Tiny | complete | — | |
| 1615 | +| strudel.git | buffered Tiny | complete | — | |
| 1616 | +| doorbearer.git | buffered Tiny | complete | — | |
| 1617 | +| atlas.git | buffered Tiny | failed | incremental_state_ack | |
| 1618 | +| atlas.git | buffered Tiny | failed | initial_push | |
| 1619 | +| atlas.git | private Tiny (NIP-98) | complete | — | |
| 1620 | + |
| 1621 | +Profile finding from the captured before run: CPU samples identify memory clearing/copying as the visible hot path; the independent Go RSS reading is 6093.9 MiB. Keep the raw profile and process/cgroup captures beside any interpretation. |
| 1622 | + |
| 1623 | +Raw JSON artifacts are linked from the HTML report. |
| 1624 | diff --git a/docs/khatru-integration.md b/docs/khatru-integration.md |
| 1625 | new file mode 100644 |
| 1626 | index 0000000..66f44fb |
| 1627 | --- /dev/null |
| 1628 | +++ b/docs/khatru-integration.md |
| 1629 | @@ -0,0 +1,81 @@ |
| 1630 | +# Khatru integration spike |
| 1631 | + |
| 1632 | +Date: 2026-09-07 |
| 1633 | +Module inspected: `fiatjaf.com/nostr@v0.0.0-20260902034142-316ef6591fa2/khatru` |
| 1634 | +Reference transport: `../bindws/src/relay.ts` |
| 1635 | + |
| 1636 | +## Decision |
| 1637 | + |
| 1638 | +Khatru is useful protocol prior art and can carry a small first milestone, but it |
| 1639 | +is not a drop-in transport for tinyrelay's full contract. Use its event types, |
| 1640 | +NIP-42/NIP-45/NIP-77 codecs and policy vocabulary where useful. Keep the |
| 1641 | +production websocket transport under tinyrelay's ownership once the requirements |
| 1642 | +below matter. The decisive reasons are the historical/live subscription race, |
| 1643 | +the lack of a connection shutdown API, and the absence of an error-bearing query |
| 1644 | +iterator. These are correctness and lifecycle boundaries, not benchmark concerns. |
| 1645 | + |
| 1646 | +## What the pinned module provides |
| 1647 | + |
| 1648 | +`Relay` exposes hooks for event admission, storage, replacement, deletion, |
| 1649 | +request and count authorization, historical query, authentication, listener |
| 1650 | +add/remove, live broadcast filtering, and NIP-11/NIP-86. `RequestAuth(ctx)` can |
| 1651 | +send a challenge from `OnConnect`, `OnEvent`, `OnRequest`, or another hook. NIP-45 |
| 1652 | +COUNT and NIP-77 negentropy are wired through `Count`/`CountHLL` and |
| 1653 | +`QueryStored`. |
| 1654 | + |
| 1655 | +The wire path verifies event IDs and signatures before hooks. Normal events are |
| 1656 | +stored, then `OnEventSaved` runs, then listeners are notified. Deletion events |
| 1657 | +are stored before Khatru's NIP-09 target deletion pass. `QueryStored` is |
| 1658 | +`iter.Seq[nostr.Event]`; iteration has no error return. The default websocket |
| 1659 | +handler starts a goroutine for every incoming message and two connection loops. |
| 1660 | +Writes are serialized per socket, but there is no relay-level worker registry or |
| 1661 | +wait group. |
| 1662 | + |
| 1663 | +## Compatibility matrix |
| 1664 | + |
| 1665 | +| Requirement | Native hook fit | Adapter / blocker | |
| 1666 | +|---|---|---| |
| 1667 | +| Proactive AUTH challenge | Yes | Call `RequestAuth(ctx)` from `OnConnect`; configure the relay URL carefully. This is opt-in and not sent by `NewRelay`. | |
| 1668 | +| kind 24133 signer exception | Partial | `OnRequest`, `QueryStored`, and `PreventBroadcast` can implement the encrypted NIP-46 visibility rule. No built-in exception exists; make it an explicit policy branch and test historical, live, COUNT, and negentropy paths separately. | |
| 1669 | +| Per-event privacy for REQ/live | Partial | Historical privacy belongs inside `QueryStored`; live privacy belongs in `PreventBroadcast`. `OnRequest` alone is too coarse. | |
| 1670 | +| Per-event privacy for COUNT | Yes, with own store | Implement `Count`/`CountHLL` against the same authorization predicate. The hook only gates the request; it does not filter rows for you. | |
| 1671 | +| Per-event privacy for negentropy | Yes, with own store | Negentropy calls `QueryStored` with a negentropy context, so the same filtered iterator can work. Verify that the vector contains exactly the events visible to that authenticated client. | |
| 1672 | +| No historical/live gap | No | Khatru queries history first and adds listeners afterward. An event committed between those operations can be missed. There is no public snapshot/fence hook. Own the transport or add a store-level sequence barrier and modify the dispatch path. | |
| 1673 | +| NIP-09 deletion-before-arrival tombstones | No | Khatru only deletes rows it finds. Persist tombstones in an admission transaction and have future `StoreEvent` reject/ignore matching events. This requires an own storage/admission layer. | |
| 1674 | +| Gift-recipient deletion | Partial | `AllowDeleting(target, deletion)` can authorize the p-tagged recipient for kind 1059. The default path has no recipient tombstone model, so recipient deletes still need the own store. | |
| 1675 | +| Private policy change closes sessions | No | The public API returns snapshots and listener callbacks, but does not expose a socket close/remove method. `WebSocket.Context` is observable, not cancellable by callers. Own transport or maintain a fork. | |
| 1676 | +| Event + delivery intent atomic acknowledgement | Partial | Put both writes in a custom `StoreEvent`/`ReplaceEvent` transaction. Do not use `UseEventstore` unchanged: its `SaveEvent` cannot atomically add tinyrelay delivery rows. Intent calculation must be deterministic and side-effect free inside the transaction. | |
| 1677 | +| Query/storage errors | No | `iter.Seq` cannot report a database error. A failing iterator can only stop early, which Khatru will treat as a complete EOSE. Use a query API with an error result in the owned transport, or expose a conservative health/error side channel and fail closed. | |
| 1678 | +| Clean shutdown of all goroutines | No | The comments mention `Server.Shutdown`, but this pinned package has no exported relay shutdown/wait method. `http.Server.Shutdown` does not provide a join for Khatru's websocket goroutines. Track and close sockets in an owned transport. | |
| 1679 | +| Arbitrary operator host limits | Partial | `MaxMessageSize`, `MaxAuthenticatedClients`, websocket deadlines, and query limits are configurable. Defaults include 512000-byte messages, eight authenticated keys, and a caller-supplied query limit; set every limit from explicit host configuration so no default becomes a product cap. | |
| 1680 | + |
| 1681 | +## Required adapter rules if Khatru is used temporarily |
| 1682 | + |
| 1683 | +* Set `ChallengePrefix`, `ServiceURL`, `MaxMessageSize`, |
| 1684 | + `MaxAuthenticatedClients`, write/read/ping deadlines, and query limits from the |
| 1685 | + operator configuration. Do not inherit `NewRelay` defaults silently. |
| 1686 | +* Implement one authorization predicate and reuse it from `QueryStored`, |
| 1687 | + `Count`/`CountHLL`, `PreventBroadcast`, and negentropy. It must understand |
| 1688 | + authenticated pubkeys, event author, p-tags, private kinds, and the 24133 |
| 1689 | + signer exception. |
| 1690 | +* Wrap `StoreEvent`, `ReplaceEvent`, and deletion admission in the tenant |
| 1691 | + transaction. Store a deletion tombstone before acknowledging a deletion and |
| 1692 | + check it before accepting a late event. Add recipient tombstones for gift |
| 1693 | + wraps according to the local privacy policy. |
| 1694 | +* Persist delivery intent before returning a successful local `OK`. Remote |
| 1695 | + sends remain at-least-once and must be idempotent by tenant, event, and target. |
| 1696 | +* Treat a query iterator ending without an explicit store-success marker as |
| 1697 | + incomplete. Until the owned transport exists, surface a degraded health state |
| 1698 | + rather than claiming a complete history. |
| 1699 | +* On policy revision, reject new requests immediately and close every affected |
| 1700 | + socket through the owned connection registry. Khatru hooks alone cannot do the |
| 1701 | + close. |
| 1702 | + |
| 1703 | +## Recommendation |
| 1704 | + |
| 1705 | +Prototype event policy and storage transactions behind Khatru only if it shortens |
| 1706 | +the first vertical slice. Before enabling private relay templates or promising |
| 1707 | +durable subscriptions, replace `HandleWebsocket` with a tinyrelay transport that |
| 1708 | +owns a per-connection reader, single writer, cancellation, admission fence, and |
| 1709 | +wait-group shutdown. Keep Khatru's codecs and NIP implementations, but do not |
| 1710 | +make Khatru's default `ServeHTTP` the long-term lifecycle boundary. |
| 1711 | diff --git a/docs/observability.md b/docs/observability.md |
| 1712 | new file mode 100644 |
| 1713 | index 0000000..b27701b |
| 1714 | --- /dev/null |
| 1715 | +++ b/docs/observability.md |
| 1716 | @@ -0,0 +1,79 @@ |
| 1717 | +# Observability |
| 1718 | + |
| 1719 | +The `internal/telemetry` package is the relay's process-local observability boundary. It owns an isolated Prometheus registry, OpenTelemetry tracing, structured logging, and an authenticated diagnostics handler. A self-hosted binary can run without a collector or observability database. |
| 1720 | + |
| 1721 | +## Host integration |
| 1722 | + |
| 1723 | +```go |
| 1724 | +tel, err := telemetry.New(ctx, telemetry.Config{ |
| 1725 | + Version: buildVersion, |
| 1726 | + Revision: buildRevision, |
| 1727 | + TraceSampleRatio: 0.01, |
| 1728 | + DebugToken: os.Getenv("TINYRELAY_DEBUG_TOKEN"), |
| 1729 | +}) |
| 1730 | +if err != nil { |
| 1731 | + return err |
| 1732 | +} |
| 1733 | +defer tel.Close(context.Background()) |
| 1734 | + |
| 1735 | +diagnostics := http.Server{Addr: "127.0.0.1:9090", Handler: tel.Handler()} |
| 1736 | +``` |
| 1737 | + |
| 1738 | +Bind the diagnostics server separately from the public relay listener. Every endpoint (`/metrics`, `/health`, and `/debug/pprof/*`) requires `Authorization: Bearer <DebugToken>`. Use a strong secret and restrict the listener at the network layer as well. |
| 1739 | + |
| 1740 | +The main listener exposes `/healthz` for process liveness and `/readyz` for readiness. Readiness requires the serving lifecycle to have started and a bounded catalog storage probe to succeed; it does not require a default tenant. Tenant paths expose `/r/<name>/healthz` and `/r/<name>/readyz`, which perform a bounded local SQLite probe and return unavailable during tenant maintenance. Remote replication and delivery failures do not make the process ready state fail. |
| 1741 | + |
| 1742 | +The package API is: |
| 1743 | + |
| 1744 | +```go |
| 1745 | +type Config struct { |
| 1746 | + Version, Revision, OTLPEndpoint, DebugToken string |
| 1747 | + TraceSampleRatio float64 |
| 1748 | +} |
| 1749 | +func New(context.Context, Config) (*Telemetry, error) |
| 1750 | +func (*Telemetry) Close(context.Context) error |
| 1751 | +func (*Telemetry) Logger() *slog.Logger |
| 1752 | +func (*Telemetry) Handler() http.Handler |
| 1753 | +func (*Telemetry) Start(context.Context, string) (context.Context, func(string)) |
| 1754 | +func (*Telemetry) ObserveStorage(string, string, time.Duration) |
| 1755 | +func (*Telemetry) Connection(int) |
| 1756 | +func (*Telemetry) Subscription(int) |
| 1757 | +func (*Telemetry) Delivery(string, time.Duration) |
| 1758 | +func (*Telemetry) Queue(string, int) |
| 1759 | +``` |
| 1760 | + |
| 1761 | +Operation, outcome, and queue labels are normalized to bounded vocabularies. Never add tenant IDs, pubkeys, event IDs, subscription IDs, raw filters, or arbitrary URLs to metric labels. Per-tenant inspection belongs in a separately authenticated, short-lived administrative view. |
| 1762 | + |
| 1763 | +The registry exports relay metrics plus Go runtime collectors. The OTel tracer is always present and uses a ratio sampler; exporting is optional. When `OTLPEndpoint` is set, the package uses the pinned OTLP HTTP exporter and flushes spans during `Close`; no collector is required for the relay itself. |
| 1764 | + |
| 1765 | +Register durable work metrics explicitly after constructing the queue: |
| 1766 | + |
| 1767 | +```go |
| 1768 | +queue := work.New(store) |
| 1769 | +if err := tel.Register(queue.Metrics()); err != nil { |
| 1770 | + return err |
| 1771 | +} |
| 1772 | +worker := work.NewWorkerWithOptions(queue, handlers, work.WorkerOptions{ |
| 1773 | + Lease: 30 * time.Second, |
| 1774 | + Renew: 10 * time.Second, |
| 1775 | + Observe: tel.ObserveWork, |
| 1776 | +}) |
| 1777 | +``` |
| 1778 | + |
| 1779 | +Each active handler attempt has a renewal loop at the configured interval. If renewal loses the fencing token or an administrator cancels the intent, the handler context is cancelled and the worker waits for the renewal loop before attempting any completion or retry. A stale attempt cannot commit after another worker reclaims the intent. Work metric kind labels use a fixed allowlist and collapse unknown kinds to `other`. |
| 1780 | + |
| 1781 | +Use pprof and execution traces only during bounded diagnostic captures. Profile one mode at a time because some Go diagnostic tools interfere with one another. Treat metrics as the continuous signal and profiles/traces as evidence for a specific optimization. |
| 1782 | + |
| 1783 | +The first dashboards should cover traffic and protocol outcomes, connection/fan-out pressure, inbox/outbox lag and retries, storage latency/locks/WAL/checkpoints, runtime health, and slow/error exemplars. Optimize only after a metric, trace, or profile identifies the dominant cost; preserve a reproducible benchmark workload and compare correctness, tail latency, CPU, memory, allocations, goroutines, WAL growth, checkpoint cost, and disk writes together. |
| 1784 | + |
| 1785 | +## Capability and peer status |
| 1786 | + |
| 1787 | +`Tenant.Capabilities(peers)` returns the bounded operator registry used to assemble protocol capability status. Only entries with `status == "enabled"` should be advertised as supported NIPs. Git and GRASP entries require both the tenant policy and an initialized Git service. Blob doors report their concrete BUD-01, BUD-02, BUD-04, BUD-09, BUD-11, and BUD-12 status only when the file service is mounted; unsupported BUD documents are not advertised. |
| 1788 | + |
| 1789 | +NIP-66 monitoring is opt-in and accepts only the peer URLs supplied in `PeerMonitorConfig`. `App.PeerMonitorRun(ctx)` owns the probe lifecycle when `Config.PeerMonitor` is set. The CLI publication path requires both `--peer-publish` and an explicit `--peer-publish-tenant NAME`; it signs and stores kind 30166 records with that tenant's relay identity, then uses the tenant's configured durable outbox for any delivery. A publication request remains `configured` until the signed publisher is bound, so a switch alone cannot claim that signed NIP-66 events are being published. Probe callbacks expose only bounded `ok`/`error` outcomes and duration; peer URLs belong in operator status, never metric labels. |
| 1790 | + |
| 1791 | +## Git workload captures |
| 1792 | + |
| 1793 | +The [real repository experiment](git-performance.md) follows this workflow. The original Git HTTP wrapper buffered entire pack responses; process RSS and the CPU profile exposed allocation, clearing and copying costs. Streaming the response and batching signed-ref/object checks removed the measured bottlenecks. The harness checks EVENT acknowledgement and exact-ID query latency while push, clone and fetch operations run, so a fast Git transfer cannot conceal a stalled relay. |
| 1794 | + |
| 1795 | +Use `scripts/observe-git-benchmark.py` for repeatable captures. Keep Go process RSS separate from cgroup memory: native Git children and filesystem cache are charged to the container, and do not appear in a Go heap profile. Likewise, Go CPU profiles exclude Git child CPU; use cgroup CPU deltas to account for the whole server. Record concurrency, repository object/ref counts, Git versions, page-cache conditions and the native Git baseline with each result. Instructions and comparison limits are in [testing.md](testing.md#real-git-repositories). |
| 1796 | diff --git a/docs/parity-gap-audit.md b/docs/parity-gap-audit.md |
| 1797 | new file mode 100644 |
| 1798 | index 0000000..f232269 |
| 1799 | --- /dev/null |
| 1800 | +++ b/docs/parity-gap-audit.md |
| 1801 | @@ -0,0 +1,87 @@ |
| 1802 | +# Parity gap audit |
| 1803 | + |
| 1804 | +Compared with bindws revision `5d31267`. This is a behavior ledger, not a |
| 1805 | +claim that package compilation proves full UX parity. The native contract |
| 1806 | +removes hosted leases/trials, ownership provisioning claims, fuel/billing, and |
| 1807 | +product entitlement caps. Membership invite claims remain required. |
| 1808 | + |
| 1809 | +## Evidence recorded |
| 1810 | + |
| 1811 | +- The retained 90-method source registry is exposed through the tenant |
| 1812 | + dispatcher. `internal/daemon/management_golden_test.go` invokes every method |
| 1813 | + on a fresh tenant and fails on `unsupported`; representative read methods |
| 1814 | + must succeed. |
| 1815 | +- `internal/daemon/ux_contract_test.go` makes signed HTTP calls for invite |
| 1816 | + creation/listing, member mutation, moderation, IP rules, policy and identity |
| 1817 | + changes, config export, fork, and deletion. It also checks guided Sync |
| 1818 | + argument shapes. |
| 1819 | +- Domain/NIP-AD validation, custom host catalog mappings, retention sweeps, |
| 1820 | + records scheduling/privacy, callback registration validation, backup sealing, |
| 1821 | + Git behavior, and replication queues have focused package tests. |
| 1822 | +- The current conformance artifact `20260907T170116Z` runs all 14 configured |
| 1823 | + files (the source NIP-11 file is replaced by the quota-free compatibility |
| 1824 | + test) and records 17 passing tests with no skips. Earlier artifacts remain |
| 1825 | + historical evidence only. |
| 1826 | +- The follow-up Git artifact `20260907T154227Z` passes its Git case. A |
| 1827 | + separate browser run also exercised the remote-signer backup flow. These |
| 1828 | + close those observed cases only, not the entire GRASP or browser families. |
| 1829 | +- The latest retained protocol artifact `20260907T170116Z` again passes all |
| 1830 | + 17 tests with no skips. `go test -race ./...` and `go vet ./...` also pass. |
| 1831 | +- `routes_acceptance_test.go` covers mounted NIP-11, NIP-86, bridge auth, |
| 1832 | + NIP-05, NIP-AD auth behavior, public pages, CORS, and data-route denial. |
| 1833 | + `durable_acceptance_test.go` covers restart recovery for a dump job, view |
| 1834 | + checkpoint, and notification intent. `git_push_test.go` covers immediate |
| 1835 | + 30-ref HTTP push after router publication. |
| 1836 | +- Browser evidence covers a distinct NIP-46 signer reconnect and signed |
| 1837 | + mutation (`output/playwright/nip46-distinct-resume-evidence.json`) and a |
| 1838 | + valid backup restore plus malformed-archive retry |
| 1839 | + (`artifacts/browser-restore-qa.json`). The restore run used a page-local NIP-07 fixture, so its subsequent search route |
| 1840 | + lost that injected signer and required authentication again. The retained NIP-46 |
| 1841 | + browser evidence now closes navigation persistence for the supported remote |
| 1842 | + signer path with distinct remote and user keys; NIP-07 extension persistence |
| 1843 | + remains browser-managed and is not emulated by the page. |
| 1844 | +- Git performance evidence covers six real repositories through all harness |
| 1845 | + phases in `artifacts/git-performance/results-after`. |
| 1846 | +- `hosting_routes_acceptance_test.go` verifies per-tenant site isolation and a |
| 1847 | + custom host mapping. `callback_restart_acceptance_test.go` verifies durable |
| 1848 | + callback recovery and revocation. `git_private_restart_test.go` verifies |
| 1849 | + pending private state recovery, unsigned denial, signed HTTP discovery and |
| 1850 | + receive-pack negotiation after restart, and tampered-payload rejection. |
| 1851 | +- Public transfers pass all six fixtures; private nzip and Atlas transfers |
| 1852 | + also pass. Authenticated upload spooling keeps Go RSS independent of pack |
| 1853 | + size. Tail acknowledgement stalls remain visible in the performance report. |
| 1854 | +- The complete Linux race suite passes at |
| 1855 | + `artifacts/slate/tiny-private-git-final-20260907/20260907T165524Z`. |
| 1856 | + The subsequent Git policy changes pass the daemon race suite on Linux at |
| 1857 | + `artifacts/slate/tiny-git-policy-final-20260907/20260907T165928Z`. |
| 1858 | + |
| 1859 | +## Remaining acceptance work |
| 1860 | + |
| 1861 | +1. HTTP portable import now has signed mounted-route acceptance for final |
| 1862 | + imported counts and persisted content, alongside durable import-job restart |
| 1863 | + coverage. The combined Git-containing HTTP backup/restore journey also has |
| 1864 | + focused acceptance coverage. A streamed progress UI remains outside the |
| 1865 | + current native route contract. Positive signed NIP-AD and NIP-96 |
| 1866 | + upload/get/delete, site isolation/custom mapping, Smart HTTP push, and |
| 1867 | + backup preview/restore are already covered by focused tests. |
| 1868 | +2. Complete browser tree traversal and moderation recovery acceptance. |
| 1869 | + Invite claim, Sync progress/retry, view publication and fork navigation |
| 1870 | + have recorded checks in `output/playwright/browser-checks.md`; deletion |
| 1871 | + recovery also has focused daemon tests. The |
| 1872 | + NIP-46 reconnect and signed mutation path is covered; the NIP-07 restore |
| 1873 | + fixture intentionally loses its page-local signer after navigation and is |
| 1874 | + not evidence of a native signer persistence failure. |
| 1875 | +3. Restart coverage now includes inbox/outbox delivery retry, callback |
| 1876 | + revocation, dump and backup jobs, view checkpoints, notification intents, |
| 1877 | + and terminal replication-job failure status with retry attempts. Portable |
| 1878 | + import progress remains an unverified restart case. |
| 1879 | +4. Complete a private object transfer and full inventory assertion after |
| 1880 | + restart. Signed private HTTP discovery/negotiation and pending inventory |
| 1881 | + survive restart; the separate real private transfer run passes too. Native state projection, |
| 1882 | + stale-worker protection, dropped-ref cleanup, journal recovery, immediate |
| 1883 | + multi-ref push, and private-state hiding already have focused tests. |
| 1884 | + |
| 1885 | +The complete source inventory and explicit exclusions remain machine-readable |
| 1886 | +in `docs/parity.json`. Items stay `pending` when their implementation has |
| 1887 | +focused tests but lacks the route, browser, restart, or source conformance |
| 1888 | +evidence named above. |
| 1889 | diff --git a/docs/parity.json b/docs/parity.json |
| 1890 | new file mode 100644 |
| 1891 | index 0000000..9ddc368 |
| 1892 | --- /dev/null |
| 1893 | +++ b/docs/parity.json |
| 1894 | @@ -0,0 +1,410 @@ |
| 1895 | +{ |
| 1896 | + "schema": "tiny/parity/1", |
| 1897 | + "source": { |
| 1898 | + "repository": "../bindws", |
| 1899 | + "revision": "5d31267", |
| 1900 | + "routes": "src/routes.ts", |
| 1901 | + "management": "src/manage.ts", |
| 1902 | + "relayTemplates": "relay-templates", |
| 1903 | + "connectionTemplates": "connection-templates", |
| 1904 | + "conformance": "test/conformance" |
| 1905 | + }, |
| 1906 | + "statusMeaning": { |
| 1907 | + "pending": "retained behavior not yet verified in tiny", |
| 1908 | + "verified": "tiny implementation and acceptance check pass", |
| 1909 | + "intentional-change": "explicitly removed by the project contract" |
| 1910 | + }, |
| 1911 | + "evidence": { |
| 1912 | + "conformanceArtifact": "20260907T170116Z: 14 configured files, 17 tests passed, no skips (NIP-11 uses the quota-free compatibility test)", |
| 1913 | + "runtimeChecks": "Full local race suite and go vet pass; full Linux race suite passes at artifacts/slate/tiny-private-git-final-20260907/20260907T165524Z. The final daemon policy changes also pass Linux race tests at artifacts/slate/tiny-git-policy-final-20260907/20260907T165928Z. Focused daemon tests cover mounted NIP-AD signed discovery and isolation, private Git authorization/restart, bounded authenticated upload spooling, tampered payload rejection, and callback recovery/revocation. Public Git performance covers six repositories at artifacts/git-performance/results-after; private nzip and Atlas pass separately at results-private-smoke and results-private-atlas.", |
| 1914 | + "managementGolden": "internal/daemon/management_golden_test.go", |
| 1915 | + "signedUXJourneys": "internal/daemon/ux_contract_test.go", |
| 1916 | + "scope": "focused package tests do not close route, browser, restart, or unlisted conformance matrices" |
| 1917 | + }, |
| 1918 | + "exclusions": [ |
| 1919 | + { |
| 1920 | + "id": "change.route.lease", |
| 1921 | + "source": "src/edge.ts:/lease", |
| 1922 | + "category": "provisioning", |
| 1923 | + "status": "intentional-change", |
| 1924 | + "check": "tiny creates an owned tenant atomically" |
| 1925 | + }, |
| 1926 | + { |
| 1927 | + "id": "change.management.claim", |
| 1928 | + "source": "src/manage.ts:METHODS.claim", |
| 1929 | + "category": "provisioning", |
| 1930 | + "status": "intentional-change", |
| 1931 | + "check": "relay ownership is assigned by tiny tenant create; membership invitation claims remain" |
| 1932 | + }, |
| 1933 | + { |
| 1934 | + "id": "change.runtime.lease-lifecycle", |
| 1935 | + "source": "src/settings.ts:Policy.lease;src/relay.ts:lease/alarm", |
| 1936 | + "category": "provisioning", |
| 1937 | + "status": "intentional-change", |
| 1938 | + "check": "no lease expiry, claim conversion, trial retention or teardown" |
| 1939 | + }, |
| 1940 | + { |
| 1941 | + "id": "change.route.fuel", |
| 1942 | + "source": "src/routes.ts:/fuel,/fuel/invoice;src/fuel.ts", |
| 1943 | + "category": "economics", |
| 1944 | + "status": "intentional-change", |
| 1945 | + "check": "fuel balances, invoices and fuel admission gates are absent; NIP-57 events remain supported" |
| 1946 | + }, |
| 1947 | + { |
| 1948 | + "id": "change.runtime.product-capacity", |
| 1949 | + "source": "src/fuel.ts;src/settings.ts:Policy limits", |
| 1950 | + "category": "economics", |
| 1951 | + "status": "intentional-change", |
| 1952 | + "check": "no billing ledger, paid tier, monthly allowance or built-in tenant capacity entitlement" |
| 1953 | + } |
| 1954 | + ], |
| 1955 | + "items": [ |
| 1956 | + { |
| 1957 | + "id": "route.git-smart-http", |
| 1958 | + "source": "src/routes.ts:isGitPath", |
| 1959 | + "category": "http", |
| 1960 | + "status": "pending", |
| 1961 | + "check": "GRASP Smart HTTP paths work when enabled" |
| 1962 | + }, |
| 1963 | + { |
| 1964 | + "id": "route.web-address-discovery", |
| 1965 | + "source": "src/routes.ts:isWebAddressRequest", |
| 1966 | + "category": "http", |
| 1967 | + "status": "verified", |
| 1968 | + "check": "routes_acceptance_test.go rejects unsigned discovery; hosting_routes_acceptance_test.go verifies positive signed NIP-AD through mounted tenant routing and isolation; domains tests cover path validation and read denial" |
| 1969 | + }, |
| 1970 | + { |
| 1971 | + "id": "route.nip11", |
| 1972 | + "source": "src/routes.ts:Accept application/nostr+json", |
| 1973 | + "category": "http", |
| 1974 | + "status": "verified", |
| 1975 | + "check": "routes_acceptance_test.go verifies mounted NIP-11 content negotiation and absence of removed fuel capability; full capability matrix remains in protocol items" |
| 1976 | + }, |
| 1977 | + { |
| 1978 | + "id": "route.nip86", |
| 1979 | + "source": "src/routes.ts:isManagementRequest", |
| 1980 | + "category": "http", |
| 1981 | + "status": "verified", |
| 1982 | + "check": "routes_acceptance_test.go verifies signed and unsigned mounted NIP-86 requests" |
| 1983 | + }, |
| 1984 | + { |
| 1985 | + "id": "route.bridge", |
| 1986 | + "source": "src/routes.ts:/events,/query,/count", |
| 1987 | + "category": "http", |
| 1988 | + "status": "verified", |
| 1989 | + "check": "routes_acceptance_test.go verifies signed query, unsigned denial, malformed request, and CORS preflight" |
| 1990 | + }, |
| 1991 | + { |
| 1992 | + "id": "route.nip05", |
| 1993 | + "source": "src/routes.ts:/.well-known/nostr.json", |
| 1994 | + "category": "http", |
| 1995 | + "status": "verified", |
| 1996 | + "check": "routes_acceptance_test.go verifies mounted NIP-05 unknown-name response; named-user and directory visibility remain in hosting acceptance" |
| 1997 | + }, |
| 1998 | + { |
| 1999 | + "id": "route.people", |
| 2000 | + "source": "src/routes.ts:/people", |
| 2001 | + "category": "http", |
| 2002 | + "status": "pending", |
| 2003 | + "check": "directory listing and self key match" |
| 2004 | + }, |
| 2005 | + { |
| 2006 | + "id": "route.invites", |
| 2007 | + "source": "src/routes.ts:/terms,/invite/*,/api/join-policy,/api/invites/claim", |
| 2008 | + "category": "http", |
| 2009 | + "status": "pending", |
| 2010 | + "check": "membership invitation claim/join flow works; this is not relay leasing" |
| 2011 | + }, |
| 2012 | + { |
| 2013 | + "id": "route.data", |
| 2014 | + "source": "src/routes.ts:/dumps/*,/backups/*,/backups/restore,/backups/preview,/view/*,/import", |
| 2015 | + "category": "http", |
| 2016 | + "status": "pending", |
| 2017 | + "check": "download, restore, preview, views and import preserve auth" |
| 2018 | + }, |
| 2019 | + { |
| 2020 | + "id": "route.files", |
| 2021 | + "source": "src/routes.ts:/upload,/mirror,/report,/list/*,blob paths,/.well-known/nostr/nip96.json,/nip96/*", |
| 2022 | + "category": "http", |
| 2023 | + "status": "pending", |
| 2024 | + "check": "Blossom and NIP-96 behavior works on local object storage" |
| 2025 | + }, |
| 2026 | + { |
| 2027 | + "id": "route.public-pages", |
| 2028 | + "source": "src/routes.ts:/card.json,/card.nostr,/card.svg,/qr.svg,/connect.json,/* pages,/signer.js", |
| 2029 | + "category": "http", |
| 2030 | + "status": "pending", |
| 2031 | + "check": "plain HTML site keeps all public tasks and handoffs" |
| 2032 | + }, |
| 2033 | + { |
| 2034 | + "id": "route.cors", |
| 2035 | + "source": "src/routes.ts:OPTIONS", |
| 2036 | + "category": "http", |
| 2037 | + "status": "pending", |
| 2038 | + "check": "CORS preflight and content types match" |
| 2039 | + }, |
| 2040 | + { |
| 2041 | + "id": "management.methods", |
| 2042 | + "source": "src/manage.ts:METHODS", |
| 2043 | + "category": "management", |
| 2044 | + "status": "verified", |
| 2045 | + "check": "management_golden_test invokes all 90 retained methods on fresh tenants without unsupported dispatch; ux_contract_test covers signed invite/member/moderation/config/identity/fork/delete and guided Sync journeys; browser and source-conformance coverage remain separate items" |
| 2046 | + }, |
| 2047 | + { |
| 2048 | + "id": "relay.websocket", |
| 2049 | + "source": "src/relay.ts", |
| 2050 | + "category": "protocol", |
| 2051 | + "status": "pending", |
| 2052 | + "check": "NIP-01, NIP-42, filtering, EOSE, replaceables, deletes, expiry, protected events and reconnect behavior" |
| 2053 | + }, |
| 2054 | + { |
| 2055 | + "id": "relay.query-search-sync", |
| 2056 | + "source": "src/store.ts;src/filter.ts;src/negentropy.ts", |
| 2057 | + "category": "protocol", |
| 2058 | + "status": "pending", |
| 2059 | + "check": "COUNT/HLL, NIP-50 modes, NIP-77 and visibility parity" |
| 2060 | + }, |
| 2061 | + { |
| 2062 | + "id": "identity-groups", |
| 2063 | + "source": "src/identity.ts;src/groups.ts;src/roles.ts;src/succession.ts", |
| 2064 | + "category": "community", |
| 2065 | + "status": "pending", |
| 2066 | + "check": "NIP-29/43 identity, roles, invites, joins, leaves, pins, transfer and succession" |
| 2067 | + }, |
| 2068 | + { |
| 2069 | + "id": "moderation", |
| 2070 | + "source": "src/audit.ts;src/manage.ts;src/settings.ts", |
| 2071 | + "category": "community", |
| 2072 | + "status": "pending", |
| 2073 | + "check": "reports, hide thresholds, bans/erase, blocked words, IP blocks, audit and pin flows" |
| 2074 | + }, |
| 2075 | + { |
| 2076 | + "id": "delivery", |
| 2077 | + "source": "src/delivery.ts;src/push.ts;src/notify.ts", |
| 2078 | + "category": "automation", |
| 2079 | + "status": "pending", |
| 2080 | + "check": "NIP-65, NIP-9a, NIP-17/NIP-59 notifications, retries, deduplication and terminal status" |
| 2081 | + }, |
| 2082 | + { |
| 2083 | + "id": "jobs", |
| 2084 | + "source": "src/jobs.ts;src/pull.ts", |
| 2085 | + "category": "automation", |
| 2086 | + "status": "pending", |
| 2087 | + "check": "durable_acceptance_test.go proves dump/view/notification recovery and replication_acceptance_test.go proves delivery retry across restart; backup jobs, inbox/outbox restart, and terminal failure metrics remain" |
| 2088 | + }, |
| 2089 | + { |
| 2090 | + "id": "storage-files", |
| 2091 | + "source": "src/blossom.ts;src/nip96.ts;src/site-mirror.ts", |
| 2092 | + "category": "hosting", |
| 2093 | + "status": "verified", |
| 2094 | + "check": "nip96_test.go covers mounted upload/get/delete, sites_test.go covers verified mirror and manifest deletion, and the 17-test conformance artifact covers Blossom behavior" |
| 2095 | + }, |
| 2096 | + { |
| 2097 | + "id": "hosting-sites", |
| 2098 | + "source": "src/sites.ts;src/domains.ts;src/pages.ts;src/nipad.ts", |
| 2099 | + "category": "hosting", |
| 2100 | + "status": "pending", |
| 2101 | + "check": "Mounted site/custom-host isolation and positive signed NIP-AD discovery pass; domains tests cover validation/auth and conformance covers NIP-5A. Combined pages/feed UX acceptance remains separate." |
| 2102 | + }, |
| 2103 | + { |
| 2104 | + "id": "hosting-git", |
| 2105 | + "source": "src/grasp.ts;src/grasp-state.ts;src/git-storage.ts;src/git-sqlite.ts;src/git-objects.ts", |
| 2106 | + "category": "hosting", |
| 2107 | + "status": "pending", |
| 2108 | + "check": "Immediate 30-ref HTTP push, live hosting/read policy changes, native ref projection/deletion/recovery, private HTTP discovery and negotiation after restart, payload binding, six public repositories and private Atlas transfers pass. Full inventory/repaired private transfer after restart remains an acceptance scenario." |
| 2109 | + }, |
| 2110 | + { |
| 2111 | + "id": "hosting-marmot", |
| 2112 | + "source": "src/marmot.ts", |
| 2113 | + "category": "hosting", |
| 2114 | + "status": "pending", |
| 2115 | + "check": "KeyPackages, opaque MLS envelopes, recipient checks and templates" |
| 2116 | + }, |
| 2117 | + { |
| 2118 | + "id": "views", |
| 2119 | + "source": "src/views.ts", |
| 2120 | + "category": "derived-records", |
| 2121 | + "status": "pending", |
| 2122 | + "check": "all views, presence, schedules, fingerprints, audience privacy and run history" |
| 2123 | + }, |
| 2124 | + { |
| 2125 | + "id": "config", |
| 2126 | + "source": "src/config.ts;src/presets.ts;src/connections.ts", |
| 2127 | + "category": "configuration", |
| 2128 | + "status": "pending", |
| 2129 | + "check": "plan/apply/export, partial sections, presets, placeholders and visibility" |
| 2130 | + }, |
| 2131 | + { |
| 2132 | + "id": "backups-imports", |
| 2133 | + "source": "src/backups.ts;src/dumps.ts;src/imports.ts;src/git-backup.ts", |
| 2134 | + "category": "operations", |
| 2135 | + "status": "pending", |
| 2136 | + "check": "data_http_test.go and browser-restore-qa.json cover signed preview/restore, malformed retry, freshness, private state, files, and Git backup recovery; portable import progress and full cross-version format parity remain" |
| 2137 | + }, |
| 2138 | + { |
| 2139 | + "id": "ux.console", |
| 2140 | + "source": "src/console/console.html;src/console/console.js", |
| 2141 | + "category": "ux", |
| 2142 | + "status": "pending", |
| 2143 | + "check": "plain HTML/forms retain all ten tabs, roles, signers, validation, progress, recovery, QR and app handoff" |
| 2144 | + } |
| 2145 | + ], |
| 2146 | + "registryInventories": { |
| 2147 | + "managementMethods": { |
| 2148 | + "source": "src/manage.ts:METHODS", |
| 2149 | + "status": "pending", |
| 2150 | + "items": [ |
| 2151 | + "supportedmethods", |
| 2152 | + "listaudit", |
| 2153 | + "stats", |
| 2154 | + "getpolicy", |
| 2155 | + "setpolicy", |
| 2156 | + "listviews", |
| 2157 | + "banpubkey", |
| 2158 | + "setblockedwords", |
| 2159 | + "allowpubkey", |
| 2160 | + "setmember", |
| 2161 | + "unrulepubkey", |
| 2162 | + "removemember", |
| 2163 | + "listbannedpubkeys", |
| 2164 | + "listallowedpubkeys", |
| 2165 | + "listmembers", |
| 2166 | + "listpeople", |
| 2167 | + "createinvite", |
| 2168 | + "listinvites", |
| 2169 | + "revokeinvite", |
| 2170 | + "listclaims", |
| 2171 | + "listlisthistory", |
| 2172 | + "restorelist", |
| 2173 | + "createclaim", |
| 2174 | + "deleteclaim", |
| 2175 | + "removesubtree", |
| 2176 | + "banevent", |
| 2177 | + "allowevent", |
| 2178 | + "listeventsneedingmoderation", |
| 2179 | + "blockip", |
| 2180 | + "unblockip", |
| 2181 | + "listblockedips", |
| 2182 | + "listreports", |
| 2183 | + "resolvereport", |
| 2184 | + "exportconfig", |
| 2185 | + "importconfig", |
| 2186 | + "deleterelay", |
| 2187 | + "listblobs", |
| 2188 | + "listsites", |
| 2189 | + "deleteblob", |
| 2190 | + "listbannedevents", |
| 2191 | + "deleteevent", |
| 2192 | + "listrecentevents", |
| 2193 | + "searchevents", |
| 2194 | + "pinevent", |
| 2195 | + "unpinevent", |
| 2196 | + "listpins", |
| 2197 | + "allowkind", |
| 2198 | + "disallowkind", |
| 2199 | + "unrulekind", |
| 2200 | + "storagestats", |
| 2201 | + "gitstorage", |
| 2202 | + "setretention", |
| 2203 | + "listretention", |
| 2204 | + "purgekind", |
| 2205 | + "listallowedkinds", |
| 2206 | + "listblockedkinds", |
| 2207 | + "notifytest", |
| 2208 | + "resetrules", |
| 2209 | + "listpresets", |
| 2210 | + "listconnectiontemplates", |
| 2211 | + "listconnections", |
| 2212 | + "setconnections", |
| 2213 | + "applypreset", |
| 2214 | + "forkrelay", |
| 2215 | + "pullfrom", |
| 2216 | + "pullstatus", |
| 2217 | + "listjobs", |
| 2218 | + "deliverystatus", |
| 2219 | + "addjob", |
| 2220 | + "removejob", |
| 2221 | + "runjob", |
| 2222 | + "backfill", |
| 2223 | + "transferowner", |
| 2224 | + "listdumps", |
| 2225 | + "deletedump", |
| 2226 | + "dumpnow", |
| 2227 | + "backupnow", |
| 2228 | + "listbackups", |
| 2229 | + "deletebackup", |
| 2230 | + "setsuccession", |
| 2231 | + "clearsuccession", |
| 2232 | + "successionstatus", |
| 2233 | + "changerelayname", |
| 2234 | + "changerelaydescription", |
| 2235 | + "changerelayicon", |
| 2236 | + "adddomain", |
| 2237 | + "setdomainsite", |
| 2238 | + "checkdomain", |
| 2239 | + "removedomain", |
| 2240 | + "listdomains" |
| 2241 | + ] |
| 2242 | + }, |
| 2243 | + "relayTemplates": { |
| 2244 | + "source": "relay-templates/*.jsonc", |
| 2245 | + "status": "pending", |
| 2246 | + "items": [ |
| 2247 | + "01-default", |
| 2248 | + "02-outbox", |
| 2249 | + "03-inbox", |
| 2250 | + "04-private", |
| 2251 | + "05-chat", |
| 2252 | + "06-media", |
| 2253 | + "07-search", |
| 2254 | + "08-articles", |
| 2255 | + "09-dm", |
| 2256 | + "10-quiet", |
| 2257 | + "11-site", |
| 2258 | + "12-marmot", |
| 2259 | + "13-grasp", |
| 2260 | + "14-marmot-members", |
| 2261 | + "15-home" |
| 2262 | + ] |
| 2263 | + }, |
| 2264 | + "connectionTemplates": { |
| 2265 | + "source": "connection-templates/*.jsonc", |
| 2266 | + "status": "pending", |
| 2267 | + "items": [ |
| 2268 | + "01-notes", |
| 2269 | + "02-find-me", |
| 2270 | + "03-group", |
| 2271 | + "04-blog", |
| 2272 | + "05-repos", |
| 2273 | + "06-sites", |
| 2274 | + "07-bookmarks", |
| 2275 | + "08-photos", |
| 2276 | + "09-files", |
| 2277 | + "10-dm", |
| 2278 | + "11-marmot", |
| 2279 | + "12-relay-page" |
| 2280 | + ] |
| 2281 | + }, |
| 2282 | + "conformanceTests": { |
| 2283 | + "source": "test/conformance/*.test.ts", |
| 2284 | + "status": "verified", |
| 2285 | + "check": "artifacts/conformance/20260907T170116Z passes 17 tests across every configured source conformance family; nip11.test.ts is replaced by scripts/conformance/nip11.compat.test.ts for the quota-free self-hosted contract", |
| 2286 | + "items": [ |
| 2287 | + "nip01.test.ts", |
| 2288 | + "nip09.test.ts", |
| 2289 | + "nip11.test.ts", |
| 2290 | + "nip17.test.ts", |
| 2291 | + "nip40.test.ts", |
| 2292 | + "nip42.test.ts", |
| 2293 | + "nip45.test.ts", |
| 2294 | + "nip50.test.ts", |
| 2295 | + "nip62.test.ts", |
| 2296 | + "nip70.test.ts", |
| 2297 | + "nip77.test.ts", |
| 2298 | + "nip5a.test.ts", |
| 2299 | + "blossom.test.ts", |
| 2300 | + "grasp.test.ts" |
| 2301 | + ] |
| 2302 | + } |
| 2303 | + } |
| 2304 | +} |
| 2305 | diff --git a/docs/parity.md b/docs/parity.md |
| 2306 | new file mode 100644 |
| 2307 | index 0000000..6b70d12 |
| 2308 | --- /dev/null |
| 2309 | +++ b/docs/parity.md |
| 2310 | @@ -0,0 +1,45 @@ |
| 2311 | +# Tiny parity ledger |
| 2312 | + |
| 2313 | +`docs/parity.json` is the machine-readable completion ledger for the Go |
| 2314 | +implementation. Every retained item starts as `pending`; implementation work |
| 2315 | +may change it to `verified` only with a test or recorded acceptance check. |
| 2316 | + |
| 2317 | +The source baseline is bindws revision `5d31267`. The registry inventories are |
| 2318 | +deliberately explicit: all retained management methods, relay templates, |
| 2319 | +connection templates and black-box conformance files are listed. Broad family |
| 2320 | +items point to the source modules that must be decomposed into individual |
| 2321 | +ledger entries as work lands. |
| 2322 | + |
| 2323 | +The only intentional changes are the `/lease` and lease lifecycle, the relay |
| 2324 | +ownership `claim` method, fuel and billing endpoints, and hosted product |
| 2325 | +capacity entitlements. Membership |
| 2326 | +invitation claims (`createclaim`, `listclaims`, `deleteclaim` and invite join |
| 2327 | +flows) remain required. NIP-57 zap events remain ordinary supported Nostr |
| 2328 | +events; they do not create fuel credit. |
| 2329 | + |
| 2330 | +## Acceptance |
| 2331 | + |
| 2332 | +Run the black-box suite against a running local `tiny` instance with |
| 2333 | +`RELAY_URL=ws://host:port`. The conformance setup provisions a permanent, |
| 2334 | +owned tenant through `tiny tenant create` when configured. It never skips a |
| 2335 | +test because the relay has no `claim` method. A legacy bindws run is available |
| 2336 | +only with `LEGACY_CLAIM=1` for comparison. |
| 2337 | + |
| 2338 | +Full completion requires every retained ledger entry to be `verified`, every |
| 2339 | +conformance file to pass, and an end-to-end browser matrix covering the plain |
| 2340 | +HTML replacement for all ten console families: People, Moderation, Rules, |
| 2341 | +Identity, Connect, Data, Sync, Views, Health and Owner. The matrix includes |
| 2342 | +NIP-07 and NIP-46 signing, role authorization, validation and error states, |
| 2343 | +progress/recovery, backup/restore, QR and app handoff. Disabled features still |
| 2344 | +need an implementation and an enabled-path test. |
| 2345 | + |
| 2346 | +Current evidence includes the 20260907T170116Z artifact (14 configured files, |
| 2347 | +17 tests, no skips), passing `go test -race ./...` and `go vet ./...`, mounted |
| 2348 | +route acceptance tests, durable restart tests, distinct NIP-46 browser resume |
| 2349 | +evidence, browser backup restore/retry evidence, and six-repository Git |
| 2350 | +performance results. The ledger still separates those checks from the |
| 2351 | +remaining full route, browser navigation, restart coverage, and native Git |
| 2352 | +state semantics. |
| 2353 | + |
| 2354 | +No `skipped` status is permitted. Removed fuel/lease tests are explicit |
| 2355 | +negative tests for absence; all other bindws behavior remains required. |
| 2356 | diff --git a/docs/personal-relay.md b/docs/personal-relay.md |
| 2357 | new file mode 100644 |
| 2358 | index 0000000..9296c37 |
| 2359 | --- /dev/null |
| 2360 | +++ b/docs/personal-relay.md |
| 2361 | @@ -0,0 +1,45 @@ |
| 2362 | +# Personal relay deployment |
| 2363 | + |
| 2364 | +The personal relay runs on Slate and is available through Tailscale. Open [https://tiny](https://tiny) on this Mac, or use [https://tiny.tailbe516a.ts.net](https://tiny.tailbe516a.ts.net) on your phone with Tailscale connected. |
| 2365 | + |
| 2366 | +## Storage |
| 2367 | + |
| 2368 | +The relay data lives on a dedicated 2 TB WD drive mounted at `/srv/tiny`. The drive uses UUID `98f49cba-bd8c-432d-a91a-41db66920e90` and is formatted as ext4. |
| 2369 | + |
| 2370 | +Persisted directories include: |
| 2371 | + |
| 2372 | +- `/srv/tiny/data` for relay and tenant data |
| 2373 | +- `/srv/tiny/tailscale` for Tailscale state |
| 2374 | +- `/srv/tiny/certs` for certificates |
| 2375 | +- `/srv/tiny/logs` for service logs |
| 2376 | + |
| 2377 | +The service checks the drive's UUID before starting and stops if the mount disappears. |
| 2378 | + |
| 2379 | +## Service operations |
| 2380 | + |
| 2381 | +The service is `tiny.service`, with certificate renewal scheduled by `tiny-certificates.timer`. |
| 2382 | + |
| 2383 | +In the Tailscale admin console, open **Machines > tiny > Endpoints** to find the HTTP and HTTPS listeners. The node's key expiry is disabled. |
| 2384 | + |
| 2385 | +```sh |
| 2386 | +ssh slate sudo systemctl status tiny |
| 2387 | +ssh slate sudo systemctl restart tiny |
| 2388 | +ssh slate sudo systemctl status tiny-certificates.timer |
| 2389 | +ssh slate docker logs tiny |
| 2390 | +``` |
| 2391 | + |
| 2392 | +To deploy an update, build the images from a checkout on Slate, then restart the service: |
| 2393 | + |
| 2394 | +```sh |
| 2395 | +docker build --target runtime -t tiny:dogfood . |
| 2396 | +docker build -f deploy/Tailscale.Dockerfile -t tiny-tailscale:dogfood deploy |
| 2397 | +sudo systemctl restart tiny |
| 2398 | +``` |
| 2399 | + |
| 2400 | +The owner is `npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5`. Sign in with that identity to manage the relay. |
| 2401 | + |
| 2402 | +## Web operations |
| 2403 | + |
| 2404 | +Sign in at the relay URL with a Nostr signer before using owner controls. The Manage pages provide relay configuration, jobs, connections, backups, and file operations. Use Manage data or the browser tools page to start and inspect backups. The [WebMCP guide](webmcp.md) describes native browser-agent controls and their existing signer authorization path. |
| 2405 | + |
| 2406 | +Download backups from **Manage > Data** and keep a copy on another device. |
| 2407 | diff --git a/docs/protocol-expansion.md b/docs/protocol-expansion.md |
| 2408 | new file mode 100644 |
| 2409 | index 0000000..c65d5ba |
| 2410 | --- /dev/null |
| 2411 | +++ b/docs/protocol-expansion.md |
| 2412 | @@ -0,0 +1,49 @@ |
| 2413 | +# Approved protocol and client expansion |
| 2414 | + |
| 2415 | +Approved 2026-09-07. Full retained bindws behavior and UX parity remains required. |
| 2416 | +BUD-05 media optimization and BUD-07 payments are excluded. Tenants remain |
| 2417 | +permanent, without trials, fuel, billing, or product resource entitlements. |
| 2418 | + |
| 2419 | +## Acceptance ledger |
| 2420 | + |
| 2421 | +An implementation is complete only after relevant mounted routes, permissions, |
| 2422 | +restart behavior, interoperability, and user journeys have evidence. Compilation |
| 2423 | +or a feature flag alone does not establish protocol support. |
| 2424 | + |
| 2425 | +| Work | Required outcome | Status | |
| 2426 | +| --- | --- | --- | |
| 2427 | +| GRASP-01 | Native streaming Git, signed refs, recovery and HTTP interoperability | In progress | |
| 2428 | +| GRASP-02/03 | Durable independent repository synchronization and complete collaboration filtering | In progress | |
| 2429 | +| GRASP-05/06 | Archive and alternative PR behavior with recoverable inventory | In progress | |
| 2430 | +| GRASP-08 | Private service boundary, authenticated metadata and Git, private peer replication | In progress | |
| 2431 | +| BUD-01/02/04/06/08/09/11/12 | Correct file retrieval, ownership, authorization, pagination, mirrors and reports | In progress | |
| 2432 | +| BUD-03/10 and NIP-B7/94/92 | Portable server lists, references, metadata and client fallback | In progress | |
| 2433 | +| NIP-34/22 | Repository activity, threads, statuses and GRASP server lists | In progress | |
| 2434 | +| NIP-65/77/67 | Inbox/outbox recovery and accurate scalable reconciliation | In progress | |
| 2435 | +| NIP-42/98/17/59/70 | Consistent authentication and private data access | In progress | |
| 2436 | +| NIP-43/56 | Membership requests and integrated moderation | In progress | |
| 2437 | +| NIP-11/66/86 | Truthful capabilities, optional peer monitoring, operational visibility | In progress | |
| 2438 | +| NIP-96 | Preserve upload/get/delete compatibility through shared file storage | In progress | |
| 2439 | + |
| 2440 | +## Contained client experience |
| 2441 | + |
| 2442 | +The same embedded site must provide repository discovery, branch/tag selection, |
| 2443 | +source trees, escaped source previews, downloads, history, commit diffs and |
| 2444 | +repository activity. File browsing includes pagination, metadata, previews, |
| 2445 | +downloads and existing signed management actions. Connect publishes owner-signed |
| 2446 | +service lists through the existing signer. Operational pages expose synchronization, |
| 2447 | +jobs, failures, storage, backups and capabilities with links to recovery actions. |
| 2448 | + |
| 2449 | +All routes share the tenant's current access policy; private data must not become |
| 2450 | +public through a browsing or download route. The site retains its plain HTML |
| 2451 | +foundation and fixed desktop/mobile variants. The user explicitly approved richer |
| 2452 | +Git/source components: repository tabs, branch selectors, breadcrumbs, file trees, |
| 2453 | +syntax highlighting, compact line numbers, colored diffs and clone controls. |
| 2454 | +Basic forms, locally served scripts and optional Fixi enhancement remain the |
| 2455 | +foundation. Native Git supplies object reads and diffs. Large |
| 2456 | +files are streamed for download and previews disclose truncation. |
| 2457 | + |
| 2458 | +Observability accompanies implementation: queue age, retries, last success, |
| 2459 | +subprocess time, memory and relay acknowledgement latency under mixed workloads. |
| 2460 | +Real repository performance fixtures on Slate remain regression evidence; tail |
| 2461 | +latency is still an investigation target. |
| 2462 | diff --git a/docs/testing.md b/docs/testing.md |
| 2463 | new file mode 100644 |
| 2464 | index 0000000..3f81c4b |
| 2465 | --- /dev/null |
| 2466 | +++ b/docs/testing.md |
| 2467 | @@ -0,0 +1,83 @@ |
| 2468 | +# Testing and the Linux slate |
| 2469 | + |
| 2470 | +The normal development loop is local: |
| 2471 | + |
| 2472 | +```sh |
| 2473 | +make test-internal-race |
| 2474 | +make test |
| 2475 | +``` |
| 2476 | + |
| 2477 | +The repository also contains a multistage Docker build. `make docker-test` runs `TEST_PACKAGES` (default `./...`) under Go 1.27.1 on Debian Bookworm with the race detector. `make docker-build` builds the self-hosted runtime image. |
| 2478 | + |
| 2479 | +## Remote slate |
| 2480 | + |
| 2481 | +`scripts/test-slate.sh` runs the same internal race test in an isolated Docker build on the configured Linux host. It synchronizes the exact worktree, including untracked files, over SSH into a unique directory under `/home/tank/tinyrelay-tests/`; it never runs global Docker cleanup and never edits host configuration. |
| 2482 | + |
| 2483 | +```sh |
| 2484 | +SLATE_HOST=slate SLATE_NAME=tinyrelay-review-1 ./scripts/test-slate.sh |
| 2485 | +``` |
| 2486 | + |
| 2487 | +Useful overrides are `SLATE_PACKAGES` (the default internal package list) and `SLATE_ARTIFACTS` (local artifact destination). The script records UTC time, host, kernel, CPU count, Docker server version, Go version, target platform, and SQLite module version beside the Docker build log, including a status file when the build fails. A unique `SLATE_NAME` is required when multiple runs share a slate host. |
| 2488 | + |
| 2489 | +To run the complete tree on Linux, set `SLATE_PACKAGES='./...'`. |
| 2490 | + |
| 2491 | +The runtime image is a small Debian image containing the static Go binary, `ca-certificates`, and `git`. It runs as UID 10001 (`relay`) with `/data` as the writable volume. Bind the relay and diagnostics listeners explicitly; diagnostics should stay on loopback or a protected Tailscale address and must not be published publicly. |
| 2492 | + |
| 2493 | +Performance runs should use a separate artifact name and record workload seed, client version, host CPU, Go version, SQLite version, storage medium, configuration, raw logs, and profiles. Capacity claims are meaningful only when those inputs are kept with the result. |
| 2494 | + |
| 2495 | +The checked-in benchmarks provide a repeatable first baseline without claiming an optimized capacity: |
| 2496 | + |
| 2497 | +```sh |
| 2498 | +make benchmark |
| 2499 | +SLATE_HOST=slate ./scripts/bench-slate.sh |
| 2500 | +``` |
| 2501 | + |
| 2502 | +`BenchmarkSave`, `BenchmarkQuery`, and `BenchmarkTextSearch` use a local SQLite WAL store with representative seeded events. `BenchmarkFanout` measures only the in-memory broadcast and enqueue path to 10, 100, and 1,000 subscribed clients; it excludes websocket network writes and client-reader scheduling. The slate script records the Linux host, CPU count, Docker/Go/SQLite versions, benchmark output, and exit status under `artifacts/bench/`; use `BENCH_PACKAGES` and `BENCHTIME` for controlled comparisons. Treat these as baselines until a workload and hardware matrix supports a capacity claim. |
| 2503 | + |
| 2504 | +For Git workloads that include child processes inside Docker, `scripts/observe-git-benchmark.py` captures the benchmark output, host load/memory, and Docker cgroup v2 CPU, memory, I/O, and PID samples every 500ms. It also snapshots authenticated `/metrics`, heap, and goroutine profiles before and after the run. The optional `--cpu-profile` captures one bounded 60-second Go CPU profile; cgroup samples remain the source for child CPU accounting. |
| 2505 | + |
| 2506 | +```sh |
| 2507 | +python3 scripts/observe-git-benchmark.py --out artifacts/git/baseline \ |
| 2508 | + --container tiny-git-perf-baseline \ |
| 2509 | + --diagnostics http://127.0.0.1:17448 --token "$TINY_DIAGNOSTICS_TOKEN" \ |
| 2510 | + -- ./scripts/git-performance.mjs |
| 2511 | +``` |
| 2512 | + |
| 2513 | +The conformance harness runs the copied bindws-compatible cases against a running local daemon and preserves stdout, stderr, status, and environment artifacts. Start the daemon, then run `node scripts/conformance/run.mjs`; the NIP-11 compatibility assertion reflects the quota-free self-hosted contract. |
| 2514 | + |
| 2515 | +## Real Git repositories |
| 2516 | + |
| 2517 | +The [Git performance report](git-performance.md) records the six-repository Slate experiment. Its HTML version and raw profiles live in `artifacts/git-performance/`; regenerate both reports with `python3 scripts/report-git-performance.py`. |
| 2518 | + |
| 2519 | +Prepare a new corpus from committed local branches and tags. This leaves the source repositories unchanged and refuses an existing output directory: |
| 2520 | + |
| 2521 | +```sh |
| 2522 | +python3 scripts/prepare-git-performance.py \ |
| 2523 | + --source-directory "$HOME/Developer" --out /tmp/tiny-git-corpus |
| 2524 | +``` |
| 2525 | + |
| 2526 | +Create a dedicated test tenant using the public benchmark fixture identity, then start a separate test daemon. Never use this identity for a real relay: |
| 2527 | + |
| 2528 | +```sh |
| 2529 | +tiny tenant create --data-dir /tmp/tiny-git-test --name main \ |
| 2530 | + --owner 5ac640e5df8f7945381c31f435288ba3f587fbd68efb27abaf941792f9c36369 |
| 2531 | +tiny serve --data-dir /tmp/tiny-git-test --default-tenant main \ |
| 2532 | + --listen 127.0.0.1:17447 --allow-private-relays |
| 2533 | +``` |
| 2534 | + |
| 2535 | +In another terminal, enable Git hosting and run the workload: |
| 2536 | + |
| 2537 | +```sh |
| 2538 | +node scripts/configure-git-performance.mjs http://127.0.0.1:17447 |
| 2539 | +node scripts/git-performance.mjs --relay ws://127.0.0.1:17447 \ |
| 2540 | + --repos-dir /tmp/tiny-git-corpus --out artifacts/git-performance/local-run \ |
| 2541 | + --repeats 3 |
| 2542 | +``` |
| 2543 | + |
| 2544 | +Use `--only strudel,atlas` to select fixtures. Each run creates distinct signed repository identities, checks all transferred refs and HEAD, runs `git fsck`, and measures relay EVENT/REQ traffic throughout the Git phases. The incremental push adds a commit with an unchanged tree; it is a small metadata update, not a new binary upload. |
| 2545 | + |
| 2546 | +Add `--private` to test private repository announcements and NIP-98 Git authorization. The harness authenticates its control WebSocket with the repository signer and starts a loopback signing proxy for native Git. That proxy hashes each request through a temporary file and signs the exact URL, method and body; its overhead is included in private timings. Private server authorization also spools before invoking Git, so malformed payloads cannot mutate the repository and upload size does not determine Go heap size. Do not interpret private timings as a like-for-like unauthenticated native Git comparison. |
| 2547 | + |
| 2548 | +For native Git comparisons, `go run ./scripts/git-http-baseline --root /path/to/fresh-bare-repos --listen 127.0.0.1:17449` exposes a benchmark-only HTTP server. Initialize one empty bare `<name>.git` per fixture, set its symbolic HEAD to the corpus manifest's `head_branch`, and enable `http.receivepack`. Add `--baseline http://127.0.0.1:17449` to the workload. Use fresh baseline repositories for each run so initial pushes transfer the complete corpus. |
| 2549 | + |
| 2550 | +The raw JSON records failures as failures; interrupted or absent phases are not successful samples. The observer separates Go process memory from container memory, which also includes native Git and file cache. Run performance measurements separately from compilation and test suites on the same host. |
| 2551 | diff --git a/docs/webmcp.md b/docs/webmcp.md |
| 2552 | new file mode 100644 |
| 2553 | index 0000000..e5735a5 |
| 2554 | --- /dev/null |
| 2555 | +++ b/docs/webmcp.md |
| 2556 | @@ -0,0 +1,9 @@ |
| 2557 | +# Browser tools |
| 2558 | + |
| 2559 | +Open **Manage > Tools** to see the tools available to your browser agent. The agent can browse repositories and files, inspect service status, manage background jobs, create backups and update relay settings. |
| 2560 | + |
| 2561 | +Choose **Sign in** in the main navigation and connect your Nostr signer. Reading protected information uses your browser session. Management actions request a signature and follow your account permissions. A queued backup or job has not finished until its status says so. |
| 2562 | + |
| 2563 | +In Chrome, enable **WebMCP for testing** at `chrome://flags/#enable-webmcp-testing` and relaunch. Other browsers can use the same pages and forms. See [Chrome's WebMCP guide](https://developer.chrome.com/docs/ai/webmcp) for availability. |
| 2564 | + |
| 2565 | +The Tools page lists registered tools and the most recent operation. Read the current policy or connection list before changing settings. Job intervals are measured in hours; zero runs once. |
| 2566 | diff --git a/go.mod b/go.mod |
| 2567 | new file mode 100644 |
| 2568 | index 0000000..a287d9b |
| 2569 | --- /dev/null |
| 2570 | +++ b/go.mod |
| 2571 | @@ -0,0 +1,66 @@ |
| 2572 | +module github.com/FelineStateMachine/tinyrelay |
| 2573 | + |
| 2574 | +go 1.27.1 |
| 2575 | + |
| 2576 | +require ( |
| 2577 | + fiatjaf.com/nostr v0.0.0-20260902034142-316ef6591fa2 |
| 2578 | + github.com/btcsuite/btcd/btcec/v2 v2.3.4 |
| 2579 | + github.com/coder/websocket v1.8.13 |
| 2580 | + github.com/dlclark/regexp2 v1.12.0 |
| 2581 | + github.com/google/uuid v1.6.0 |
| 2582 | + github.com/prometheus/client_golang v1.24.1 |
| 2583 | + github.com/prometheus/client_model v0.6.2 |
| 2584 | + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e |
| 2585 | + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 |
| 2586 | + go.opentelemetry.io/otel/sdk v1.46.0 |
| 2587 | + go.opentelemetry.io/otel/trace v1.46.0 |
| 2588 | + modernc.org/sqlite v1.58.0 |
| 2589 | +) |
| 2590 | + |
| 2591 | +require ( |
| 2592 | + github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect |
| 2593 | + github.com/beorn7/perks v1.0.1 // indirect |
| 2594 | + github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect |
| 2595 | + github.com/cenkalti/backoff/v5 v5.0.3 // indirect |
| 2596 | + github.com/cespare/xxhash/v2 v2.3.0 // indirect |
| 2597 | + github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect |
| 2598 | + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect |
| 2599 | + github.com/dustin/go-humanize v1.0.1 // indirect |
| 2600 | + github.com/go-logr/logr v1.4.4 // indirect |
| 2601 | + github.com/go-logr/stdr v1.2.2 // indirect |
| 2602 | + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect |
| 2603 | + github.com/josharian/intern v1.0.0 // indirect |
| 2604 | + github.com/json-iterator/go v1.1.12 // indirect |
| 2605 | + github.com/mailru/easyjson v0.9.0 // indirect |
| 2606 | + github.com/mattn/go-isatty v0.0.24 // indirect |
| 2607 | + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect |
| 2608 | + github.com/modern-go/reflect2 v1.0.2 // indirect |
| 2609 | + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect |
| 2610 | + github.com/ncruces/go-strftime v1.0.0 // indirect |
| 2611 | + github.com/prometheus/common v0.70.1 // indirect |
| 2612 | + github.com/prometheus/procfs v0.21.1 // indirect |
| 2613 | + github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect |
| 2614 | + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| 2615 | + github.com/templexxx/cpu v0.0.1 // indirect |
| 2616 | + github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b // indirect |
| 2617 | + github.com/tidwall/gjson v1.18.0 // indirect |
| 2618 | + github.com/tidwall/match v1.1.1 // indirect |
| 2619 | + github.com/tidwall/pretty v1.2.1 // indirect |
| 2620 | + go.opentelemetry.io/auto/sdk v1.2.1 // indirect |
| 2621 | + go.opentelemetry.io/otel v1.46.0 // indirect |
| 2622 | + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect |
| 2623 | + go.opentelemetry.io/otel/metric v1.46.0 // indirect |
| 2624 | + go.opentelemetry.io/proto/otlp v1.11.0 // indirect |
| 2625 | + golang.org/x/crypto v0.55.0 // indirect |
| 2626 | + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect |
| 2627 | + golang.org/x/net v0.58.0 // indirect |
| 2628 | + golang.org/x/sys v0.47.0 // indirect |
| 2629 | + golang.org/x/text v0.41.0 // indirect |
| 2630 | + google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect |
| 2631 | + google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect |
| 2632 | + google.golang.org/grpc v1.83.1 // indirect |
| 2633 | + google.golang.org/protobuf v1.36.12 // indirect |
| 2634 | + modernc.org/libc v1.75.6 // indirect |
| 2635 | + modernc.org/mathutil v1.7.1 // indirect |
| 2636 | + modernc.org/memory v1.12.1 // indirect |
| 2637 | +) |
| 2638 | diff --git a/go.sum b/go.sum |
| 2639 | new file mode 100644 |
| 2640 | index 0000000..9b79c25 |
| 2641 | --- /dev/null |
| 2642 | +++ b/go.sum |
| 2643 | @@ -0,0 +1,178 @@ |
| 2644 | +fiatjaf.com/nostr v0.0.0-20260902034142-316ef6591fa2 h1:w8nKifd91TaKbcjgQVqZrgGCSqZRBM56SOwdk+649Fo= |
| 2645 | +fiatjaf.com/nostr v0.0.0-20260902034142-316ef6591fa2/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM= |
| 2646 | +github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg= |
| 2647 | +github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA= |
| 2648 | +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= |
| 2649 | +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= |
| 2650 | +github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= |
| 2651 | +github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= |
| 2652 | +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= |
| 2653 | +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= |
| 2654 | +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= |
| 2655 | +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= |
| 2656 | +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= |
| 2657 | +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= |
| 2658 | +github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= |
| 2659 | +github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= |
| 2660 | +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
| 2661 | +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
| 2662 | +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
| 2663 | +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= |
| 2664 | +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= |
| 2665 | +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= |
| 2666 | +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= |
| 2667 | +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= |
| 2668 | +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= |
| 2669 | +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
| 2670 | +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
| 2671 | +github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= |
| 2672 | +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= |
| 2673 | +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= |
| 2674 | +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= |
| 2675 | +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= |
| 2676 | +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= |
| 2677 | +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= |
| 2678 | +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= |
| 2679 | +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= |
| 2680 | +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= |
| 2681 | +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= |
| 2682 | +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= |
| 2683 | +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= |
| 2684 | +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
| 2685 | +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
| 2686 | +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= |
| 2687 | +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= |
| 2688 | +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= |
| 2689 | +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= |
| 2690 | +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= |
| 2691 | +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= |
| 2692 | +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= |
| 2693 | +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= |
| 2694 | +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= |
| 2695 | +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= |
| 2696 | +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= |
| 2697 | +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= |
| 2698 | +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= |
| 2699 | +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= |
| 2700 | +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= |
| 2701 | +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= |
| 2702 | +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= |
| 2703 | +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= |
| 2704 | +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= |
| 2705 | +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= |
| 2706 | +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= |
| 2707 | +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= |
| 2708 | +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= |
| 2709 | +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= |
| 2710 | +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| 2711 | +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
| 2712 | +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= |
| 2713 | +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= |
| 2714 | +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= |
| 2715 | +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= |
| 2716 | +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= |
| 2717 | +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= |
| 2718 | +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= |
| 2719 | +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= |
| 2720 | +github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= |
| 2721 | +github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= |
| 2722 | +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| 2723 | +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| 2724 | +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= |
| 2725 | +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= |
| 2726 | +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= |
| 2727 | +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= |
| 2728 | +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= |
| 2729 | +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= |
| 2730 | +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= |
| 2731 | +github.com/templexxx/cpu v0.0.1 h1:hY4WdLOgKdc8y13EYklu9OUTXik80BkxHoWvTO6MQQY= |
| 2732 | +github.com/templexxx/cpu v0.0.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk= |
| 2733 | +github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b h1:XeDLE6c9mzHpdv3Wb1+pWBaWv/BlHK0ZYIu/KaL6eHg= |
| 2734 | +github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b/go.mod h1:7rwmCH0wC2fQvNEvPZ3sKXukhyCTyiaZ5VTZMQYpZKQ= |
| 2735 | +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= |
| 2736 | +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= |
| 2737 | +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= |
| 2738 | +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= |
| 2739 | +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= |
| 2740 | +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= |
| 2741 | +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= |
| 2742 | +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= |
| 2743 | +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= |
| 2744 | +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= |
| 2745 | +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= |
| 2746 | +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= |
| 2747 | +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= |
| 2748 | +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss= |
| 2749 | +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM= |
| 2750 | +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= |
| 2751 | +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= |
| 2752 | +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= |
| 2753 | +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= |
| 2754 | +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= |
| 2755 | +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= |
| 2756 | +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= |
| 2757 | +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= |
| 2758 | +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= |
| 2759 | +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= |
| 2760 | +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= |
| 2761 | +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= |
| 2762 | +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= |
| 2763 | +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= |
| 2764 | +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= |
| 2765 | +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= |
| 2766 | +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= |
| 2767 | +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= |
| 2768 | +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= |
| 2769 | +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= |
| 2770 | +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= |
| 2771 | +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= |
| 2772 | +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= |
| 2773 | +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= |
| 2774 | +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= |
| 2775 | +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| 2776 | +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= |
| 2777 | +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| 2778 | +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= |
| 2779 | +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= |
| 2780 | +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= |
| 2781 | +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= |
| 2782 | +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= |
| 2783 | +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= |
| 2784 | +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= |
| 2785 | +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI= |
| 2786 | +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4= |
| 2787 | +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= |
| 2788 | +google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= |
| 2789 | +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= |
| 2790 | +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= |
| 2791 | +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= |
| 2792 | +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
| 2793 | +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
| 2794 | +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= |
| 2795 | +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= |
| 2796 | +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= |
| 2797 | +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= |
| 2798 | +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= |
| 2799 | +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= |
| 2800 | +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= |
| 2801 | +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= |
| 2802 | +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= |
| 2803 | +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= |
| 2804 | +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= |
| 2805 | +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= |
| 2806 | +modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= |
| 2807 | +modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= |
| 2808 | +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= |
| 2809 | +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= |
| 2810 | +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= |
| 2811 | +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= |
| 2812 | +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= |
| 2813 | +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= |
| 2814 | +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= |
| 2815 | +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= |
| 2816 | +modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0= |
| 2817 | +modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY= |
| 2818 | +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= |
| 2819 | +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= |
| 2820 | +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= |
| 2821 | +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= |
| 2822 | diff --git a/internal/auth/auth.go b/internal/auth/auth.go |
| 2823 | new file mode 100644 |
| 2824 | index 0000000..b1f0ac5 |
| 2825 | --- /dev/null |
| 2826 | +++ b/internal/auth/auth.go |
| 2827 | @@ -0,0 +1,360 @@ |
| 2828 | +// Package auth identifies Nostr callers at HTTP and websocket boundaries. |
| 2829 | +// Access policy belongs to the caller; this package only verifies proofs. |
| 2830 | +package auth |
| 2831 | + |
| 2832 | +import ( |
| 2833 | + "crypto/rand" |
| 2834 | + "crypto/sha256" |
| 2835 | + "encoding/base64" |
| 2836 | + "encoding/hex" |
| 2837 | + "errors" |
| 2838 | + "fmt" |
| 2839 | + "net/url" |
| 2840 | + "strings" |
| 2841 | + "sync" |
| 2842 | + "time" |
| 2843 | + |
| 2844 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 2845 | +) |
| 2846 | + |
| 2847 | +const tokenSkew = time.Minute |
| 2848 | + |
| 2849 | +var ErrMissingAuthorization = errors.New("auth-required: missing Nostr Authorization header") |
| 2850 | + |
| 2851 | +type Validator struct { |
| 2852 | + now func() time.Time |
| 2853 | + mu sync.Mutex |
| 2854 | + seen map[string]time.Time |
| 2855 | +} |
| 2856 | + |
| 2857 | +func NewValidator(now func() time.Time) *Validator { |
| 2858 | + if now == nil { |
| 2859 | + now = time.Now |
| 2860 | + } |
| 2861 | + return &Validator{now: now, seen: make(map[string]time.Time)} |
| 2862 | +} |
| 2863 | + |
| 2864 | +func (v *Validator) VerifyNIP98(header, rawURL, method, body string) (event.Event, error) { |
| 2865 | + e, err := decodeToken(header, "NIP-98") |
| 2866 | + if err != nil { |
| 2867 | + return event.Event{}, err |
| 2868 | + } |
| 2869 | + if err := event.Validate(e); err != nil { |
| 2870 | + return event.Event{}, authError(err.Error()) |
| 2871 | + } |
| 2872 | + if e.Kind != 27235 { |
| 2873 | + return event.Event{}, authError("token must be kind 27235") |
| 2874 | + } |
| 2875 | + now := v.now() |
| 2876 | + if outsideSkew(now, e.CreatedAt) { |
| 2877 | + return event.Event{}, authError("token expired") |
| 2878 | + } |
| 2879 | + if err := sameRequestURL(event.Tag(e, "u"), rawURL); err != nil { |
| 2880 | + return event.Event{}, err |
| 2881 | + } |
| 2882 | + if strings.ToUpper(event.Tag(e, "method")) != strings.ToUpper(method) { |
| 2883 | + return event.Event{}, authError("token was signed for another method") |
| 2884 | + } |
| 2885 | + if body != "" && event.Tag(e, "payload") != hashBytes([]byte(body)) { |
| 2886 | + return event.Event{}, authError("token payload hash does not match the body") |
| 2887 | + } |
| 2888 | + if err := v.mark(e.ID, now); err != nil { |
| 2889 | + return event.Event{}, err |
| 2890 | + } |
| 2891 | + return e, nil |
| 2892 | +} |
| 2893 | + |
| 2894 | +func (v *Validator) VerifyBlossom(header, action string) (event.Event, error) { |
| 2895 | + return v.verifyBlossom(header, action, "", "", false) |
| 2896 | +} |
| 2897 | + |
| 2898 | +// VerifyBlossomRequest verifies a BUD-11 request, including optional server |
| 2899 | +// domain and blob-hash scope. It is separate from VerifyBlossom to preserve |
| 2900 | +// callers that use the older, server-agnostic helper. |
| 2901 | +func (v *Validator) VerifyBlossomRequest(header, action, server, blobHash string) (event.Event, error) { |
| 2902 | + return v.verifyBlossom(header, action, server, blobHash, true) |
| 2903 | +} |
| 2904 | + |
| 2905 | +// IsBlossomAuthorization reports whether an Authorization header contains a |
| 2906 | +// signed BUD-11 event. Malformed tokens return false so callers can produce |
| 2907 | +// their normal authentication error through the protocol-specific verifier. |
| 2908 | +func IsBlossomAuthorization(header string) bool { |
| 2909 | + e, err := decodeToken(header, "Blossom") |
| 2910 | + return err == nil && e.Kind == 24242 |
| 2911 | +} |
| 2912 | + |
| 2913 | +func (v *Validator) verifyBlossom(header, action, server, blobHash string, strict bool) (event.Event, error) { |
| 2914 | + e, err := decodeToken(header, "Blossom") |
| 2915 | + if err != nil { |
| 2916 | + return event.Event{}, err |
| 2917 | + } |
| 2918 | + if err := event.Validate(e); err != nil { |
| 2919 | + return event.Event{}, authError(err.Error()) |
| 2920 | + } |
| 2921 | + if e.Kind != 24242 { |
| 2922 | + return event.Event{}, authError("token must be kind 24242") |
| 2923 | + } |
| 2924 | + if event.Tag(e, "t") != action { |
| 2925 | + return event.Event{}, authError(fmt.Sprintf("token is not for %s", action)) |
| 2926 | + } |
| 2927 | + if strict && server != "" && !blossomServerScope(e, server) { |
| 2928 | + return event.Event{}, authError("token is not scoped to this server") |
| 2929 | + } |
| 2930 | + now := v.now() |
| 2931 | + expiration := event.Expiration(e) |
| 2932 | + if expiration == 0 || expiration <= now.Unix() { |
| 2933 | + return event.Event{}, authError("token expired") |
| 2934 | + } |
| 2935 | + if e.CreatedAt > now.Unix()+int64(5*time.Minute/time.Second) { |
| 2936 | + return event.Event{}, authError("token is from the future") |
| 2937 | + } |
| 2938 | + if strict && e.CreatedAt < now.Unix()-int64(5*time.Minute/time.Second) { |
| 2939 | + return event.Event{}, authError("token is expired") |
| 2940 | + } |
| 2941 | + if strict && requiresBlobScope(action) && len(event.TagValues(e, "x")) == 0 { |
| 2942 | + return event.Event{}, authError("token must include an x tag") |
| 2943 | + } |
| 2944 | + if strict && blobHash != "" && !contains(event.TagValues(e, "x"), blobHash) { |
| 2945 | + return event.Event{}, authError("token x tag does not name this blob") |
| 2946 | + } |
| 2947 | + return e, nil |
| 2948 | +} |
| 2949 | + |
| 2950 | +func requiresBlobScope(action string) bool { |
| 2951 | + switch action { |
| 2952 | + case "upload", "delete", "mirror", "media": |
| 2953 | + return true |
| 2954 | + default: |
| 2955 | + return false |
| 2956 | + } |
| 2957 | +} |
| 2958 | + |
| 2959 | +func blossomServerScope(e event.Event, server string) bool { |
| 2960 | + for _, value := range event.TagValues(e, "server") { |
| 2961 | + if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(server)) { |
| 2962 | + return true |
| 2963 | + } |
| 2964 | + } |
| 2965 | + return len(event.TagValues(e, "server")) == 0 |
| 2966 | +} |
| 2967 | + |
| 2968 | +func (v *Validator) WhoAsks(header, rawURL, method, body, action, blobHash string) ([]string, error) { |
| 2969 | + if strings.TrimSpace(header) == "" { |
| 2970 | + return nil, nil |
| 2971 | + } |
| 2972 | + e, err := decodeToken(header, "authorization") |
| 2973 | + if err != nil { |
| 2974 | + return nil, err |
| 2975 | + } |
| 2976 | + if e.Kind == 24242 { |
| 2977 | + if action == "" { |
| 2978 | + return nil, authError("this door takes a NIP-98 signature") |
| 2979 | + } |
| 2980 | + e, err = v.VerifyBlossom(header, action) |
| 2981 | + if err != nil { |
| 2982 | + return nil, err |
| 2983 | + } |
| 2984 | + xs := event.TagValues(e, "x") |
| 2985 | + if blobHash != "" && len(xs) > 0 && !contains(xs, blobHash) { |
| 2986 | + return nil, authError("token x tag does not name this blob") |
| 2987 | + } |
| 2988 | + return []string{e.PubKey}, nil |
| 2989 | + } |
| 2990 | + e, err = v.VerifyNIP98(header, rawURL, method, body) |
| 2991 | + if err != nil { |
| 2992 | + return nil, err |
| 2993 | + } |
| 2994 | + return []string{e.PubKey}, nil |
| 2995 | +} |
| 2996 | + |
| 2997 | +// ValidateBlobPayload checks the hash binding on a token that was already |
| 2998 | +// authenticated by WhoAsks. It deliberately does not mark the event as seen a |
| 2999 | +// second time: upload authorization and the streamed body validator inspect |
| 3000 | +// the same proof at two different points in the request. |
| 3001 | +func (v *Validator) ValidateBlobPayload(header, actualSHA string) error { |
| 3002 | + e, err := decodeToken(header, "authorization") |
| 3003 | + if err != nil { |
| 3004 | + return err |
| 3005 | + } |
| 3006 | + if err := event.Validate(e); err != nil { |
| 3007 | + return authError(err.Error()) |
| 3008 | + } |
| 3009 | + switch e.Kind { |
| 3010 | + case 24242: |
| 3011 | + if values := event.TagValues(e, "x"); len(values) > 0 && !contains(values, actualSHA) { |
| 3012 | + return authError("token x tag does not name this blob") |
| 3013 | + } |
| 3014 | + case 27235: |
| 3015 | + if event.Tag(e, "payload") != actualSHA { |
| 3016 | + return authError("token payload hash does not match the blob hash") |
| 3017 | + } |
| 3018 | + default: |
| 3019 | + return authError("token must be kind 24242 or 27235") |
| 3020 | + } |
| 3021 | + return nil |
| 3022 | +} |
| 3023 | + |
| 3024 | +// ValidateBlossomPayload rechecks the content hash for a Blossom-authenticated |
| 3025 | +// operation after a streaming body has been materialized. It does not mark the |
| 3026 | +// authorization event as seen a second time. |
| 3027 | +func (v *Validator) ValidateBlossomPayload(header, action, actualSHA string) error { |
| 3028 | + e, err := v.VerifyBlossom(header, action) |
| 3029 | + if err != nil { |
| 3030 | + return err |
| 3031 | + } |
| 3032 | + if values := event.TagValues(e, "x"); len(values) > 0 && !contains(values, actualSHA) { |
| 3033 | + return authError("token x tag does not name this blob") |
| 3034 | + } |
| 3035 | + return nil |
| 3036 | +} |
| 3037 | + |
| 3038 | +type ChallengeManager struct { |
| 3039 | + relayURL string |
| 3040 | + now func() time.Time |
| 3041 | + mu sync.Mutex |
| 3042 | + active map[string]time.Time |
| 3043 | +} |
| 3044 | + |
| 3045 | +func NewChallengeManager(relayURL string, now func() time.Time) *ChallengeManager { |
| 3046 | + if now == nil { |
| 3047 | + now = time.Now |
| 3048 | + } |
| 3049 | + return &ChallengeManager{relayURL: relayURL, now: now, active: make(map[string]time.Time)} |
| 3050 | +} |
| 3051 | + |
| 3052 | +func (m *ChallengeManager) Issue() (string, error) { |
| 3053 | + raw := make([]byte, 32) |
| 3054 | + if _, err := rand.Read(raw); err != nil { |
| 3055 | + return "", fmt.Errorf("create NIP-42 challenge: %w", err) |
| 3056 | + } |
| 3057 | + challenge := hex.EncodeToString(raw) |
| 3058 | + now := m.now() |
| 3059 | + m.mu.Lock() |
| 3060 | + m.prune(now) |
| 3061 | + m.active[challenge] = now.Add(tokenSkew) |
| 3062 | + m.mu.Unlock() |
| 3063 | + return challenge, nil |
| 3064 | +} |
| 3065 | + |
| 3066 | +func (m *ChallengeManager) Verify(e event.Event) error { |
| 3067 | + if err := event.Validate(e); err != nil { |
| 3068 | + return authError(err.Error()) |
| 3069 | + } |
| 3070 | + if e.Kind != 22242 { |
| 3071 | + return authError("AUTH event must be kind 22242") |
| 3072 | + } |
| 3073 | + now := m.now() |
| 3074 | + if outsideSkew(now, e.CreatedAt) { |
| 3075 | + return authError("AUTH event timestamp expired") |
| 3076 | + } |
| 3077 | + if event.Tag(e, "relay") != m.relayURL { |
| 3078 | + return authError("AUTH event names another relay") |
| 3079 | + } |
| 3080 | + challenge := event.Tag(e, "challenge") |
| 3081 | + m.mu.Lock() |
| 3082 | + defer m.mu.Unlock() |
| 3083 | + m.prune(now) |
| 3084 | + if _, ok := m.active[challenge]; !ok { |
| 3085 | + return authError("AUTH challenge is unknown or already used") |
| 3086 | + } |
| 3087 | + delete(m.active, challenge) |
| 3088 | + return nil |
| 3089 | +} |
| 3090 | + |
| 3091 | +func (m *ChallengeManager) prune(now time.Time) { |
| 3092 | + for challenge, expires := range m.active { |
| 3093 | + if !expires.After(now) { |
| 3094 | + delete(m.active, challenge) |
| 3095 | + } |
| 3096 | + } |
| 3097 | +} |
| 3098 | + |
| 3099 | +func (v *Validator) mark(id string, now time.Time) error { |
| 3100 | + v.mu.Lock() |
| 3101 | + defer v.mu.Unlock() |
| 3102 | + for seenID, at := range v.seen { |
| 3103 | + if now.Sub(at) > tokenSkew { |
| 3104 | + delete(v.seen, seenID) |
| 3105 | + } |
| 3106 | + } |
| 3107 | + if _, ok := v.seen[id]; ok { |
| 3108 | + return authError("token replay detected") |
| 3109 | + } |
| 3110 | + v.seen[id] = now |
| 3111 | + return nil |
| 3112 | +} |
| 3113 | + |
| 3114 | +func decodeToken(header, tokenName string) (event.Event, error) { |
| 3115 | + parts := strings.Fields(header) |
| 3116 | + if len(parts) != 2 || !strings.EqualFold(parts[0], "Nostr") { |
| 3117 | + return event.Event{}, ErrMissingAuthorization |
| 3118 | + } |
| 3119 | + raw, err := decodeBase64(parts[1]) |
| 3120 | + if err != nil { |
| 3121 | + return event.Event{}, authError(fmt.Sprintf("malformed %s token", tokenName)) |
| 3122 | + } |
| 3123 | + e, err := event.Parse(raw) |
| 3124 | + if err != nil { |
| 3125 | + return event.Event{}, authError(err.Error()) |
| 3126 | + } |
| 3127 | + return e, nil |
| 3128 | +} |
| 3129 | + |
| 3130 | +func decodeBase64(value string) ([]byte, error) { |
| 3131 | + for _, encoding := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} { |
| 3132 | + if raw, err := encoding.DecodeString(value); err == nil { |
| 3133 | + return raw, nil |
| 3134 | + } |
| 3135 | + } |
| 3136 | + return nil, errors.New("invalid base64") |
| 3137 | +} |
| 3138 | + |
| 3139 | +func sameRequestURL(signed, requested string) error { |
| 3140 | + want, ok := normalizedRequestURL(requested) |
| 3141 | + if !ok { |
| 3142 | + return authError("token was signed for another URL") |
| 3143 | + } |
| 3144 | + got, ok := normalizedRequestURL(signed) |
| 3145 | + if !ok || got != want { |
| 3146 | + return authError("token was signed for another URL") |
| 3147 | + } |
| 3148 | + return nil |
| 3149 | +} |
| 3150 | + |
| 3151 | +func normalizedRequestURL(raw string) (string, bool) { |
| 3152 | + u, err := url.Parse(strings.TrimSpace(raw)) |
| 3153 | + if err != nil || u.Scheme == "" || u.Host == "" || u.User != nil || u.Fragment != "" || u.Opaque != "" { |
| 3154 | + return "", false |
| 3155 | + } |
| 3156 | + path := u.EscapedPath() |
| 3157 | + if path == "" { |
| 3158 | + path = "/" |
| 3159 | + } |
| 3160 | + // URL.Host preserves a possibly meaningful port while hostname matching is |
| 3161 | + // case insensitive. Query strings remain part of the NIP-98 binding. |
| 3162 | + host := strings.ToLower(u.Hostname()) |
| 3163 | + if port := u.Port(); port != "" { |
| 3164 | + host += ":" + port |
| 3165 | + } |
| 3166 | + return strings.ToLower(u.Scheme) + "://" + host + path + "?" + u.RawQuery, true |
| 3167 | +} |
| 3168 | + |
| 3169 | +func outsideSkew(now time.Time, createdAt int64) bool { |
| 3170 | + delta := now.Unix() - createdAt |
| 3171 | + return delta > int64(tokenSkew/time.Second) || delta < -int64(tokenSkew/time.Second) |
| 3172 | +} |
| 3173 | + |
| 3174 | +func hashBytes(value []byte) string { |
| 3175 | + sum := sha256.Sum256(value) |
| 3176 | + return hex.EncodeToString(sum[:]) |
| 3177 | +} |
| 3178 | + |
| 3179 | +func authError(reason string) error { return fmt.Errorf("auth-required: %s", reason) } |
| 3180 | +func contains(values []string, value string) bool { |
| 3181 | + for _, candidate := range values { |
| 3182 | + if candidate == value { |
| 3183 | + return true |
| 3184 | + } |
| 3185 | + } |
| 3186 | + return false |
| 3187 | +} |
| 3188 | diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go |
| 3189 | new file mode 100644 |
| 3190 | index 0000000..908a0f7 |
| 3191 | --- /dev/null |
| 3192 | +++ b/internal/auth/auth_test.go |
| 3193 | @@ -0,0 +1,162 @@ |
| 3194 | +package auth |
| 3195 | + |
| 3196 | +import ( |
| 3197 | + "crypto/sha256" |
| 3198 | + "encoding/base64" |
| 3199 | + "encoding/hex" |
| 3200 | + "strings" |
| 3201 | + "testing" |
| 3202 | + "time" |
| 3203 | + |
| 3204 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 3205 | +) |
| 3206 | + |
| 3207 | +func TestNIP98ValidatesRequestAndRejectsReplay(t *testing.T) { |
| 3208 | + now := time.Unix(1_700_000_000, 0) |
| 3209 | + validator := NewValidator(func() time.Time { return now }) |
| 3210 | + e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api?ignored=1"}, {"method", "POST"}, {"payload", ""}}) |
| 3211 | + // A non-empty request body must carry its exact SHA-256 tag. |
| 3212 | + e = signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api?ignored=1"}, {"method", "POST"}, {"payload", sha256Hex("hello")}}) |
| 3213 | + header := token(t, e) |
| 3214 | + got, err := validator.VerifyNIP98(header, "https://RELAY.example/api?ignored=1", "post", "hello") |
| 3215 | + if err != nil || got.PubKey == "" { |
| 3216 | + t.Fatalf("verify valid token: %v", err) |
| 3217 | + } |
| 3218 | + if _, err := validator.VerifyNIP98(header, "https://relay.example/api?ignored=1", "POST", "hello"); err == nil || !strings.Contains(err.Error(), "replay") { |
| 3219 | + t.Fatalf("expected replay rejection, got %v", err) |
| 3220 | + } |
| 3221 | +} |
| 3222 | + |
| 3223 | +func TestNIP98ExpiryAndPayload(t *testing.T) { |
| 3224 | + now := time.Unix(1_700_000_000, 0) |
| 3225 | + validator := NewValidator(func() time.Time { return now }) |
| 3226 | + e := signedEvent(t, 27235, now.Add(-61*time.Second).Unix(), "", [][]string{{"u", "https://relay.example/api"}, {"method", "POST"}, {"payload", sha256Hex("hello")}}) |
| 3227 | + if _, err := validator.VerifyNIP98(token(t, e), "https://relay.example/api", "POST", "hello"); err == nil || !strings.Contains(err.Error(), "expired") { |
| 3228 | + t.Fatalf("expected expiry, got %v", err) |
| 3229 | + } |
| 3230 | + e = signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api"}, {"method", "POST"}, {"payload", sha256Hex("other")}}) |
| 3231 | + if _, err := validator.VerifyNIP98(token(t, e), "https://relay.example/api", "POST", "hello"); err == nil || !strings.Contains(err.Error(), "payload") { |
| 3232 | + t.Fatalf("expected payload rejection, got %v", err) |
| 3233 | + } |
| 3234 | +} |
| 3235 | + |
| 3236 | +func TestNIP98BindsSchemeQueryAndPath(t *testing.T) { |
| 3237 | + now := time.Unix(1_700_000_000, 0) |
| 3238 | + validator := NewValidator(func() time.Time { return now }) |
| 3239 | + for _, requested := range []string{ |
| 3240 | + "http://relay.example/api?a=1", |
| 3241 | + "https://relay.example/other?a=1", |
| 3242 | + "https://relay.example/api?a=2", |
| 3243 | + "https://relay.example/api?a=1#fragment", |
| 3244 | + "https://user:pass@relay.example/api?a=1", |
| 3245 | + } { |
| 3246 | + e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api?a=1"}, {"method", "GET"}}) |
| 3247 | + if _, err := validator.VerifyNIP98(token(t, e), requested, "GET", ""); err == nil { |
| 3248 | + t.Fatalf("accepted request URL %q", requested) |
| 3249 | + } |
| 3250 | + } |
| 3251 | +} |
| 3252 | + |
| 3253 | +func TestBlossomTokenMayBeReusedAcrossHeadAndPut(t *testing.T) { |
| 3254 | + now := time.Unix(1_700_000_000, 0) |
| 3255 | + validator := NewValidator(func() time.Time { return now }) |
| 3256 | + e := signedEvent(t, 24242, now.Unix(), "upload", [][]string{{"t", "upload"}, {"expiration", "1700000300"}}) |
| 3257 | + header := token(t, e) |
| 3258 | + if _, err := validator.VerifyBlossom(header, "upload"); err != nil { |
| 3259 | + t.Fatalf("verify first Blossom request: %v", err) |
| 3260 | + } |
| 3261 | + if _, err := validator.VerifyBlossom(header, "upload"); err != nil { |
| 3262 | + t.Fatalf("verify reused Blossom request: %v", err) |
| 3263 | + } |
| 3264 | +} |
| 3265 | + |
| 3266 | +func TestValidateBlobPayloadBindsBlossomAndNIP98Hashes(t *testing.T) { |
| 3267 | + now := time.Unix(1_700_000_000, 0) |
| 3268 | + validator := NewValidator(func() time.Time { return now }) |
| 3269 | + hash := sha256Hex("blob") |
| 3270 | + blossom := signedEvent(t, 24242, now.Unix(), "upload", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"x", hash}}) |
| 3271 | + if err := validator.ValidateBlobPayload(token(t, blossom), hash); err != nil { |
| 3272 | + t.Fatalf("valid Blossom hash: %v", err) |
| 3273 | + } |
| 3274 | + if err := validator.ValidateBlobPayload(token(t, blossom), sha256Hex("other")); err == nil || !strings.Contains(err.Error(), "blob") { |
| 3275 | + t.Fatalf("expected Blossom hash rejection, got %v", err) |
| 3276 | + } |
| 3277 | + nip98 := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/upload"}, {"method", "PUT"}, {"payload", hash}}) |
| 3278 | + if err := validator.ValidateBlobPayload(token(t, nip98), hash); err != nil { |
| 3279 | + t.Fatalf("valid NIP98 hash: %v", err) |
| 3280 | + } |
| 3281 | + if err := validator.ValidateBlobPayload(token(t, nip98), sha256Hex("other")); err == nil || !strings.Contains(err.Error(), "payload") { |
| 3282 | + t.Fatalf("expected NIP98 hash rejection, got %v", err) |
| 3283 | + } |
| 3284 | +} |
| 3285 | + |
| 3286 | +func TestValidateBlossomPayloadBindsMirrorHash(t *testing.T) { |
| 3287 | + now := time.Unix(1_700_000_000, 0) |
| 3288 | + validator := NewValidator(func() time.Time { return now }) |
| 3289 | + hash := sha256Hex("blob") |
| 3290 | + tokenEvent := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "mirror"}, {"expiration", "1700000300"}, {"x", hash}}) |
| 3291 | + header := token(t, tokenEvent) |
| 3292 | + if err := validator.ValidateBlossomPayload(header, "mirror", hash); err != nil { |
| 3293 | + t.Fatalf("valid mirror hash: %v", err) |
| 3294 | + } |
| 3295 | + if err := validator.ValidateBlossomPayload(header, "mirror", sha256Hex("other")); err == nil { |
| 3296 | + t.Fatal("accepted mismatched mirror hash") |
| 3297 | + } |
| 3298 | +} |
| 3299 | + |
| 3300 | +func TestVerifyBlossomRequestRequiresScopeAndServer(t *testing.T) { |
| 3301 | + now := time.Unix(1_700_000_000, 0) |
| 3302 | + validator := NewValidator(func() time.Time { return now }) |
| 3303 | + hash := sha256Hex("blob") |
| 3304 | + valid := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "relay.example"}, {"x", hash}}) |
| 3305 | + if _, err := validator.VerifyBlossomRequest(token(t, valid), "upload", "relay.example", hash); err != nil { |
| 3306 | + t.Fatalf("valid scoped Blossom token: %v", err) |
| 3307 | + } |
| 3308 | + wrongServer := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "other.example"}, {"x", hash}}) |
| 3309 | + if _, err := validator.VerifyBlossomRequest(token(t, wrongServer), "upload", "relay.example", hash); err == nil { |
| 3310 | + t.Fatal("accepted token scoped to another server") |
| 3311 | + } |
| 3312 | + missingHash := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "relay.example"}}) |
| 3313 | + if _, err := validator.VerifyBlossomRequest(token(t, missingHash), "upload", "relay.example", ""); err == nil { |
| 3314 | + t.Fatal("accepted upload token without x scope") |
| 3315 | + } |
| 3316 | +} |
| 3317 | + |
| 3318 | +func TestChallengeManagerBindsRelayAndConsumesChallenge(t *testing.T) { |
| 3319 | + now := time.Unix(1_700_000_000, 0) |
| 3320 | + m := NewChallengeManager("wss://relay.example/tenant", func() time.Time { return now }) |
| 3321 | + challenge, err := m.Issue() |
| 3322 | + if err != nil { |
| 3323 | + t.Fatalf("issue challenge: %v", err) |
| 3324 | + } |
| 3325 | + e := signedEvent(t, 22242, now.Unix(), "", [][]string{{"relay", "wss://relay.example/tenant"}, {"challenge", challenge}}) |
| 3326 | + if err := m.Verify(e); err != nil { |
| 3327 | + t.Fatalf("verify challenge: %v", err) |
| 3328 | + } |
| 3329 | + if err := m.Verify(e); err == nil || !strings.Contains(err.Error(), "challenge") { |
| 3330 | + t.Fatalf("expected one-time challenge rejection, got %v", err) |
| 3331 | + } |
| 3332 | +} |
| 3333 | + |
| 3334 | +func signedEvent(t *testing.T, kind int, createdAt int64, content string, tags [][]string) event.Event { |
| 3335 | + t.Helper() |
| 3336 | + e := event.Event{CreatedAt: createdAt, Kind: kind, Content: content, Tags: tags} |
| 3337 | + if err := event.Sign(&e, strings.Repeat("0", 63)+"1"); err != nil { |
| 3338 | + t.Fatalf("sign event: %v", err) |
| 3339 | + } |
| 3340 | + return e |
| 3341 | +} |
| 3342 | + |
| 3343 | +func token(t *testing.T, e event.Event) string { |
| 3344 | + t.Helper() |
| 3345 | + raw, err := event.Canonical(e) |
| 3346 | + if err != nil { |
| 3347 | + t.Fatalf("encode event: %v", err) |
| 3348 | + } |
| 3349 | + return "Nostr " + base64.StdEncoding.EncodeToString(raw) |
| 3350 | +} |
| 3351 | + |
| 3352 | +func sha256Hex(value string) string { |
| 3353 | + sum := sha256.Sum256([]byte(value)) |
| 3354 | + return hex.EncodeToString(sum[:]) |
| 3355 | +} |
| 3356 | diff --git a/internal/blob/blob.go b/internal/blob/blob.go |
| 3357 | new file mode 100644 |
| 3358 | index 0000000..e2b1a66 |
| 3359 | --- /dev/null |
| 3360 | +++ b/internal/blob/blob.go |
| 3361 | @@ -0,0 +1,1195 @@ |
| 3362 | +// Package blob implements the Blossom and NIP-96 file doors for a local relay. |
| 3363 | +package blob |
| 3364 | + |
| 3365 | +import ( |
| 3366 | + "context" |
| 3367 | + "crypto/sha256" |
| 3368 | + "database/sql" |
| 3369 | + "encoding/hex" |
| 3370 | + "encoding/json" |
| 3371 | + "errors" |
| 3372 | + "fmt" |
| 3373 | + "io" |
| 3374 | + "net" |
| 3375 | + "net/http" |
| 3376 | + "net/url" |
| 3377 | + "os" |
| 3378 | + "path/filepath" |
| 3379 | + "regexp" |
| 3380 | + "sort" |
| 3381 | + "strconv" |
| 3382 | + "strings" |
| 3383 | + "time" |
| 3384 | + |
| 3385 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 3386 | + "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 3387 | +) |
| 3388 | + |
| 3389 | +type Action string |
| 3390 | + |
| 3391 | +const ( |
| 3392 | + ActionUpload Action = "upload" |
| 3393 | + ActionGet Action = "get" |
| 3394 | + ActionDelete Action = "delete" |
| 3395 | + ActionList Action = "list" |
| 3396 | + ActionMirror Action = "mirror" |
| 3397 | + ActionReport Action = "report" |
| 3398 | +) |
| 3399 | + |
| 3400 | +type Authorize func(*http.Request, Action) (string, error) |
| 3401 | +type CanRead func(context.Context, string, []string) bool |
| 3402 | +type ResolveIP func(context.Context, string) ([]net.IP, error) |
| 3403 | +type ReportRecorder func(context.Context, *sql.Tx, string, string, string, string, string) error |
| 3404 | + |
| 3405 | +type Config struct { |
| 3406 | + Root string |
| 3407 | + PublicURL string |
| 3408 | + Store *storage.Store |
| 3409 | + Authorize Authorize |
| 3410 | + CanRead CanRead |
| 3411 | + HTTPClient *http.Client |
| 3412 | + ResolveIP ResolveIP |
| 3413 | + IsOwner func(string) bool |
| 3414 | + ValidateUpload func(*http.Request, string) error |
| 3415 | + // ResolveServers returns the ordered BUD-03 servers for an authorized |
| 3416 | + // caller. It is used only for Blossom URI mirror fallback. |
| 3417 | + ResolveServers func(context.Context, string) ([]string, error) |
| 3418 | + // RecordReport joins native blob reporting to the host moderation ledger. |
| 3419 | + // It runs inside the blob report transaction. |
| 3420 | + RecordReport ReportRecorder |
| 3421 | +} |
| 3422 | + |
| 3423 | +type Blob struct { |
| 3424 | + SHA256 string `json:"sha256"` |
| 3425 | + Size int64 `json:"size"` |
| 3426 | + Type string `json:"type"` |
| 3427 | + Uploader string `json:"uploader"` |
| 3428 | + Uploaded int64 `json:"uploaded"` |
| 3429 | +} |
| 3430 | + |
| 3431 | +type PutOptions struct { |
| 3432 | + Reader io.Reader |
| 3433 | + Type string |
| 3434 | + Uploader string |
| 3435 | + Hash string |
| 3436 | +} |
| 3437 | + |
| 3438 | +type Service struct { |
| 3439 | + config Config |
| 3440 | + root string |
| 3441 | + store *storage.Store |
| 3442 | + resolve ResolveIP |
| 3443 | +} |
| 3444 | + |
| 3445 | +var ErrBlocked = errors.New("blocked: this blob was removed by a moderator") |
| 3446 | +var ErrHashMismatch = errors.New("conflict: mirrored blob hash does not match the requested hash") |
| 3447 | + |
| 3448 | +var shaPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) |
| 3449 | +var blobPathPattern = regexp.MustCompile(`^/([0-9a-f]{64})(?:\.[a-z0-9]{1,8})?$`) |
| 3450 | + |
| 3451 | +// HandlesPath keeps the daemon mount in sync with every supported file door. |
| 3452 | +func HandlesPath(path string) bool { |
| 3453 | + return path == "/upload" || path == "/mirror" || path == "/report" || |
| 3454 | + path == "/nip96" || path == "/.well-known/nostr/nip96.json" || |
| 3455 | + strings.HasPrefix(path, "/nip96/") || strings.HasPrefix(path, "/list/") || |
| 3456 | + blobPathPattern.MatchString(path) |
| 3457 | +} |
| 3458 | + |
| 3459 | +var extensions = map[string]string{ |
| 3460 | + "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/avif": "avif", "image/svg+xml": "svg", |
| 3461 | + "video/mp4": "mp4", "video/webm": "webm", "video/quicktime": "mov", "audio/mpeg": "mp3", "audio/ogg": "ogg", "audio/wav": "wav", "audio/mp4": "m4a", |
| 3462 | + "application/pdf": "pdf", "application/json": "json", "text/plain": "txt", "text/markdown": "md", |
| 3463 | +} |
| 3464 | + |
| 3465 | +func New(ctx context.Context, config Config) (*Service, error) { |
| 3466 | + if config.Root == "" || config.Store == nil { |
| 3467 | + return nil, errors.New("blob root and store are required") |
| 3468 | + } |
| 3469 | + if err := os.MkdirAll(filepath.Join(config.Root, "blobs"), 0o700); err != nil { |
| 3470 | + return nil, fmt.Errorf("create blob root: %w", err) |
| 3471 | + } |
| 3472 | + if _, err := config.Store.DB().ExecContext(ctx, `CREATE TABLE IF NOT EXISTS blobs ( |
| 3473 | + sha256 TEXT PRIMARY KEY, size INTEGER NOT NULL, type TEXT NOT NULL, |
| 3474 | + uploader TEXT NOT NULL, uploaded INTEGER NOT NULL |
| 3475 | + ); CREATE TABLE IF NOT EXISTS blob_claims ( |
| 3476 | + sha256 TEXT NOT NULL, uploader TEXT NOT NULL, claimed_at INTEGER NOT NULL, |
| 3477 | + PRIMARY KEY (sha256, uploader), FOREIGN KEY (sha256) REFERENCES blobs(sha256) ON DELETE CASCADE |
| 3478 | + ); CREATE INDEX IF NOT EXISTS blobs_uploader ON blobs(uploader, uploaded DESC); |
| 3479 | + CREATE INDEX IF NOT EXISTS blob_claims_uploader ON blob_claims(uploader, claimed_at DESC, sha256 DESC); |
| 3480 | + CREATE TABLE IF NOT EXISTS blob_reports ( |
| 3481 | + id TEXT PRIMARY KEY, reporter TEXT NOT NULL, target_pubkey TEXT NOT NULL DEFAULT '', |
| 3482 | + target_blob TEXT NOT NULL, type TEXT NOT NULL, content TEXT NOT NULL, at INTEGER NOT NULL, |
| 3483 | + status TEXT NOT NULL DEFAULT 'open', resolved_by TEXT NOT NULL DEFAULT '', |
| 3484 | + resolved_at INTEGER NOT NULL DEFAULT 0, action TEXT NOT NULL DEFAULT '', |
| 3485 | + UNIQUE(reporter,target_blob)); |
| 3486 | + CREATE TRIGGER IF NOT EXISTS blob_claim_on_insert AFTER INSERT ON blobs |
| 3487 | + WHEN NEW.uploader != '' BEGIN INSERT OR IGNORE INTO blob_claims(sha256,uploader,claimed_at) VALUES(NEW.sha256,NEW.uploader,NEW.uploaded); END; |
| 3488 | + CREATE TABLE IF NOT EXISTS blob_blocks (sha256 TEXT PRIMARY KEY, reason TEXT NOT NULL DEFAULT '', blocked_at INTEGER NOT NULL DEFAULT 0); |
| 3489 | + CREATE TABLE IF NOT EXISTS blob_tombstones (sha256 TEXT PRIMARY KEY, deleted_at INTEGER NOT NULL); |
| 3490 | + INSERT OR IGNORE INTO blob_claims(sha256,uploader,claimed_at) |
| 3491 | + SELECT sha256,uploader,uploaded FROM blobs WHERE uploader != ''`); err != nil { |
| 3492 | + return nil, fmt.Errorf("initialize blob metadata: %w", err) |
| 3493 | + } |
| 3494 | + if err := reconcile(ctx, config.Store, filepath.Join(config.Root, "blobs")); err != nil { |
| 3495 | + return nil, err |
| 3496 | + } |
| 3497 | + resolve := config.ResolveIP |
| 3498 | + if resolve == nil { |
| 3499 | + resolve = func(ctx context.Context, host string) ([]net.IP, error) { |
| 3500 | + return net.DefaultResolver.LookupIP(ctx, "ip", host) |
| 3501 | + } |
| 3502 | + } |
| 3503 | + return &Service{config: config, root: filepath.Join(config.Root, "blobs"), store: config.Store, resolve: resolve}, nil |
| 3504 | +} |
| 3505 | + |
| 3506 | +func (s *Service) Handler() http.HandlerFunc { return s.serveHTTP } |
| 3507 | + |
| 3508 | +// Put stores bytes using their SHA-256 digest as the durable filename. |
| 3509 | +func (s *Service) Put(ctx context.Context, options PutOptions) (Blob, error) { |
| 3510 | + if options.Reader == nil { |
| 3511 | + return Blob{}, errors.New("blob reader is required") |
| 3512 | + } |
| 3513 | + entry, _, err := s.put(ctx, options.Reader, options.Type, options.Uploader, options.Hash) |
| 3514 | + return entry, err |
| 3515 | +} |
| 3516 | + |
| 3517 | +// Get opens a verified metadata-backed blob for streaming to another service. |
| 3518 | +func (s *Service) Get(ctx context.Context, sha string) (Blob, io.ReadCloser, error) { |
| 3519 | + if !shaPattern.MatchString(sha) { |
| 3520 | + return Blob{}, nil, errors.New("invalid: bad blob sha256") |
| 3521 | + } |
| 3522 | + entry, err := s.lookup(ctx, sha) |
| 3523 | + if err != nil { |
| 3524 | + return Blob{}, nil, err |
| 3525 | + } |
| 3526 | + if s.blocked(ctx, sha) { |
| 3527 | + return Blob{}, nil, ErrBlocked |
| 3528 | + } |
| 3529 | + file, err := os.Open(filepath.Join(s.root, sha)) |
| 3530 | + if err != nil { |
| 3531 | + return Blob{}, nil, err |
| 3532 | + } |
| 3533 | + return entry, file, nil |
| 3534 | +} |
| 3535 | + |
| 3536 | +// ListMetadata returns all blobs uploaded by the supplied pubkey. |
| 3537 | +func (s *Service) ListMetadata(ctx context.Context, uploader string) ([]Blob, error) { |
| 3538 | + rows, err := s.store.DB().QueryContext(ctx, `SELECT b.sha256,b.size,b.type,c.uploader,b.uploaded |
| 3539 | + FROM blobs b JOIN blob_claims c ON c.sha256=b.sha256 WHERE c.uploader=? |
| 3540 | + ORDER BY b.uploaded DESC,b.sha256 DESC`, uploader) |
| 3541 | + if err != nil { |
| 3542 | + return nil, fmt.Errorf("list blob metadata: %w", err) |
| 3543 | + } |
| 3544 | + defer rows.Close() |
| 3545 | + entries := make([]Blob, 0) |
| 3546 | + for rows.Next() { |
| 3547 | + var entry Blob |
| 3548 | + if err := rows.Scan(&entry.SHA256, &entry.Size, &entry.Type, &entry.Uploader, &entry.Uploaded); err != nil { |
| 3549 | + return nil, fmt.Errorf("scan blob metadata: %w", err) |
| 3550 | + } |
| 3551 | + entries = append(entries, entry) |
| 3552 | + } |
| 3553 | + return entries, rows.Err() |
| 3554 | +} |
| 3555 | + |
| 3556 | +// ListPage returns a BUD-12 cursor page. The cursor is the last blob hash |
| 3557 | +// returned by the previous page and is intentionally opaque to callers. |
| 3558 | +func (s *Service) ListPage(ctx context.Context, uploader string, limit int, cursor string) ([]Blob, string, error) { |
| 3559 | + if limit < 1 { |
| 3560 | + limit = 20 |
| 3561 | + } |
| 3562 | + if limit > 100 { |
| 3563 | + limit = 100 |
| 3564 | + } |
| 3565 | + query := `SELECT b.sha256,b.size,b.type,c.uploader,b.uploaded FROM blobs b |
| 3566 | + JOIN blob_claims c ON c.sha256=b.sha256 WHERE c.uploader=?` |
| 3567 | + args := []any{uploader} |
| 3568 | + if cursor != "" { |
| 3569 | + var uploaded int64 |
| 3570 | + if err := s.store.DB().QueryRowContext(ctx, `SELECT b.uploaded FROM blobs b |
| 3571 | + JOIN blob_claims c ON c.sha256=b.sha256 WHERE b.sha256=? AND c.uploader=?`, cursor, uploader).Scan(&uploaded); err != nil { |
| 3572 | + return nil, "", fmt.Errorf("resolve blob cursor: %w", err) |
| 3573 | + } |
| 3574 | + query += " AND (b.uploaded < ? OR (b.uploaded = ? AND b.sha256 < ?))" |
| 3575 | + args = append(args, uploaded, uploaded, cursor) |
| 3576 | + } |
| 3577 | + query += " ORDER BY b.uploaded DESC,b.sha256 DESC LIMIT ?" |
| 3578 | + args = append(args, limit+1) |
| 3579 | + rows, err := s.store.DB().QueryContext(ctx, query, args...) |
| 3580 | + if err != nil { |
| 3581 | + return nil, "", fmt.Errorf("list blob page: %w", err) |
| 3582 | + } |
| 3583 | + defer rows.Close() |
| 3584 | + entries := make([]Blob, 0, limit) |
| 3585 | + for rows.Next() { |
| 3586 | + var entry Blob |
| 3587 | + if err := rows.Scan(&entry.SHA256, &entry.Size, &entry.Type, &entry.Uploader, &entry.Uploaded); err != nil { |
| 3588 | + return nil, "", fmt.Errorf("scan blob page: %w", err) |
| 3589 | + } |
| 3590 | + entries = append(entries, entry) |
| 3591 | + } |
| 3592 | + if err := rows.Err(); err != nil { |
| 3593 | + return nil, "", fmt.Errorf("read blob page: %w", err) |
| 3594 | + } |
| 3595 | + next := "" |
| 3596 | + if len(entries) > limit { |
| 3597 | + next = entries[limit-1].SHA256 |
| 3598 | + entries = entries[:limit] |
| 3599 | + } |
| 3600 | + return entries, next, nil |
| 3601 | +} |
| 3602 | + |
| 3603 | +// Delete removes a blob's metadata and content. Authorization is the caller's responsibility. |
| 3604 | +func (s *Service) Delete(ctx context.Context, sha string) error { |
| 3605 | + if !shaPattern.MatchString(sha) { |
| 3606 | + return errors.New("invalid: bad blob sha256") |
| 3607 | + } |
| 3608 | + if err := s.store.WithTx(ctx, func(tx *sql.Tx) error { |
| 3609 | + if _, err := tx.ExecContext(ctx, "INSERT INTO blob_tombstones(sha256,deleted_at) VALUES(?,?) ON CONFLICT(sha256) DO UPDATE SET deleted_at=excluded.deleted_at", sha, time.Now().UTC().Unix()); err != nil { |
| 3610 | + return fmt.Errorf("record blob deletion: %w", err) |
| 3611 | + } |
| 3612 | + if _, err := tx.ExecContext(ctx, "DELETE FROM blobs WHERE sha256=?", sha); err != nil { |
| 3613 | + return fmt.Errorf("delete blob metadata: %w", err) |
| 3614 | + } |
| 3615 | + return nil |
| 3616 | + }); err != nil { |
| 3617 | + return err |
| 3618 | + } |
| 3619 | + if err := os.Remove(filepath.Join(s.root, sha)); err != nil && !errors.Is(err, os.ErrNotExist) { |
| 3620 | + return fmt.Errorf("delete blob content: %w", err) |
| 3621 | + } |
| 3622 | + return nil |
| 3623 | +} |
| 3624 | + |
| 3625 | +// DeleteForUploader removes one owner's claim. The physical object remains |
| 3626 | +// while another owner still references it, which makes deduplicated uploads |
| 3627 | +// safe to manage independently. |
| 3628 | +func (s *Service) DeleteForUploader(ctx context.Context, sha, uploader string) error { |
| 3629 | + if !shaPattern.MatchString(sha) { |
| 3630 | + return errors.New("invalid: bad blob sha256") |
| 3631 | + } |
| 3632 | + removed := false |
| 3633 | + if err := s.store.WithTx(ctx, func(tx *sql.Tx) error { |
| 3634 | + result, err := tx.ExecContext(ctx, "DELETE FROM blob_claims WHERE sha256=? AND uploader=?", sha, uploader) |
| 3635 | + if err != nil { |
| 3636 | + return fmt.Errorf("delete blob claim: %w", err) |
| 3637 | + } |
| 3638 | + changed, err := result.RowsAffected() |
| 3639 | + if err != nil { |
| 3640 | + return fmt.Errorf("check blob claim deletion: %w", err) |
| 3641 | + } |
| 3642 | + if changed == 0 { |
| 3643 | + return sql.ErrNoRows |
| 3644 | + } |
| 3645 | + var remaining int |
| 3646 | + if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM blob_claims WHERE sha256=?", sha).Scan(&remaining); err != nil { |
| 3647 | + return fmt.Errorf("count blob claims: %w", err) |
| 3648 | + } |
| 3649 | + if remaining == 0 { |
| 3650 | + removed = true |
| 3651 | + if _, err := tx.ExecContext(ctx, "INSERT INTO blob_tombstones(sha256,deleted_at) VALUES(?,?) ON CONFLICT(sha256) DO UPDATE SET deleted_at=excluded.deleted_at", sha, time.Now().UTC().Unix()); err != nil { |
| 3652 | + return fmt.Errorf("record blob deletion: %w", err) |
| 3653 | + } |
| 3654 | + if _, err := tx.ExecContext(ctx, "DELETE FROM blobs WHERE sha256=?", sha); err != nil { |
| 3655 | + return fmt.Errorf("delete blob metadata: %w", err) |
| 3656 | + } |
| 3657 | + } |
| 3658 | + return nil |
| 3659 | + }); err != nil { |
| 3660 | + return err |
| 3661 | + } |
| 3662 | + if removed { |
| 3663 | + if err := os.Remove(filepath.Join(s.root, sha)); err != nil && !errors.Is(err, os.ErrNotExist) { |
| 3664 | + return fmt.Errorf("delete blob content: %w", err) |
| 3665 | + } |
| 3666 | + } |
| 3667 | + return nil |
| 3668 | +} |
| 3669 | + |
| 3670 | +// Descriptor returns the Blossom descriptor and NIP-94 tags for a blob. |
| 3671 | +func (s *Service) Descriptor(baseURL string, entry Blob) map[string]any { |
| 3672 | + return map[string]any{ |
| 3673 | + "url": baseURL + "/" + entry.SHA256 + extensionSuffix(entry.Type), |
| 3674 | + "sha256": entry.SHA256, |
| 3675 | + "size": entry.Size, |
| 3676 | + "type": entry.Type, |
| 3677 | + "uploaded": entry.Uploaded, |
| 3678 | + "nip94": [][]string{{"url", baseURL + "/" + entry.SHA256 + extensionSuffix(entry.Type)}, {"m", entry.Type}, {"x", entry.SHA256}, {"ox", entry.SHA256}, {"size", strconv.FormatInt(entry.Size, 10)}}, |
| 3679 | + } |
| 3680 | +} |
| 3681 | + |
| 3682 | +func (s *Service) serveHTTP(w http.ResponseWriter, r *http.Request) { |
| 3683 | + w.Header().Set("access-control-allow-origin", "*") |
| 3684 | + w.Header().Set("access-control-allow-headers", "authorization, content-type, x-sha-256, x-content-length") |
| 3685 | + w.Header().Set("access-control-allow-methods", "GET, HEAD, PUT, POST, DELETE, OPTIONS") |
| 3686 | + if r.Method == http.MethodOptions { |
| 3687 | + w.WriteHeader(http.StatusNoContent) |
| 3688 | + return |
| 3689 | + } |
| 3690 | + if r.URL.Path == "/.well-known/nostr/nip96.json" { |
| 3691 | + s.writeJSON(w, http.StatusOK, s.nip96Info(r)) |
| 3692 | + return |
| 3693 | + } |
| 3694 | + if r.URL.Path == "/upload" && r.Method == http.MethodHead { |
| 3695 | + s.uploadHead(w, r) |
| 3696 | + return |
| 3697 | + } |
| 3698 | + if r.URL.Path == "/upload" && r.Method == http.MethodPut { |
| 3699 | + s.upload(w, r) |
| 3700 | + return |
| 3701 | + } |
| 3702 | + if r.URL.Path == "/mirror" && r.Method == http.MethodPut { |
| 3703 | + s.mirror(w, r) |
| 3704 | + return |
| 3705 | + } |
| 3706 | + if r.URL.Path == "/report" && r.Method == http.MethodPut { |
| 3707 | + s.report(w, r) |
| 3708 | + return |
| 3709 | + } |
| 3710 | + if strings.HasPrefix(r.URL.Path, "/list/") && r.Method == http.MethodGet { |
| 3711 | + s.list(w, r, strings.TrimPrefix(r.URL.Path, "/list/")) |
| 3712 | + return |
| 3713 | + } |
| 3714 | + if r.URL.Path == "/nip96" { |
| 3715 | + s.nip96(w, r) |
| 3716 | + return |
| 3717 | + } |
| 3718 | + s.blob(w, r) |
| 3719 | +} |
| 3720 | + |
| 3721 | +func (s *Service) upload(w http.ResponseWriter, r *http.Request) { |
| 3722 | + pubkey, err := s.authorize(r, ActionUpload) |
| 3723 | + if err != nil { |
| 3724 | + s.fail(w, http.StatusUnauthorized, err) |
| 3725 | + return |
| 3726 | + } |
| 3727 | + claimed := strings.ToLower(strings.TrimSpace(r.Header.Get("x-sha-256"))) |
| 3728 | + if claimed != "" && !shaPattern.MatchString(claimed) { |
| 3729 | + s.fail(w, http.StatusBadRequest, errors.New("invalid: X-SHA-256 must be lowercase hex")) |
| 3730 | + return |
| 3731 | + } |
| 3732 | + blob, created, err := s.putValidated(r.Context(), r.Body, contentType(r.Header.Get("content-type")), pubkey, claimed, func(actual string) error { |
| 3733 | + if s.config.ValidateUpload != nil { |
| 3734 | + return s.config.ValidateUpload(r, actual) |
| 3735 | + } |
| 3736 | + return nil |
| 3737 | + }) |
| 3738 | + if err != nil { |
| 3739 | + s.fail(w, statusFor(err), err) |
| 3740 | + return |
| 3741 | + } |
| 3742 | + _ = created |
| 3743 | + s.writeJSON(w, http.StatusOK, s.descriptor(r, blob)) |
| 3744 | +} |
| 3745 | + |
| 3746 | +func (s *Service) uploadHead(w http.ResponseWriter, r *http.Request) { |
| 3747 | + sha := strings.ToLower(strings.TrimSpace(r.Header.Get("x-sha-256"))) |
| 3748 | + if !shaPattern.MatchString(sha) { |
| 3749 | + s.fail(w, http.StatusBadRequest, errors.New("invalid: X-SHA-256 must be lowercase hex")) |
| 3750 | + return |
| 3751 | + } |
| 3752 | + length, err := strconv.ParseInt(strings.TrimSpace(r.Header.Get("x-content-length")), 10, 64) |
| 3753 | + if err != nil || length <= 0 { |
| 3754 | + s.fail(w, http.StatusBadRequest, errors.New("invalid: X-Content-Length must be a positive integer")) |
| 3755 | + return |
| 3756 | + } |
| 3757 | + if _, err := s.authorize(r, ActionUpload); err != nil { |
| 3758 | + s.fail(w, http.StatusUnauthorized, err) |
| 3759 | + return |
| 3760 | + } |
| 3761 | + typ := r.Header.Get("x-content-type") |
| 3762 | + if typ == "" { |
| 3763 | + typ = r.Header.Get("content-type") |
| 3764 | + } |
| 3765 | + if strings.TrimSpace(typ) == "" { |
| 3766 | + s.fail(w, http.StatusBadRequest, errors.New("invalid: X-Content-Type is required")) |
| 3767 | + return |
| 3768 | + } |
| 3769 | + if existing, err := s.lookup(r.Context(), sha); err == nil && existing.SHA256 != "" { |
| 3770 | + w.WriteHeader(http.StatusOK) |
| 3771 | + return |
| 3772 | + } |
| 3773 | + w.WriteHeader(http.StatusOK) |
| 3774 | +} |
| 3775 | + |
| 3776 | +func (s *Service) blob(w http.ResponseWriter, r *http.Request) { |
| 3777 | + path := r.URL.Path |
| 3778 | + if strings.HasPrefix(path, "/nip96/") { |
| 3779 | + path = strings.TrimPrefix(path, "/nip96") |
| 3780 | + } |
| 3781 | + match := blobPathPattern.FindStringSubmatch(path) |
| 3782 | + if match == nil { |
| 3783 | + s.fail(w, http.StatusNotFound, errors.New("not found")) |
| 3784 | + return |
| 3785 | + } |
| 3786 | + sha := match[1] |
| 3787 | + if r.Method == http.MethodDelete { |
| 3788 | + s.delete(w, r, sha) |
| 3789 | + return |
| 3790 | + } |
| 3791 | + if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| 3792 | + s.fail(w, http.StatusMethodNotAllowed, errors.New("method not allowed")) |
| 3793 | + return |
| 3794 | + } |
| 3795 | + if s.config.CanRead != nil { |
| 3796 | + pubkey, err := s.authorize(r, ActionGet) |
| 3797 | + if err != nil { |
| 3798 | + s.fail(w, http.StatusUnauthorized, err) |
| 3799 | + return |
| 3800 | + } |
| 3801 | + if !s.config.CanRead(r.Context(), sha, []string{pubkey}) { |
| 3802 | + s.fail(w, http.StatusForbidden, errors.New("restricted: read access denied")) |
| 3803 | + return |
| 3804 | + } |
| 3805 | + } |
| 3806 | + entry, err := s.lookup(r.Context(), sha) |
| 3807 | + if errors.Is(err, sql.ErrNoRows) { |
| 3808 | + s.fail(w, http.StatusNotFound, errors.New("not found")) |
| 3809 | + return |
| 3810 | + } |
| 3811 | + if err != nil { |
| 3812 | + s.fail(w, http.StatusInternalServerError, err) |
| 3813 | + return |
| 3814 | + } |
| 3815 | + if s.blocked(r.Context(), sha) { |
| 3816 | + s.fail(w, http.StatusForbidden, ErrBlocked) |
| 3817 | + return |
| 3818 | + } |
| 3819 | + file, err := os.Open(filepath.Join(s.root, sha)) |
| 3820 | + if err != nil { |
| 3821 | + s.fail(w, http.StatusNotFound, errors.New("not found")) |
| 3822 | + return |
| 3823 | + } |
| 3824 | + defer file.Close() |
| 3825 | + w.Header().Set("etag", `"`+sha+`"`) |
| 3826 | + w.Header().Set("Content-Type", entry.Type) |
| 3827 | + w.Header().Set("x-content-type-options", "nosniff") |
| 3828 | + w.Header().Set("accept-ranges", "bytes") |
| 3829 | + w.Header().Set("content-security-policy", "default-src 'none'") |
| 3830 | + if s.config.CanRead == nil { |
| 3831 | + w.Header().Set("cache-control", "public, max-age=31536000, immutable") |
| 3832 | + } else { |
| 3833 | + w.Header().Set("cache-control", "private, no-store") |
| 3834 | + } |
| 3835 | + http.ServeContent(w, r, sha, time.Unix(entry.Uploaded, 0), file) |
| 3836 | +} |
| 3837 | + |
| 3838 | +func (s *Service) delete(w http.ResponseWriter, r *http.Request, sha string) { |
| 3839 | + pubkey, err := s.authorize(r, ActionDelete) |
| 3840 | + if err != nil { |
| 3841 | + s.fail(w, http.StatusUnauthorized, err) |
| 3842 | + return |
| 3843 | + } |
| 3844 | + _, err = s.lookup(r.Context(), sha) |
| 3845 | + if errors.Is(err, sql.ErrNoRows) { |
| 3846 | + s.fail(w, http.StatusNotFound, errors.New("not found")) |
| 3847 | + return |
| 3848 | + } |
| 3849 | + if err != nil { |
| 3850 | + s.fail(w, http.StatusInternalServerError, err) |
| 3851 | + return |
| 3852 | + } |
| 3853 | + if !s.hasClaim(r.Context(), sha, pubkey) && !s.isOwner(pubkey) { |
| 3854 | + s.fail(w, http.StatusForbidden, errors.New("restricted: not the upl |