tinyrelaycommit

Commit 8cf537447298

browse files at this commit | history

1diff --git a/.dockerignore b/.dockerignore
2new file mode 100644
3index 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
16diff --git a/.gitignore b/.gitignore
17new file mode 100644
18index 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
41diff --git a/AGENTS.md b/AGENTS.md
42new file mode 100644
43index 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.
49diff --git a/Dockerfile b/Dockerfile
50new file mode 100644
51index 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"]
92diff --git a/LICENSE b/LICENSE
93new file mode 100644
94index 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.
119diff --git a/Makefile b/Makefile
120new file mode 100644
121index 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
149diff --git a/README.md b/README.md
150new file mode 100644
151index 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/`.
235diff --git a/THIRD_PARTY_NOTICES b/THIRD_PARTY_NOTICES
236new file mode 100644
237index 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.
304diff --git a/architecture.html b/architecture.html
305new file mode 100644
306index 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 &amp; templates</a>
343+ <a href="#delivery"><span>04</span>Inbox &amp; outbox</a>
344+ <a href="#storage"><span>05</span>Storage &amp; 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 &amp; prior art</a>
348+ <a href="#operations"><span>09</span>Self-host operations</a>
349+ <a href="#delivery-plan"><span>10</span>Build &amp; migration</a>
350+ <a href="#decisions"><span>11</span>Decision record</a>
351+ <a href="#sources"><span>12</span>Evidence &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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+ &lt;stable-id&gt;/
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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; UX to preserve</th><th>Evidence of completion</th></tr></thead><tbody>
623+ <tr><td>Relay protocol &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>
743diff --git a/cmd/tiny/main.go b/cmd/tiny/main.go
744new file mode 100644
745index 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+}
1073diff --git a/cmd/tiny/main_test.go b/cmd/tiny/main_test.go
1074new file mode 100644
1075index 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+}
1117diff --git a/deploy/Tailscale.Dockerfile b/deploy/Tailscale.Dockerfile
1118new file mode 100644
1119index 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/
1158diff --git a/deploy/compose.yaml b/deploy/compose.yaml
1159new file mode 100644
1160index 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:
1181diff --git a/deploy/renew-tiny-certificates.sh b/deploy/renew-tiny-certificates.sh
1182new file mode 100644
1183index 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
1213diff --git a/deploy/slate-tiny.nix b/deploy/slate-tiny.nix
1214new file mode 100644
1215index 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+}
1273diff --git a/deploy/tailscale-compose.yaml b/deploy/tailscale-compose.yaml
1274new file mode 100644
1275index 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
1368diff --git a/deploy/tailscale-portlist.patch b/deploy/tailscale-portlist.patch
1369new file mode 100644
1370index 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)
1390diff --git a/deploy/tiny.Caddyfile b/deploy/tiny.Caddyfile
1391new file mode 100644
1392index 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+}
1423diff --git a/deploy/tiny.service b/deploy/tiny.service
1424new file mode 100644
1425index 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
1451diff --git a/docs/fixi.md b/docs/fixi.md
1452new file mode 100644
1453index 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.
1472diff --git a/docs/git-performance.md b/docs/git-performance.md
1473new file mode 100644
1474index 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.
1624diff --git a/docs/khatru-integration.md b/docs/khatru-integration.md
1625new file mode 100644
1626index 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.
1711diff --git a/docs/observability.md b/docs/observability.md
1712new file mode 100644
1713index 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).
1796diff --git a/docs/parity-gap-audit.md b/docs/parity-gap-audit.md
1797new file mode 100644
1798index 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.
1889diff --git a/docs/parity.json b/docs/parity.json
1890new file mode 100644
1891index 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+}
2305diff --git a/docs/parity.md b/docs/parity.md
2306new file mode 100644
2307index 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.
2356diff --git a/docs/personal-relay.md b/docs/personal-relay.md
2357new file mode 100644
2358index 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.
2407diff --git a/docs/protocol-expansion.md b/docs/protocol-expansion.md
2408new file mode 100644
2409index 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.
2462diff --git a/docs/testing.md b/docs/testing.md
2463new file mode 100644
2464index 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.
2551diff --git a/docs/webmcp.md b/docs/webmcp.md
2552new file mode 100644
2553index 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.
2566diff --git a/go.mod b/go.mod
2567new file mode 100644
2568index 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+)
2638diff --git a/go.sum b/go.sum
2639new file mode 100644
2640index 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=
2822diff --git a/internal/auth/auth.go b/internal/auth/auth.go
2823new file mode 100644
2824index 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+}
3188diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
3189new file mode 100644
3190index 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+}
3356diff --git a/internal/blob/blob.go b/internal/blob/blob.go
3357new file mode 100644
3358index 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
Diff preview truncated (256 KiB or 10,000 lines).

About

ownerc01503e8035f
visibilitypublic
head8cf537447298

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs