tinyrelaypr

All pull requests

Stack 2: Extract public protocol and metadata contracts

merged by ba1b5beed5b5

Labels: refactor

Stack and scope

Depends on the tinygit/tinyclient extraction PR, commit 9d68e8778c3448fdc08c95e7b44bb30732692f83.

The Git branch is stacked on that commit. The hosted PR API does not select a target branch; its diff is cumulative against main (5625d0b5f0230cb9d592d0e13baa9494a168f954). Review only this slice with git diff 9d68e8778c3448fdc08c95e7b44bb30732692f83 refactor/protocol-contracts. Merge the extraction first. No changes to hosted main or the running deployment are part of this PR.

Changes beyond the extraction

  • Expose protocol/nostr: event/filter types, serialization, signing, signature validation and generic matching. Host privacy and feature kind semantics remain internal. Existing internal matching retains its private-event exclusions.
  • Expose protocol/auth: existing NIP-98, NIP-42, Blossom and GRASP-08 proof verification, with compatibility aliases preserving type/error identity and shared replay state. Proof verification is not host authorization.
  • Use public event types in tinygit and tinyclient contracts. Add external-module consumer tests, transitive dependency guards and a public API-surface guard.
  • Add pure tinygit.ParseMetadata(nostr.Event) for repository metadata shape and semantics. Signatures, author authority, object availability and durable publication remain separate. Invalid ref/HEAD metadata is rejected before repository lookup or host authorization callbacks.
  • Specify the existing agent-grant, reviewed grant-request, callback and custom-view-transform project extensions. Document exact fields, ownership, lifecycle, errors, compatibility and implementation/test evidence without claiming complete upstream standards conformance.
  • Update architecture, module contracts, component docs and feature guides to match the actual split.

Verification

Executed on the integrated worktree:

  • make build: tiny, tinygit and tinyclient binaries.
  • go test -mod=readonly -count=1 -json ./...: all tests passed.
  • go vet -mod=readonly ./...: passed.
  • go test -mod=readonly -race -count=1 ./protocol/... ./internal/auth ./internal/event ./tinygit ./cmd/tinygit ./tinyclient ./cmd/tinyclient: passed.
  • npm ci and npm run test:tinyclient: all 286 JavaScript tests passed.
  • Baseline HTTP comparison: 96 guest/owner GET/HEAD cases across 24 integrated routes, assets and tenant paths. Status, content type, redirect location and response-body hashes matched the pre-extraction baseline. This is route-level parity evidence, not exhaustive protocol conformance.
  • Local documentation links and Git whitespace checks passed.

The new consumer and admission regression tests were observed failing before implementation. Independent code and specification reviews gate publication.

Existing limitations surfaced by the contract audit

These are documented behavior, not fixes or new guarantees in this refactor:

  • Callback delivery rechecks membership/read access but not an active agent grant or fresh source existence. Pausing a grant is not a guaranteed callback kill switch. Pause/resume management responses also differ from list-response URL redaction.
  • Custom-view artifact audiences do not inherit source ACLs. Even a pending/private source can produce a more broadly visible artifact. SVG screening is regex-based, PNG screening checks the signature prefix, and an ok transform status does not guarantee an artifact was accepted.
  • Grant-request schemas and signed-event clearing semantics differ in some fields; the specifications record these and other compatibility limits.

The feature guides no longer overstate callback cancellation, artifact access controls or transform validation. Security/behavior changes to these existing mechanisms should be separately tested and reviewed rather than hidden inside a parity-oriented extraction.

Next module candidates

Keep follow-ups narrow: a tinyblob storage/ownership contract, community/grant authority separated from persistence, and records/views transaction and lifecycle contracts. The public protocol packages do not depend on internal host packages; tinygit and tinyclient still use some shared internal infrastructure and are not independent Go modules. No wire or persisted identifier migration is implied by the terminology cleanup.

Changes

1diff --git a/AGENTS.md b/AGENTS.md
2index bdc0bdf..885e267 100644
3--- a/AGENTS.md
4+++ b/AGENTS.md
5@@ -1,4 +1,4 @@
6 - The middle dot character (U+00B7) is disallowed in project text and interfaces.
7 - Write all documentation in AP style, with clear, concise organization. Documentation is a user-facing surface; omit incidental implementation notes.
8 - After completing work in this project, commit the changes, push them and deploy to Slate. Verify the deployment before reporting completion.
9-- Web UI markup carries no class attributes. Style ids, elements and custom element names in `internal/webui/style.css`, and render repeated or large data through the elements in `internal/webui/components.js` (`rpc-form`, `signed-form`, `publish-list`, `json-view`, `nostr-key`) and the `nostrEvent` and `jsonView` template partials. The shell is a rail, a content column, a panel and a command footer; `page.html` owns the rail and panel dispatch, each page template renders one `<section>` of content, and every page must render without JavaScript.
10+- Web UI markup carries no class attributes. Style ids, elements and custom element names in `tinyclient/style.css`, and render repeated or large data through the elements in `tinyclient/components.js` (`rpc-form`, `signed-form`, `publish-list`, `json-view`, `nostr-key`) and the `nostrEvent` and `jsonView` template partials. The shell is a rail, a content column, a panel and a command footer; `page.html` owns the rail and panel dispatch, each page template renders one `<section>` of content, and every page must render without JavaScript.
11diff --git a/Dockerfile b/Dockerfile
12index 2c3455f..9c319c6 100644
13--- a/Dockerfile
14+++ b/Dockerfile
15@@ -28,7 +28,7 @@ RUN apt-get update \
16 && mkdir -p /data \
17 && chown relay:relay /data
18 COPY --from=binary /out/tiny /usr/local/bin/tiny
19-COPY LICENSE THIRD_PARTY_NOTICES internal/webui/signer.js.license internal/webui/nostr-name.js.license /usr/share/doc/tiny/
20+COPY LICENSE THIRD_PARTY_NOTICES tinyclient/signer.js.license tinyclient/nostr-name.js.license /usr/share/doc/tiny/
21 USER relay
22 WORKDIR /home/relay
23 VOLUME ["/data"]
24diff --git a/Makefile b/Makefile
25index 38754f9..449470f 100644
26--- a/Makefile
27+++ b/Makefile
28@@ -1,7 +1,18 @@
29-.PHONY: test test-race test-internal-race benchmark verify web-test docker-test docker-build linux-test
30+.PHONY: build build-tiny build-tinygit build-tinyclient test test-race test-internal-race benchmark verify web-test docker-test docker-build linux-test
31
32 TEST_PACKAGES ?= ./...
33
34+build: build-tiny build-tinygit build-tinyclient
35+
36+build-tiny:
37+ go build -mod=readonly -o bin/tiny ./cmd/tiny
38+
39+build-tinygit:
40+ go build -mod=readonly -o bin/tinygit ./cmd/tinygit
41+
42+build-tinyclient:
43+ go build -mod=readonly -o bin/tinyclient ./cmd/tinyclient
44+
45 test:
46 go test -mod=readonly ./...
47
48diff --git a/README.md b/README.md
49index 3217e9f..8bfbaa6 100644
50--- a/README.md
51+++ b/README.md
52@@ -19,6 +19,20 @@ flowchart LR
53 tenant -- "inbox and outbox" --> relays((other relays))
54 ```
55
56+## Components
57+
58+The repository contains three executables in one Go module:
59+
60+| Component | Build target | Role |
61+| --- | --- | --- |
62+| `tinyrelay`, distributed as `tiny` | `make build-tiny` | Integrated relay host with Git hosting and the browser interface. |
63+| [`tinygit`](tinygit/README.md) | `make build-tinygit` | Standalone public Git host using signed repository metadata and refs. |
64+| [`tinyclient`](tinyclient/README.md) | `make build-tinyclient` | Standalone frontend connected to a compatible tinyrelay backend. |
65+
66+`make build` writes all three binaries to `bin/`. Go builds embed the checked-in browser assets and do not require Node.js. The integrated `tiny` command, wire formats and existing tenant data remain compatible. Standalone components have narrower capabilities: tinygit does not start a relay or private-repository service, and tinyclient needs a backend and preserves its public origin and tenant path.
67+
68+Reusable `protocol/nostr` and `protocol/auth` packages supply wire values and proof verification without importing host services. Feature semantics and access policy remain separate. See [Architecture](docs/architecture.md), [Module boundaries and protocol contracts](docs/module-contracts.md) and the [extension catalog](docs/extensions/README.md) for ownership, supported profiles and remaining seams.
69+
70 ## Run locally
71
72 Build and start the daemon:
73diff --git a/THIRD_PARTY_NOTICES b/THIRD_PARTY_NOTICES
74index 6c98ce4..8dcda23 100644
75--- a/THIRD_PARTY_NOTICES
76+++ b/THIRD_PARTY_NOTICES
77@@ -2,7 +2,7 @@ tinyrelay third-party notices
78
79 nostr-tools browser signer bundle
80 ---------------------------------
81-The generated internal/webui/signer.js bundle is built from nostr-tools and
82+The generated tinyclient/signer.js bundle is built from nostr-tools and
83 its browser dependencies. nostr-tools is released under the Unlicense:
84
85 This is free and unencumbered software released into the public domain.
86@@ -40,14 +40,14 @@ THE SOFTWARE.
87
88 nostr-name browser bundle
89 -------------------------
90-The generated internal/webui/nostr-name.js bundle is built from
91+The generated tinyclient/nostr-name.js bundle is built from
92 nostr-web-components 0.2.2, @jsr/nostr__gadgets 0.1.9 and
93 @jsr/nostr__tools 2.23.0, as resolved by package-lock.json. The installed
94 nostr-web-components package identifies its license as the Unlicense. The
95 bundle also retains esbuild license comments for noble-curves and scure-base;
96 their installed package metadata identifies them as MIT licensed. The
97 corresponding installed license text is recorded in
98-internal/webui/nostr-name.js.license.
99+tinyclient/nostr-name.js.license.
100
101 Fixi BSD-0 notice
102 -----------------
103diff --git a/cmd/tiny/gittoken.go b/cmd/tiny/gittoken.go
104index 8833728..9e00c3f 100644
105--- a/cmd/tiny/gittoken.go
106+++ b/cmd/tiny/gittoken.go
107@@ -11,8 +11,8 @@ import (
108 "strings"
109 "time"
110
111- "github.com/FelineStateMachine/tinyrelay/internal/auth"
112 "github.com/FelineStateMachine/tinyrelay/internal/event"
113+ "github.com/FelineStateMachine/tinyrelay/protocol/auth"
114 )
115
116 // gitToken mints the GRASP-08 repository-root proof that private Git hosting
117diff --git a/cmd/tiny/gittoken_test.go b/cmd/tiny/gittoken_test.go
118index 7d4e632..5ba579e 100644
119--- a/cmd/tiny/gittoken_test.go
120+++ b/cmd/tiny/gittoken_test.go
121@@ -6,7 +6,7 @@ import (
122 "testing"
123 "time"
124
125- "github.com/FelineStateMachine/tinyrelay/internal/auth"
126+ "github.com/FelineStateMachine/tinyrelay/protocol/auth"
127 )
128
129 func TestGitTokenMintsAReusableRepositoryRootProof(t *testing.T) {
130diff --git a/cmd/tinyclient/main.go b/cmd/tinyclient/main.go
131new file mode 100644
132index 0000000..f435045
133--- /dev/null
134+++ b/cmd/tinyclient/main.go
135@@ -0,0 +1,75 @@
136+// Command tinyclient runs the standalone, server-rendered relay website.
137+package main
138+
139+import (
140+ "context"
141+ "errors"
142+ "flag"
143+ "fmt"
144+ "io"
145+ "net"
146+ "net/http"
147+ "os"
148+ "os/signal"
149+ "syscall"
150+ "time"
151+
152+ "github.com/FelineStateMachine/tinyrelay/tinyclient"
153+)
154+
155+func configure(args []string, output io.Writer) (*http.Server, error) {
156+ flags := flag.NewFlagSet("tinyclient", flag.ContinueOnError)
157+ flags.SetOutput(output)
158+ listen := flags.String("listen", "127.0.0.1:8081", "HTTP listen address")
159+ backend := flags.String("backend", "", "Upstream tinyrelay backend HTTP URL, including /r/<tenant> when used")
160+ public := flags.String("public-url", "", "Browser-facing HTTP(S) URL; preserve the relay's public origin and tenant path")
161+ if err := flags.Parse(args); err != nil {
162+ return nil, err
163+ }
164+ if flags.NArg() != 0 {
165+ return nil, errors.New("tinyclient accepts flags only")
166+ }
167+ handler, err := tinyclient.NewRemote(tinyclient.RemoteOptions{BackendURL: *backend, PublicURL: *public})
168+ if err != nil {
169+ return nil, err
170+ }
171+ return &http.Server{Addr: *listen, Handler: handler, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 90 * time.Second}, nil
172+}
173+
174+func run(ctx context.Context, args []string, output io.Writer) error {
175+ server, err := configure(args, output)
176+ if err != nil {
177+ return err
178+ }
179+ listener, err := net.Listen("tcp", server.Addr)
180+ if err != nil {
181+ return err
182+ }
183+ defer listener.Close()
184+ fmt.Fprintf(output, "tinyclient listening on %s\n", listener.Addr())
185+ done := make(chan struct{})
186+ defer close(done)
187+ go func() {
188+ select {
189+ case <-ctx.Done():
190+ shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
191+ defer cancel()
192+ _ = server.Shutdown(shutdown)
193+ case <-done:
194+ }
195+ }()
196+ err = server.Serve(listener)
197+ if errors.Is(err, http.ErrServerClosed) {
198+ return nil
199+ }
200+ return err
201+}
202+
203+func main() {
204+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
205+ defer cancel()
206+ if err := run(ctx, os.Args[1:], os.Stderr); err != nil && !errors.Is(err, flag.ErrHelp) {
207+ fmt.Fprintln(os.Stderr, "tinyclient:", err)
208+ os.Exit(1)
209+ }
210+}
211diff --git a/cmd/tinyclient/main_test.go b/cmd/tinyclient/main_test.go
212new file mode 100644
213index 0000000..88a17df
214--- /dev/null
215+++ b/cmd/tinyclient/main_test.go
216@@ -0,0 +1,48 @@
217+package main
218+
219+import (
220+ "bytes"
221+ "net/http"
222+ "net/http/httptest"
223+ "strings"
224+ "testing"
225+
226+ "github.com/FelineStateMachine/tinyrelay/tinyclient"
227+)
228+
229+func TestCommandRequiresBackendAndPublicOrigin(t *testing.T) {
230+ for _, args := range [][]string{nil, {"-backend", "http://relay.example"}, {"-backend", "http://relay.example", "-public-url", "not-a-url"}, {"-backend", "http://relay.example/r/one", "-public-url", "http://client.example/r/two"}} {
231+ if _, err := configure(args, &bytes.Buffer{}); err == nil {
232+ t.Fatalf("accepted incomplete or invalid configuration: %v", args)
233+ }
234+ }
235+}
236+
237+func TestCommandServesEmbeddedClient(t *testing.T) {
238+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
239+ if r.URL.Path != tinyclient.BackendPath {
240+ t.Errorf("unexpected upstream page %s", r.URL.Path)
241+ http.NotFound(w, r)
242+ return
243+ }
244+ w.Header().Set("Content-Type", "application/json")
245+ if r.URL.Query().Get("method") != "" {
246+ _, _ = w.Write([]byte("null"))
247+ return
248+ }
249+ _, _ = w.Write([]byte(`{"policy":{"name":"Standalone frontend","features":{"pages":true}},"slug":"test","url":"http://client.example"}`))
250+ }))
251+ defer upstream.Close()
252+ server, err := configure([]string{"-listen", "127.0.0.1:0", "-backend", upstream.URL, "-public-url", "http://client.example"}, &bytes.Buffer{})
253+ if err != nil {
254+ t.Fatal(err)
255+ }
256+ response := httptest.NewRecorder()
257+ server.Handler.ServeHTTP(response, httptest.NewRequest("GET", "http://client.example/", nil))
258+ if response.Code != 200 || !strings.Contains(response.Body.String(), "Standalone frontend") {
259+ t.Fatalf("standalone command: %d %s", response.Code, response.Body.String())
260+ }
261+ if server.Addr != "127.0.0.1:0" {
262+ t.Fatalf("listen=%s", server.Addr)
263+ }
264+}
265diff --git a/cmd/tinygit/main.go b/cmd/tinygit/main.go
266new file mode 100644
267index 0000000..07fd6ad
268--- /dev/null
269+++ b/cmd/tinygit/main.go
270@@ -0,0 +1,88 @@
271+// Command tinygit serves signed repository metadata and native Git smart HTTP.
272+package main
273+
274+import (
275+ "context"
276+ "errors"
277+ "flag"
278+ "fmt"
279+ "io"
280+ "net"
281+ "net/http"
282+ "os"
283+ "os/exec"
284+ "os/signal"
285+ "syscall"
286+ "time"
287+
288+ "github.com/FelineStateMachine/tinyrelay/tinygit"
289+)
290+
291+func main() {
292+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
293+ defer stop()
294+ if err := run(ctx, os.Args[1:], os.Stdout, os.Stderr); err != nil {
295+ fmt.Fprintln(os.Stderr, "tinygit:", err)
296+ os.Exit(1)
297+ }
298+}
299+
300+func run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
301+ flags := flag.NewFlagSet("tinygit", flag.ContinueOnError)
302+ flags.SetOutput(stderr)
303+ listen := flags.String("listen", "127.0.0.1:8082", "HTTP listen address (put TLS and request limits in front for public hosting)")
304+ data := flags.String("data", "./tinygit-data", "dedicated data directory for events.db and bare Git repositories")
305+ owner := flags.String("owner", "", "required repository owner's lowercase hex Nostr public key")
306+ publicURL := flags.String("public-url", "", "canonical public HTTP(S) URL")
307+ if err := flags.Parse(args); err != nil {
308+ if errors.Is(err, flag.ErrHelp) {
309+ return nil
310+ }
311+ return err
312+ }
313+ if flags.NArg() != 0 {
314+ return errors.New("unexpected positional arguments")
315+ }
316+ if _, err := exec.LookPath("git"); err != nil {
317+ return fmt.Errorf("native Git is required: %w", err)
318+ }
319+ handler, err := tinygit.OpenServer(ctx, tinygit.ServerConfig{DataDir: *data, Owner: *owner, PublicURL: *publicURL})
320+ if err != nil {
321+ return err
322+ }
323+ defer handler.Close()
324+ listener, err := net.Listen("tcp", *listen)
325+ if err != nil {
326+ return err
327+ }
328+ defer listener.Close()
329+ server := &http.Server{
330+ Handler: handler,
331+ ReadHeaderTimeout: 10 * time.Second,
332+ IdleTimeout: 60 * time.Second,
333+ MaxHeaderBytes: 1 << 20,
334+ BaseContext: func(net.Listener) context.Context { return ctx },
335+ }
336+ done := make(chan error, 1)
337+ go func() { done <- server.Serve(listener) }()
338+ fmt.Fprintln(stdout, "tinygit listening on http://"+listener.Addr().String())
339+ select {
340+ case err := <-done:
341+ if errors.Is(err, http.ErrServerClosed) {
342+ return nil
343+ }
344+ return err
345+ case <-ctx.Done():
346+ shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
347+ defer cancel()
348+ err := server.Shutdown(shutdown)
349+ if err != nil {
350+ _ = server.Close()
351+ }
352+ serveErr := <-done
353+ if errors.Is(serveErr, http.ErrServerClosed) {
354+ serveErr = nil
355+ }
356+ return errors.Join(err, serveErr)
357+ }
358+}
359diff --git a/cmd/tinygit/main_integration_test.go b/cmd/tinygit/main_integration_test.go
360new file mode 100644
361index 0000000..b1cd30c
362--- /dev/null
363+++ b/cmd/tinygit/main_integration_test.go
364@@ -0,0 +1,191 @@
365+package main
366+
367+import (
368+ "bufio"
369+ "bytes"
370+ "context"
371+ "encoding/json"
372+ "fmt"
373+ "io"
374+ "net/http"
375+ "os"
376+ "os/exec"
377+ "path/filepath"
378+ "strings"
379+ "testing"
380+ "time"
381+
382+ "github.com/FelineStateMachine/tinyrelay/internal/event"
383+)
384+
385+// This is deliberately a subprocess acceptance test: it proves cmd/tinygit is
386+// a real standalone server, not only an embeddable HTTP handler.
387+func TestStandaloneBinarySignedPushCloneAndRestart(t *testing.T) {
388+ if testing.Short() {
389+ t.Skip("builds and runs the standalone binary and native Git")
390+ }
391+ root := t.TempDir()
392+ binary := filepath.Join(root, "tinygit")
393+ build := exec.Command("go", "build", "-o", binary, ".")
394+ if out, err := build.CombinedOutput(); err != nil {
395+ t.Fatalf("build standalone binary: %v\n%s", err, out)
396+ }
397+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
398+ defer cancel()
399+ secret := strings.Repeat("0", 63) + "1"
400+ owner, err := event.PublicKey(secret)
401+ if err != nil {
402+ t.Fatal(err)
403+ }
404+ data := filepath.Join(root, "data")
405+ start := func() (string, func()) {
406+ t.Helper()
407+ cmd := exec.CommandContext(ctx, binary, "-listen", "127.0.0.1:0", "-data", data, "-owner", owner)
408+ stdout, err := cmd.StdoutPipe()
409+ if err != nil {
410+ t.Fatal(err)
411+ }
412+ cmd.Stderr = os.Stderr
413+ if err := cmd.Start(); err != nil {
414+ t.Fatal(err)
415+ }
416+ done := make(chan error, 1)
417+ go func() { done <- cmd.Wait() }()
418+ stopped := false
419+ stop := func() {
420+ t.Helper()
421+ if stopped {
422+ return
423+ }
424+ stopped = true
425+ _ = cmd.Process.Signal(os.Interrupt)
426+ select {
427+ case err := <-done:
428+ if err != nil {
429+ t.Errorf("server exit: %v", err)
430+ }
431+ case <-time.After(10 * time.Second):
432+ _ = cmd.Process.Kill()
433+ <-done
434+ t.Error("server did not shut down")
435+ }
436+ }
437+ t.Cleanup(stop)
438+ line := make(chan string, 1)
439+ go func() {
440+ scanner := bufio.NewScanner(stdout)
441+ if scanner.Scan() {
442+ line <- scanner.Text()
443+ } else {
444+ line <- ""
445+ }
446+ }()
447+ var address string
448+ select {
449+ case ready := <-line:
450+ const prefix = "tinygit listening on "
451+ if !strings.HasPrefix(ready, prefix) {
452+ t.Fatalf("startup: %q", ready)
453+ }
454+ address = strings.TrimPrefix(ready, prefix)
455+ case <-ctx.Done():
456+ t.Fatal(ctx.Err())
457+ }
458+ res, err := http.Get(address + "/healthz")
459+ if err != nil {
460+ t.Fatal(err)
461+ }
462+ res.Body.Close()
463+ if res.StatusCode != http.StatusOK {
464+ t.Fatalf("health = %d", res.StatusCode)
465+ }
466+ return address, stop
467+ }
468+ address, stop := start()
469+ post := func(e event.Event) bool {
470+ t.Helper()
471+ if err := event.Sign(&e, secret); err != nil {
472+ t.Fatal(err)
473+ }
474+ body, err := json.Marshal(e)
475+ if err != nil {
476+ t.Fatal(err)
477+ }
478+ res, err := http.Post(address+"/events", "application/json", bytes.NewReader(body))
479+ if err != nil {
480+ t.Fatal(err)
481+ }
482+ defer res.Body.Close()
483+ var result struct {
484+ ID string `json:"id"`
485+ Pending bool `json:"pending"`
486+ }
487+ if res.StatusCode != http.StatusOK {
488+ body, _ := io.ReadAll(res.Body)
489+ t.Fatalf("publish = %d: %s", res.StatusCode, body)
490+ }
491+ if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
492+ t.Fatal(err)
493+ }
494+ if result.ID != e.ID {
495+ t.Fatalf("event ID = %q, want %q", result.ID, e.ID)
496+ }
497+ return result.Pending
498+ }
499+ git := func(dir string, wantSuccess bool, args ...string) string {
500+ t.Helper()
501+ cmd := exec.CommandContext(ctx, "git", args...)
502+ cmd.Dir = dir
503+ cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.invalid", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.invalid")
504+ out, err := cmd.CombinedOutput()
505+ if (err == nil) != wantSuccess {
506+ t.Fatalf("git %v: %v\n%s", args, err, out)
507+ }
508+ return strings.TrimSpace(string(out))
509+ }
510+ work := filepath.Join(root, "work")
511+ if err := os.Mkdir(work, 0700); err != nil {
512+ t.Fatal(err)
513+ }
514+ git(work, true, "init", "-b", "main")
515+ if err := os.WriteFile(filepath.Join(work, "hello.txt"), []byte("standalone Git\n"), 0600); err != nil {
516+ t.Fatal(err)
517+ }
518+ git(work, true, "add", "hello.txt")
519+ git(work, true, "commit", "-m", "first")
520+ oid := git(work, true, "rev-parse", "HEAD")
521+ post(event.Event{Kind: 30617, CreatedAt: 100, Tags: [][]string{{"d", "demo"}}})
522+ cloneURL := func() string {
523+ return address + "/npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d/demo.git"
524+ }
525+ git(work, false, "push", cloneURL(), "main")
526+ state := event.Event{Kind: 30618, CreatedAt: 101, Tags: [][]string{{"d", "demo"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", oid}}}
527+ if !post(state) {
528+ t.Fatal("missing objects were not staged as pending")
529+ }
530+ t.Log(git(work, true, "push", cloneURL(), "main"))
531+ if post(state) {
532+ t.Fatal("receive-pack did not promote pending state")
533+ }
534+ git(work, true, "commit", "--allow-empty", "-m", "unsigned change")
535+ git(work, false, "push", cloneURL(), "main")
536+ git(work, false, "push", cloneURL(), "HEAD:refs/heads/unauthorized")
537+ verifyClone := func(name string) {
538+ t.Helper()
539+ dest := filepath.Join(root, name)
540+ t.Log(git(root, true, "clone", cloneURL(), dest))
541+ if got := git(dest, true, "rev-parse", "HEAD"); got != oid {
542+ t.Fatalf("cloned HEAD = %s, want %s", got, oid)
543+ }
544+ body, err := os.ReadFile(filepath.Join(dest, "hello.txt"))
545+ if err != nil || string(body) != "standalone Git\n" {
546+ t.Fatalf("clone content = %q (%v)", body, err)
547+ }
548+ }
549+ verifyClone("clone")
550+ stop()
551+ address, stop = start()
552+ defer stop()
553+ verifyClone("restarted-clone")
554+ t.Log(fmt.Sprintf("verified signed push, rejected unsigned pushes, clone and durable restart at %s", oid))
555+}
556diff --git a/docs/agents.md b/docs/agents.md
557index 0342d70..ecd5a1c 100644
558--- a/docs/agents.md
559+++ b/docs/agents.md
560@@ -4,6 +4,8 @@ An agent is a member of your relay that acts under a grant you sign. It has its
561
562 Agents suit assistants, bots and automations that should post as themselves rather than with your key. Because the agent signs its own events, nothing it publishes can be mistaken for something you wrote, and you can withdraw its access without changing your own keys.
563
564+The [agent-grant contract](extensions/agent-grants.md) and [reviewed grant-request contract](extensions/agent-grant-requests.md) specify these project extensions, their authority rules and compatibility limits. They build on Nostr events but are not themselves upstream NIPs.
565+
566 ## Grant an agent
567
568 The owner or a moderator grants an agent by publishing a kind 30392 event addressed to the agent's public key. Publishing the grant makes the agent a member with the role `agent`. Publishing a new grant for the same agent replaces the earlier one.
569@@ -352,7 +354,9 @@ export function verify(body, header, secret) {
570 }
571 ```
572
573-Right before each POST the relay checks that the callback still exists and is not paused and that the key may still read the event, so hiding an event or revoking an agent stops deliveries at once. Events go to a callback one at a time, in the order they arrived as far as possible. A repository state is delivered once its objects have arrived, so a push wakes the agent when the objects can be fetched.
574+Before each POST, the relay checks that the callback exists and is not paused, that its owner still has membership and that the read gate permits the queued event. Delivery does not separately recheck whether an agent grant is active or reload the source event. Pause the callback itself when delivery must stop; pausing a grant or deleting an event is not a guaranteed cancellation of already queued delivery. An in-flight request cannot be recalled.
575+
576+Each callback has at most one in-process delivery at a time. Retries can repeat delivery, so receivers must handle events idempotently. A repository state is delivered once its objects have arrived. See the [callback contract](extensions/callbacks.md) for exact payloads, management responses, retries and audited limits.
577
578 ### Retries and pauses
579
580diff --git a/docs/architecture.md b/docs/architecture.md
581index d0a7eec..64f089b 100644
582--- a/docs/architecture.md
583+++ b/docs/architecture.md
584@@ -1,5 +1,7 @@
585 # Architecture
586
587+`tinyrelay` assembles the relay host. `tinygit` owns Git functionality, and `tinyclient` owns the frontend. These functional modules share one repository and Go module. See [Module boundaries and protocol contracts](module-contracts.md) for ownership rules, extension documentation and proposed follow-up seams.
588+
589 Each tenant has a SQLite store, a policy snapshot and a set of services. The daemon constructs those services, connects their contracts and manages their lifetimes. HTTP, WebSocket and management adapters establish caller identity and enter the tenant's operation gate before calling services.
590
591 ## Find your starting point
592@@ -10,15 +12,17 @@ Each tenant has a SQLite store, a policy snapshot and a set of services. The dae
593
594 | Packages | Responsibility |
595 | --- | --- |
596-| [`event`](../internal/event/doc.go), [`policy`](../internal/policy/doc.go) | Event and filter values, signing, validation and shared policy decisions. |
597-| [`auth`](../internal/auth/doc.go), [`gates`](../internal/gates/doc.go) | Signed request proofs, event admission and visibility checks. |
598+| [`protocol/nostr`](../protocol/nostr/doc.go) | Public event and filter values, canonical encoding, signatures and generic wire checks. No host access policy. |
599+| [`protocol/auth`](../protocol/auth/doc.go) | Public request-proof verification and replay state. No membership or permission decisions. |
600+| [`event`](../internal/event/doc.go), [`auth`](../internal/auth/doc.go) | Internal compatibility aliases and feature helpers; private-kind search filtering remains host behavior. |
601+| [`policy`](../internal/policy/doc.go), [`gates`](../internal/gates/doc.go) | Host policy, event admission and visibility checks. |
602 | [`storage`](../internal/storage/doc.go), [`work`](../internal/work/doc.go) | Event persistence, queries, transaction hooks and durable queue claims. |
603 | [`community`](../internal/community/doc.go), [`communityread`](../internal/communityread/doc.go) | Membership, moderation, rooms, agents and the read models consumed by records. |
604 | [`records`](../internal/records/doc.go) | Relay identity, signed protocol records, projections and notifications. |
605 | [`blob`](../internal/blob/doc.go), [`sites`](../internal/sites/doc.go) | Content-addressed files, upload rules and sites backed by signed manifests. |
606-| [`gitrelay`](../internal/gitrelay/doc.go) | Repository admission, object storage, synchronization, repair and Git HTTP endpoints. |
607+| [`tinygit`](../tinygit/doc.go) | Repository admission, object storage, synchronization, repair and Git HTTP endpoints. |
608 | [`replication`](../internal/replication/doc.go), [`syncprotocol`](../internal/syncprotocol/doc.go) | Relay synchronization plans and transports, count sketches and reconciliation sessions. |
609-| [`relay`](../internal/relay/doc.go), [`mcp`](../internal/mcp/doc.go), [`webui`](../internal/webui/doc.go) | WebSocket sessions, MCP requests, HTML pages and browser interactions. |
610+| [`relay`](../internal/relay/doc.go), [`mcp`](../internal/mcp/doc.go), [`tinyclient`](../tinyclient/doc.go) | WebSocket sessions, MCP requests, HTML pages and browser interactions. |
611 | [`agentrunner`](../internal/agentrunner) | The optional agent process: room mentions, durable queue recovery, ACP or command execution, and permission prompts. |
612 | [`views`](../internal/views/doc.go), [`wiki`](../internal/wiki/doc.go), [`webpush`](../internal/webpush/doc.go) | Fenced-block parsing, article rendering and encrypted browser push delivery. |
613 | [`seedmark`](../internal/seedmark) | Deterministic decorative SVG avatars for people and rooms without a profile picture. |
614@@ -32,10 +36,12 @@ Each tenant has a SQLite store, a policy snapshot and a set of services. The dae
615
616 Services own the schema and data access for their features. `storage.Save` and `storage.SaveTx` accept transaction hooks so event data, projections and work intents can commit together. APIs that receive `*sql.Tx` use the caller's transaction. Community event handlers that accept a persistence callback open the transaction and pass it into the callback.
617
618-Consumers declare interfaces around the operations they need. `records.CommunityReader` reads membership and moderation projections through `communityread` values. `relay.Backend` supplies event operations to the WebSocket router. `webui.Backend` supplies management results, and `webui.RoomsReader` supplies typed room pages. The daemon implements these adapters and assembles the feature handlers used by `work.Worker`.
619+Consumers declare interfaces around the operations they need. `records.CommunityReader` reads membership and moderation projections through `communityread` values. `relay.Backend` supplies event operations to the WebSocket router. `tinyclient.Backend` supplies management results, and `tinyclient.RoomsReader` supplies typed room pages. The daemon implements these adapters and assembles the feature handlers used by `work.Worker`.
620
621 ## Event acceptance
622
623+`protocol/nostr.Validate` checks wire shape, event ID and signature. `tinygit.ParseMetadata` separately parses repository claims without establishing signer validity, repository authority or object availability. Git admission entry points perform signature and metadata checks before host admission callbacks. Generic `nostr.Matches` is not a visibility gate; callers must apply host access policy.
624+
625 Client publication and imported events use the shared event persistence and projection path. Each entry point applies its admission rules and protocol actions. Signed repository state stays pending until its referenced Git objects are available; promotion makes it visible and plans its optional follow-up work.
626
627 Callbacks and custom views are optional. A planning intent commits with an accepted event and captures its eligible registrations or failed local lookups. Recovery rechecks the source and current registrations, with at most three worker attempts and a 15-minute age limit. Stable child intent IDs preserve completed work. Remote delivery and transforms have their own retry policies. See [callbacks](agents.md#retries-and-pauses) and [custom views](views.md#failures).
628diff --git a/docs/extensions/README.md b/docs/extensions/README.md
629new file mode 100644
630index 0000000..2203c4e
631--- /dev/null
632+++ b/docs/extensions/README.md
633@@ -0,0 +1,34 @@
634+# Project extensions
635+
636+This catalog indexes project-defined interoperability contracts. An extension is not a claim of upstream NIP, GRASP or Blossom standardization. See [Module boundaries and protocol contracts](../module-contracts.md) for the distinction between upstream rules, supported profiles, project extensions and implementation details.
637+
638+| Identifier | Contract owner | Status | Specification |
639+| --- | --- | --- | --- |
640+| `tinyclient-read-adapter` | `tinyclient` presentation contract; tinyrelay data and authorization | Experimental, revision 1 | [Read-only frontend/backend HTTP adapter](tinyclient-read-adapter.md) |
641+| `agent-grants` | `internal/community` grant semantics; `internal/gates` admission; `internal/daemon` integration | Experimental, version 1 | [Agent grants](agent-grants.md) |
642+| `agent-grant-requests` | `internal/community` review and replacement; `internal/gates` admission; `internal/daemon` MCP and approvals | Experimental, version 1 | [Reviewed grant requests](agent-grant-requests.md) |
643+| `callbacks` | `internal/daemon` callback service and management | Experimental, version 1 | [Event callbacks](callbacks.md) |
644+| `custom-view-transforms` | `internal/daemon` transform service; `internal/views` block identity; `internal/records` relay signing | Experimental, version 1 | [Custom-view block transforms](custom-view-transforms.md) |
645+
646+## Status and compatibility
647+
648+The four version 1 specifications document existing implementation behavior audited at repository baseline [`9d68e8778c3448fdc08c95e7b44bb30732692f83`](https://012.run/repo?owner=c01503e8035f5a98eb2004721dc4eeddda498c7871927ae7edb4525ab6ccf2d7&repo=tinyrelay&ref=9d68e8778c3448fdc08c95e7b44bb30732692f83&view=home). Relative source and test links identify the owners in this stack. Their identifiers and versions are documentation identifiers, not new wire fields. There is no negotiated extension version, extension handshake or independent conformance certification. Use compatible client and relay revisions; changing existing field meanings, authority or persisted identifiers requires a compatibility review, not a naming-only refactor.
649+
650+“Must” and “rejects” in these specifications describe the audited contract, not new runtime requirements. An explicit audit limit takes precedence over broader wording in a feature guide. The catalog remains incomplete: unlisted project bindings and proposed reusable view definitions are not implicitly covered. The upstream standards audit is also incomplete. References below identify foundations, but this audit did not verify an upstream commit or establish full conformance to those documents. A NIP number advertised by the relay is discovery, not proof that every provision is implemented.
651+
652+## Shared transport boundary
653+
654+Signed grant and request events use the normal Nostr event envelope and publication path: WebSocket `EVENT`, `POST /events` with one event JSON object, or an MCP publishing tool. Event ID/signature validation proves authorship; the feature's current authority checks decide whether the event may take effect. The relay does not sign as an agent or convert a request into an operator grant.
655+
656+Management methods in these specifications are project methods carried through the [NIP-86](https://github.com/nostr-protocol/nips/blob/master/86.md) management envelope. Send `POST /` at the tenant root with `Content-Type: application/nostr+json+rpc` and `{"method":"<method>","params":[...]}`. Except for `supportedmethods`, the HTTP handler resolves the actor from authentication. [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) proofs are bound to the actual URL, method and body; a supplied public key is not authority. HTTP routing recognizes the management media type after dedicated routes, so clients should use the root, not invent a feature-specific management endpoint.
657+
658+Management HTTP success is `{"result":...}` with status 200; errors are `{"error":"<reason>"}`. Authentication failures use 401; `restricted:` and `blocked:` method errors use 403; other method errors use 400. Event publication instead returns `{"event_id":"...","accepted":true|false,"message":"..."}` with status 200 or 400 after parsing/authentication. Clients must inspect `accepted`, not assume all error prefixes map to the management HTTP status rules. MCP tools use `POST /mcp`; inspect `isError` and the publishing result, not HTTP success alone. The MCP transport revision is separate from these extension versions; see [MCP](../mcp.md).
659+
660+Sources: [`http.go`](../../internal/daemon/http.go) (`ServeHTTP`, `bridge`, `httpPublish`, `manageHTTP`, `Execute`), [`mcp_tools.go`](../../internal/daemon/mcp_tools.go), [`mcp_http.go`](../../internal/daemon/mcp_http.go), [`relay.go`](../../internal/relay/relay.go) and [`event.go`](../../internal/event/event.go).
661+
662+## Ownership rules
663+
664+- Feature owners define field parsing and semantic validation. Shared event and storage code does not confer operator, repository or tenant authority.
665+- The daemon binds authentication, host policy and feature services. An internal parser or unsigned-event builder alone is not an admission boundary.
666+- The caller that stores an accepted event owns its transaction. Grant effects participate in that transaction; optional callback and transform planning must not turn a valid source event into a rejected publish. Remote delivery is subsequent work, not part of the source event's atomic commit.
667+- Test links are evidence for the stated behavior, not a declaration of an exhaustive test suite. Gaps and differences between guides, schemas and implementation are recorded in each specification.
668diff --git a/docs/extensions/agent-grant-requests.md b/docs/extensions/agent-grant-requests.md
669new file mode 100644
670index 0000000..1cfdf7a
671--- /dev/null
672+++ b/docs/extensions/agent-grant-requests.md
673@@ -0,0 +1,93 @@
674+# Reviewed grant requests
675+
676+| Field | Value |
677+| --- | --- |
678+| Identifier | `agent-grant-requests` |
679+| Status | Experimental |
680+| Contract version | 1; no negotiated wire version |
681+| Owner | `internal/community` request parsing, patching and replacement validation; `internal/gates` admission/privacy; `internal/daemon` MCP preparation, approvals and history. |
682+| Wire | Agent-signed kind `1111` with `request=grant`; operator-signed kind `30392` replacement. |
683+
684+The [catalog's audit baseline and transport rules](README.md) apply. [NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md) supplies the kind 1111 comment/reference foundation; [NIP-25](https://github.com/nostr-protocol/nips/blob/master/25.md) supplies kind 7 reactions; NIP-01 and NIP-40 supply signed event and expiration foundations. The `grant` request type, JSON patch, provenance and authority rules are project additions to those foundations. No upstream revision or full NIP-22 conformance audit is asserted. This is not the general-purpose approval convention or upstream delegation. See [Agent identities](../agents.md#requesting-additional-access) for the workflow.
685+
686+## Request envelope
687+
688+The request is signed by the agent named by the active current [grant](agent-grants.md). Required tags are:
689+
690+```json
691+[
692+ ["E", "<current grant event ID>", "", "<operator pubkey>"],
693+ ["K", "30392"],
694+ ["P", "<operator pubkey>"],
695+ ["e", "<current grant event ID>", "", "<operator pubkey>"],
696+ ["k", "30392"],
697+ ["request", "grant"],
698+ ["grant", "{\"base\":\"<current grant event ID>\",\"changes\":{\"kinds\":[9]}}"],
699+ ["p", "<operator pubkey>"]
700+]
701+```
702+
703+This is the builder's tag shape, with placeholders, not a signed event. The content is the reason: valid UTF-8, nonblank after trimming, at most 500 Unicode code points. Direct event validation counts the original content; the MCP builder trims it before constructing the event.
704+
705+[`decodeGrantRequest` and `ReviewAgentGrantRequest`](../../internal/community/agent_grant_requests.go) enforce:
706+
707+- Kind `1111` and first `request` value exactly `grant`.
708+- Only `request`, `grant`, `E`, `K`, `P`, `e`, `k`, `p`, `subject` and `expiration` tag names. Any repeated tag name or unsupported/empty tag is rejected. `subject` and `expiration` are optional; no extension-specific subject length check is made.
709+- `grant` contains one JSON value with fields `base` and `changes`. Unknown struct fields or trailing JSON values are rejected. Duplicate JSON object keys are not separately rejected by the Go decoder; clients should emit unique keys.
710+- `base` equals both `E` and `e`. The reference is 64 characters and must resolve to the current grant; `K` and `k` are `30392`; `P` and the one `p` name the current grant signer. Reference relay hints and additional positions are not fully NIP-22-validated here; clients should use the builder shape above.
711+- The stored current grant belongs to the requesting agent and is active. A human role with a grant cannot use the narrow agent-request admission path. Requests without grants are rejected even when general comment publishing is allowed.
712+- At least one nonnull change field is present, and the resulting parsed scope differs from the original under ordered `reflect.DeepEqual` comparison. Empty arrays alone or adding only existing kinds/rooms cannot satisfy the no-op check. Repository/site replacements remove matching tags and append requested entries, so reordering alone can pass even when permissions are unchanged.
713+
714+The optional positive `expiration` ends review eligibility. Malformed/nonpositive expiration values map to no expiry through [`event.Expiration`](../../internal/event/event.go); this parser does not add a stricter expiration syntax check. Ordinary event admission still rejects already expired requests.
715+
716+## Changes and replacement construction
717+
718+| JSON field | Accepted value and operation |
719+| --- | --- |
720+| `kinds` | Integer array; add entries, retaining existing kinds. Resulting grant kinds must be from 0 through 65535. |
721+| `rooms` | String array; add entries. Each requested room must match `^[a-z0-9_-]{1,64}$`, stricter than a direct grant's room parser. |
722+| `repos` | Objects with `owner`, `identifier`, `level`; replace entries matched by raw owner/identifier, retaining other repositories (see compatibility limits below). Repeated identities in a request are rejected. Resulting entries must pass the grant parser. |
723+| `sites` | Objects with `label`, optional integer `ttl` and boolean `encrypted`; replace entries matched by raw label, retaining others (see compatibility limits below). Duplicate labels and negative TTLs are rejected. Omitted/zero TTL means no TTL in the signed-event path; positive TTL is 1 through 365. The label must belong to the agent or be `*`. |
724+| `wiki` | String replacing the field: empty, `propose` or `edit`. |
725+| `jobs` | String replacing the field: empty, `request`, `serve` or `both`. |
726+| `rate` | Integer replacing the field, 1 through 600. |
727+
728+All resulting lists and fields pass [`ParseAgentGrant`](../../internal/community/agents.go), including its 256-entry caps. Null pointer fields mean no replacement. There is no request patch for the agent, operator, name, expiration or grant content. Kinds/rooms cannot remove entries; repository/site entries can change an existing permission, and empty wiki/jobs can clear those fields. “Additional access” is a use case, not a rule that every patch must increase permissions.
729+
730+[`grantRequestTags`](../../internal/community/agent_grant_requests.go) copies retained tags in order, removes previous request provenance, adds requested kinds then rooms then repositories then sites, and appends supplied wiki/jobs/rate values. It then appends:
731+
732+```json
733+[
734+ ["grant-request", "<request event ID>"],
735+ ["grant-base", "<current grant event ID>"],
736+ ["e", "<request event ID>", "", "grant-request"]
737+]
738+```
739+
740+The unsigned replacement retains the original operator, kind, name, expiration and content. Its ID/signature are cleared and its timestamp is advanced past the base grant if necessary. Clients should sign the reviewed template without reordering or normalizing tags: validation compares the complete ordered tag array as well as parsed scope, identity, name, expiration and original content.
741+
742+## Authority and lifecycle
743+
744+1. `request_grant` over `POST /mcp` with `reason` and `changes` derives the current base/operator and returns `unsigned` plus `next`. This prepares but does not publish. Its schema is in [`mcp_grant_request.go`](../../internal/daemon/mcp_grant_request.go).
745+2. The agent signs the event. Calling `request_grant` with `event` requires its `pubkey` to match the authenticated actor, reviews it again and uses normal publication. WebSocket `EVENT` and `POST /events` enforce the same feature admission through the gate. This narrow path does not require `k=1111`; the agent rate limit and host restrictions still apply.
746+3. The request can be read only by its author or the `p` operator, subject to other read checks. Relay-owner/moderator standing alone does not bypass this participant check in [`Gate.CanSee`](../../internal/gates/gates.go). Notifications direct the operator to review, not one-tap grant approval.
747+4. Approval is a separately signed replacement grant from that operator, who must still have grant authority. A `+` reaction never changes access and does not settle the request as approved. Agents cannot approve themselves.
748+5. Any stored, unexpired kind 7 reaction from the operator with an `e` reference to the request and trimmed content `-` blocks replacement. This is not a newest-reaction-wins rule. A later `+` does not undo a still-stored denial.
749+6. Replacement requires the request to be stored, not expired or denied; its base to remain current; the agent grant to remain unpaused, unrevoked and unexpired; and the exact reviewed replacement fields/tags. Validation runs at admission and again in [`ApplyAgentEventTx`](../../internal/community/agents.go), inside the event/grant transaction. A prepared template is not a reservation on current authority.
750+7. Approvals report `open`, `answered` with decision `approved`/`denied`, or `expired`. A superseded base produces a conflict and is rendered expired; inactive requests can carry `grant_error` with no actionable review. [`grant_approvals.go`](../../internal/daemon/grant_approvals.go) consults archived grant revisions so a later grant edit does not erase the historical approval receipt. A historical approved receipt does not prove the permission is still effective.
751+
752+Private visibility is relay access control, not encryption. The operator and relay can read the reason and requested scope; signed copies may exist outside this relay. Do not put secrets in a rationale.
753+
754+## Errors and discovery
755+
756+Error families include `invalid:` for malformed/no-op patches or replacement differences; `restricted:` for a missing/inactive grant, wrong requesting role or replacement operator; and `conflict:` for stale, expired or denied requests. Examples are `invalid: grant request changes nothing`, `conflict: agent grant changed since the request` and `invalid: grant replacement tags differ from the reviewed request`. MCP preparation/submission failures set `isError` and include an `expected` shape; an unsigned preview is not a successful write. Apply the [transport error rules](README.md#shared-transport-boundary).
757+
758+Discover `request_grant` with MCP `tools/list`. `browseapprovals` and `browseapproval` are daemon read queries used by the approvals UI; the latter supplies the `grant` review (`request_id`, `base`, `agent`, `operator`, `before`, `after`, `unsigned`) or `grant_error`. There is no dedicated grant-request version capability; the relay's `agents` information entry describes grants generally, not caller eligibility.
759+
760+## Compatibility limits and evidence
761+
762+- MCP's advertised plain-field schema requires nonempty wiki/jobs strings and a site TTL of at least 1, while signed-event review permits empty wiki/jobs and omitted/zero TTL. Do not assume schema acceptance equals event admission or that both paths expose every clearing operation.
763+- Direct grants trim repository strings and site labels, and repository identifiers may contain colons. The request patch remover matches raw tags without trimming and splits repository strings differently (`SplitN(..., 3)`). A normalized repository/site identity may therefore fail to match the old tag; the retained entry takes precedence over the appended entry when parsed. This can make the request a rejected no-op or leave that replacement unapplied within an otherwise accepted patch. For example, an existing `["sites", " * ", "ttl=7"]` plus a request adding a new kind `9` and setting site `*` to TTL 3 is accepted but retains effective TTL 7. Inspect the reviewed effective scope, not just the requested changes. This audit documents the mismatches; it does not redefine identities or fix runtime behavior.
764+- Request JSON and NIP-22 references have the decoder/positional validation limits noted above. No independently versioned JSON schema or exhaustive upstream conformance suite is claimed.
765+
766+Evidence: [`community/agent_grant_requests_test.go`](../../internal/community/agent_grant_requests_test.go) tests retained entries and patch ordering; [`grant_admission_test.go`](../../internal/daemon/grant_admission_test.go) tests malformed requests, role/state restrictions, exact replacement and stale review; [`grant_approvals_test.go`](../../internal/daemon/grant_approvals_test.go) tests no-kind-1111 admission, participant reads, ineffective `+`, successful replacement/history, denial and supersession; [`mcp_grant_request_test.go`](../../internal/daemon/mcp_grant_request_test.go) tests operator-bound preparation, invalid changes and schema behavior.
767diff --git a/docs/extensions/agent-grants.md b/docs/extensions/agent-grants.md
768new file mode 100644
769index 0000000..49cca12
770--- /dev/null
771+++ b/docs/extensions/agent-grants.md
772@@ -0,0 +1,84 @@
773+# Agent grants
774+
775+| Field | Value |
776+| --- | --- |
777+| Identifier | `agent-grants` |
778+| Status | Experimental |
779+| Contract version | 1; no negotiated wire version |
780+| Owner | `internal/community` parses grants and owns their effective state; `internal/gates` enforces admission; `internal/daemon` binds management, storage and feature authority. |
781+| Wire | Kind `30392`; address `30392:<operator pubkey>:<agent pubkey>`. |
782+
783+The [catalog's audit baseline and transport rules](README.md) apply. Foundations are [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) signed/addressable events, [NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md) deletion, [NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md) expiration, and NIP-86/NIP-98 management. Kind 30392, its grant tags, the agent role and the management methods below are project conventions, not an upstream delegation standard. No upstream revision or complete conformance audit is asserted. The user guide is [Agent identities](../agents.md).
784+
785+## Event fields
786+
787+The agent remains the author and signer of its own events. The grant's `pubkey` is the operator, not the agent. [`ParseAgentGrant`](../../internal/community/agents.go) validates the following fields; signer authority is a separate check.
788+
789+| Field or tag | Version 1 validation and interpretation |
790+| --- | --- |
791+| `kind` | Exactly `30392`. |
792+| `d` | Required agent public key: 64 lowercase hexadecimal characters. Must not equal the event's signer. |
793+| `p` | Required; its first value must equal `d`. |
794+| `expiration` | Required positive, signed 64-bit Unix timestamp; no later than the admission time plus 365 days. A timestamp at or before admission time is allowed as revocation, unlike ordinary expired events. |
795+| `content` | Empty or any valid JSON value, not necessarily an object. No permission is derived from it. |
796+| `name` | Optional; trimmed and truncated to 64 **bytes**, not rejected for excess length. |
797+| `room` | Repeated, trimmed strings of 1 to 128 bytes; deduplicated after trimming. The grant parser does not require the narrower room ID syntax. |
798+| `repo` | Repeated `<owner>:<identifier>:<level>`. Owner is a lowercase 64-character hex key; identifier is 1 to 256 bytes; level is exactly `propose`, `read` or `maintain`. The final colon separates the level; the identifier may contain colons. Outer whitespace is trimmed. First entry for an owner/identifier wins. |
799+| `k` | Repeated decimal integers from 0 through 65535, parsed after trimming; deduplicated numerically. |
800+| `wiki` | Absent/empty, `propose` or `edit`. |
801+| `jobs` | Absent/empty, `request`, `serve` or `both`. |
802+| `sites` | Repeated `["sites","<label>",...]`; trimmed label is `*` or a site under the agent's key accepted by [`sites.ParseSite`](../../internal/sites). Snapshot labels are rejected. Subsequent trimmed values may only be `encrypted` or `ttl=<days>`, with days from 1 through 365. Repeated TTL flags use the last value; first entry for a label wins. |
803+| `rate` | Absent/empty defaults to 60 events per minute; otherwise a trimmed decimal integer from 1 through 600. |
804+
805+Each deduplicated list of rooms, repositories, kinds or sites is limited to 256 entries. Scalar tags use the first tag with a value, as defined by [`event.Tag`](../../internal/event/event.go). Grant parsing does not reject unknown tags or duplicate scalar tags. Clients should emit one scalar tag per field and unambiguous list entries; the stricter [grant-request](agent-grant-requests.md) rules are not interchangeable with this parser. Original signed tags and content are preserved, even when the effective projection trims or deduplicates them.
806+
807+## Signature, authority and state
808+
809+[`agentGrantShape`](../../internal/gates/gates.go) requires the signer's current role to be `owner` or `moderator`. A valid signature or successful `ParseAgentGrant` call alone does not confer this authority. Host bans, allowed kinds, room membership and feature rules still apply. The same grant gate is used for client writes and imports.
810+
811+[`ApplyAgentEventTx`](../../internal/community/agents.go), bound by [`event_commit.go`](../../internal/daemon/event_commit.go), mirrors a stored grant and the membership change inside the event transaction. A fresh active grant resets pause/revocation and adds role `agent` only if the key has no existing human role. A human member, moderator or owner retains that role and is not restricted by the grant. A revoked grant's record remains available for audit; its `agent` membership row is removed. Grant admission still checks retained grant state for a key with no role, preventing revoked keys from escaping the grant gate merely by losing membership.
812+
813+A grant is active only when not paused, not revoked and `expires > now`. A new accepted grant replaces the effective grant for that agent. Nostr addressable-event replacement is scoped by kind, signer and `d`, but the effective grant table is keyed only by agent. Do not infer a cross-operator “newest timestamp wins” rule from NIP-01: different authorized operators can publish different addresses for the same agent, and the accepted grant application updates that one effective row.
814+
815+Revocation can use:
816+
817+- A kind 5 deletion from the current grant's signer naming its event ID in `e`, or its address in `a`.
818+- A newly accepted kind 30392 grant with an expired positive `expiration`.
819+- The privileged `revokeagent` management method.
820+
821+Pause/resume are local management state, not signed grant replacements or portable Nostr events. A fresh active grant restores access; resuming a revoked grant is rejected. Request-linked replacements must also pass the provenance and stale-base rules in [Reviewed grant requests](agent-grant-requests.md).
822+
823+## Scope enforcement
824+
825+[`AgentGrant.Check` and `AllowsKind`](../../internal/community/agents.go), [`agentAdmission`](../../internal/gates/gates.go) and [`jobReply`](../../internal/gates/jobs.go) establish these rules:
826+
827+- Profiles (`0`), relay lists (`10002`) and authentication (`22242`) pass the grant's kind test. An inactive grant still fails the state test. Other kinds require a `k` entry or the derived permission below.
828+- `wiki=propose` admits `30818` and `818`; `wiki=edit` adds `30819`. Proposal visibility and decisions belong to the [wiki approval integration](../../internal/daemon/wiki_approvals.go), not event signature validation.
829+- `jobs=request` admits requests `5000` through `5999`, excluding reserved kind `5128`; `serve` admits results `6000` through `6999` and feedback `7000`; `both` combines them. Agent answers require a locally held, readable request and valid request references. See [`jobs_test.go`](../../internal/gates/jobs_test.go).
830+- `sites` admits `15128` and `35128`, not snapshot kind `5128`. A manifest must match a covering label even if its kind was explicitly granted. When every covering entry has a TTL, its `expiration` must fit the longest covering TTL measured from admission time; an entry with no TTL removes that bound.
831+- A nonempty first `h` value must be in the grant's rooms. This does not bypass room existence, membership or administration rules.
832+- Repository collaboration kinds `1617`, `1618`, `1619`, `1621`, `1111` and `1630` through `1633` are checked against valid `30617:<owner>:<identifier>` coordinates in `a` and `A` tags. Every recognized coordinate needs a grant entry. All except a comment (`1111`) require such a coordinate. Status kinds require `maintain`; all still require kind permission.
833+- Repository announcements/state (`30617`, `30618`) have their own Git admission and maintainer rules. An active `maintain` grant contributes repository authority through [`repository_maintainers.go`](../../internal/daemon/repository_maintainers.go); it does not implicitly add kind 30618. Private Git membership checks remain separate.
834+- The rate limiter is a sliding 60-second admission counter, shared by writes/imports through the gate and reset by process restart. It is not a persistent count of successfully stored events.
835+
836+Site upload TTL/encryption effects use [`agentUploadGrant` and `blobUploadTerms`](../../internal/daemon/services.go) and the blob service, not grant event signatures. `encrypted` on any site entry applies to all agent uploads; it is a MIME-based acceptance rule, not cryptographic proof of encryption. Grant scope is not a general read-access control list.
837+
838+## Management and discovery
839+
840+Owner and moderator methods are `listagents`, `pauseagent`, `resumeagent`, `revokeagent`, `pauseallagents` and `resumeallagents`. Single-agent methods take the public key as their first positional string. Corresponding MCP names are `list_agents`, `pause_agent`, `resume_agent`, `revoke_agent`, `pause_all_agents` and `resume_all_agents`. See [`agentExecute`](../../internal/daemon/management.go) and [`mcp_tools.go`](../../internal/daemon/mcp_tools.go).
841+
842+`listagents` returns entries with `pubkey`, `owner`, `event_id`, `name`, `expires`, `paused`, `revoked`, `scope` and `lastEvent`. Scope uses `kinds`, `rooms`, `repos`, `rate` and optional `wiki`, `jobs`, `sites`; repository objects use `owner`, `identifier`, `level`, and site objects use `label`, optional `ttl` and `encrypted`. These are effective projections, not replacement event templates.
843+
844+While an active grant exists, [`Capabilities`](../../internal/daemon/capabilities.go) adds `{"id":"agents","status":"enabled","reason":"agent identities under owner-signed grants"}` and [`information`](../../internal/daemon/http.go) includes the `agents` entry in relay information. This does not enumerate grants, encode version 1 or establish caller permission. Absence can mean no active grants, not that the implementation lacks this extension.
845+
846+## Errors, compatibility and evidence
847+
848+Malformed grants return `invalid: agent grant ...`; unauthorized signers return `restricted: only the owner or a moderator can grant an agent`. Inactive or out-of-scope agent writes return `restricted: agent grant ...`; site TTL violations return `invalid: agent grant allows site ...`. Preserve the message and apply the [transport-specific error rules](README.md#shared-transport-boundary).
849+
850+Evidence:
851+
852+- [`gates/agents_test.go`](../../internal/gates/agents_test.go): `TestAgentGrantShapeAndSigner`, `TestAgentGrantScopesKindsRoomsAndRepositories`, `TestAgentGrantStateRejectsWrites`, `TestAgentGrantRateLimitSlidesOverOneMinute`, `TestHumanMemberWithGrantKeepsMemberPermissions`, `TestImportEnforcesAgentGrant`, and site shape/expiry cases.
853+- [`daemon/agents_test.go`](../../internal/daemon/agents_test.go): `TestAgentGrantPublishCreatesRoleRowAndRevokesOnDeletion` and operator management/read cases.
854+- [`git_agent_maintainer_test.go`](../../internal/daemon/git_agent_maintainer_test.go) and [`blob_authorization_test.go`](../../internal/daemon/blob_authorization_test.go): host-specific Git and upload authority.
855+
856+Audit limits: The name's byte truncation can split UTF-8 and differs from the guide's character wording. Direct grant room syntax is broader than request room syntax. Cross-operator replacement ordering is not a portable conflict-resolution protocol. These are documented behavior/ambiguities, not runtime changes or new conformance claims.
857diff --git a/docs/extensions/callbacks.md b/docs/extensions/callbacks.md
858new file mode 100644
859index 0000000..394a6f7
860--- /dev/null
861+++ b/docs/extensions/callbacks.md
862@@ -0,0 +1,93 @@
863+# Event callbacks
864+
865+| Field | Value |
866+| --- | --- |
867+| Identifier | `callbacks` |
868+| Status | Experimental |
869+| Contract version | 1; no negotiated wire version |
870+| Owner | `internal/daemon` callback service, registration, matching and delivery; host visibility is supplied by `internal/gates`. |
871+| Wire | Project management methods plus HTTPS POST of one signed Nostr event. No registration event kind. |
872+
873+The [catalog's audit baseline and transport rules](README.md) apply. [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) supplies events and filter matching; NIP-86/NIP-98 supply the management/authentication foundations. Registration, HMAC headers, retries and quotas are project extensions, not NIP-01 subscriptions or an upstream webhook standard. No upstream revision or full conformance audit is asserted. See the [callback guide](../agents.md#callbacks) for receiver examples.
874+
875+This contract is **not** the signed push-registration/replication callback implemented by [`internal/replication/push.go`](../../internal/replication/push.go). That service has a different registration, payload and authority model. Sharing the pinned HTTP client does not make the protocols interchangeable.
876+
877+## Registration and management
878+
879+Use the tenant-root management endpoint or the corresponding MCP tool at `/mcp`:
880+
881+| Management method | MCP tool | Parameters/effect |
882+| --- | --- | --- |
883+| `listcallbacks` | `list_callbacks` | No parameters; list accessible registrations. |
884+| `addcallback` | `add_callback` | One object with `url`, `filter`, optional `secret`; creates a registration owned by the authenticated caller. |
885+| `removecallback` | `remove_callback` | First parameter is an ID string or `{"id":"..."}`; deletes the registration. MCP takes `id`. |
886+| `pausecallback` | `pause_callback` | Same ID forms; stops further delivery attempts. |
887+| `resumecallback` | `resume_callback` | Same ID forms; clears pause, failure count and last status. |
888+
889+Members and agents manage their own registrations. Owner/moderator roles may list and control all registrations; registration always belongs to the caller, not an arbitrary key in the options. Guests are rejected. The per-key policy allowance is `callbacks` (default 4); all stored registrations count, including paused ones. Zero prevents nonowner registration. Only the owner is exempt from the cap. Management mutations are audited.
890+
891+[`addCallback`](../../internal/daemon/callbacks.go) trims `url` and `secret`. The URL is at most 2,048 bytes after trimming, uses `https`, has a hostname, and has no credentials or fragment. Path, query and explicit port are allowed. Unknown top-level registration object fields are ignored by this decoder; filter fields are stricter.
892+
893+A supplied nonempty secret must be 16 to 128 bytes of ASCII `!` through `~`: embedded spaces are rejected despite the error's “printable ASCII” wording. An omitted/blank secret generates 32 random bytes encoded as 64 lowercase hex characters. Registration IDs are 16 random bytes encoded as 32 lowercase hex characters. Treat an ID as an opaque registration identifier, not authentication.
894+
895+Registration returns a summary plus `secret` once. Summary fields are `id`, `owner`, `url`, `host`, `filter`, `created`, `lastDelivery`, `lastStatus`, `failures` and `paused`. Times are Unix seconds. `host` includes an explicit port. List responses omit `url` for registrations owned by someone other than the caller, and never include secrets. Pause/resume return a summary; removal returns `{"id":"...","removed":true}`. **Audit gap:** pause/resume currently return `record.summary()` without the listing's cross-owner URL redaction. Do not interpret the redacted list as a guarantee that an operator cannot retrieve a path/query through a mutation response.
896+
897+## Filter contract
898+
899+The filter is a JSON object, or a JSON-encoded object string accepted for form compatibility. Matching uses [`event.Matches`](../../internal/event) through the callback's NIP-01 filter projection: OR within one field's values, AND across fields. Only these keys are accepted:
900+
901+| Key | Validation |
902+| --- | --- |
903+| `kinds` | Required array of 1 to 32 integers, each 0 through 65535. Sorted on storage; duplicates are not removed. |
904+| `authors` | Optional array of 1 to 8 lowercase 64-character hex public keys. Prefixes are not accepted. |
905+| `#e`, `#p` | Optional arrays of 1 to 8 lowercase 64-character hex IDs/keys. |
906+| `#a` | Optional array of 1 to 8 `<kind>:<pubkey>:<identifier>` strings. Kind is a nonempty digit string; pubkey is lowercase 64-character hex. This validator does not bound the kind numerically or require a nonempty identifier. |
907+| `#h` | Optional array of 1 to 8 room IDs matching `^[a-z0-9_-]{1,64}$`. |
908+| `since` | Accepted and ignored, without value validation. There is no callback history/backfill cursor. |
909+
910+Other keys, empty arrays and malformed values are rejected. At registration, an agent must have an active grant; every named `#h` must be a granted room, and recognized repository coordinates in `#a` must have a grant entry. Kinds are deliberately not constrained to the agent's publishing kinds. Omitting a room/repository filter does not grant read access; each event is separately checked by the host gate.
911+
912+## Delivery and authentication
913+
914+One delivery is `POST <registered url>` with the signed event object itself as JSON, not a batch, `EVENT` frame or retry envelope. The sender emits:
915+
916+| Header | Value |
917+| --- | --- |
918+| `Content-Type` | `application/json` |
919+| `X-Tiny-Callback` | Registration ID. |
920+| `X-Tiny-Signature` | `sha256=` plus lowercase hex HMAC-SHA256 of the exact request-body bytes, keyed by the secret's bytes. |
921+| `X-Tiny-Relay` | Configured public relay URL. |
922+| `User-Agent` | `tinyrelay` |
923+
924+A receiver should verify the MAC over the raw body with a constant-time comparison before acting, then validate the Nostr event if it needs proof of event authorship. The MAC proves possession of the registration secret, not the event author's identity. Header values other than the body MAC are not separately authenticated by that MAC. Neither `X-Tiny-Relay` nor the event's signature authorizes a local action at the receiver.
925+
926+The receiver acknowledges with any 2xx status within 10 seconds. Response content is ignored (the sender drains at most 4,096 bytes). Non-2xx responses, timeout and connection failure count as failed attempts. There is no challenge handshake, delivery timestamp, nonce or delivery-attempt header. Receivers must make side effects idempotent, normally using registration ID plus event ID; retries and uncertain acknowledgments can cause duplicates. No exactly-once or strict ordering guarantee is made.
927+
928+## Lifecycle and trust boundaries
929+
930+[`callbacks.go`](../../internal/daemon/callbacks.go), [`callbacks_delivery.go`](../../internal/daemon/callbacks_delivery.go) and [`event_followups.go`](../../internal/daemon/event_followups.go) define the lifecycle:
931+
932+- Stored event acceptance captures eligible registrations for later work. Optional local planning failure does not reject a valid source event. Recovery is bounded by a 15-minute age limit and three background worker planning attempts, separately from delivery retries. Successfully planned child work retains its identity. Failed discovery recovery excludes registrations created at or after the acceptance second, so uncertain same-second registrations can be skipped.
933+- Repository-state callbacks are planned after object promotion, rather than waking a receiver while objects are missing. Ephemeral events use a separate immediate planning path whose intent carries the event; they do not gain durable source history or the stored-event planning recovery guarantee.
934+- Before each POST the service rereads the registration, skips missing/paused registrations, checks current membership and applies `Gate.CanSee` to the queued event. A key with no remaining membership has its callback paused. Gate checks include bans, held events, host read policy, rooms and recipients.
935+- Each callback has at most one in-process POST at a time. A failed event gets two further scheduled attempts, after one minute and then five minutes. The third is final. Each failed HTTP attempt increments consecutive failures; success resets them. At 20 failures the callback pauses. Resumption does not promise replay of skipped or exhausted events.
936+
937+Outbound URLs use the shared [`NewPinnedClient`](../../internal/replication/callback.go), which dials resolved public addresses directly and refuses redirects. The lexical registration check rejects private/loopback IPs and local-style hostnames. A relay configured with a private/loopback public URL deliberately relaxes private-target restrictions for local use; HTTPS and redirect refusal remain. This is a deployment trust switch, not an exemption clients can request. DNS filtering is the implementation's explicit address-deny list, not a claim of a complete SSRF audit.
938+
939+Callbacks intentionally disclose the entire readable signed event to an external service. Secrets and URL tokens require protection in storage and at the receiver. Revocation cannot recall an already sent request.
940+
941+**Read-state limits:** Delivery does not rerun `checkCallbackScope` or `AgentGrant.Active`; `Gate.CanSee` is a read gate, not a write-grant check. Paused/expired agents can retain membership/read access, so pausing a grant alone is not a guaranteed webhook kill switch. Pause the callback to stop it. Delivery checks the queued event payload, not a fresh source lookup; deletion/expiration alone is not a guaranteed cancellation of already planned deliveries. Do not broaden the guide's read-recheck wording into guarantees the implementation does not enforce.
942+
943+## Errors, discovery and evidence
944+
945+Registration/shape/quota errors use `invalid:`; role/scope errors use `restricted:`; missing IDs return `not found: callback`. Delivery records bounded status reasons such as `ok`, `HTTP 500`, `timeout`, `connection failed`, or `paused after 20 failures: ...`. Apply the [transport error rules](README.md#shared-transport-boundary).
946+
947+Discover method names with `supportedmethods`, and MCP names/schemas with `tools/list`; `/llms.txt` also describes the tools. These surfaces do not negotiate callback version 1 or promise permission to register.
948+
949+Evidence:
950+
951+- [`callbacks_test.go`](../../internal/daemon/callbacks_test.go): `TestCallbackRegistrationValidatesURLFilterScopeAndCap`, `TestCallbackManagementPermissionMatrix`, `TestCallbackMatchesFilterAndKeepsPrivateEventsBehindTheGate`, `TestCallbackDeliverySignsBodyRetriesAndPauses`, `TestCallbackDeliveryRechecksTheGateAndMembership`, `TestMCPCallbackTools`.
952+- [`callback_followups_test.go`](../../internal/daemon/callback_followups_test.go): planning failure, preserved completed work, paused/removed/later registrations, exhaustion and independent view failure.
953+- [`replication/callback.go`](../../internal/replication/callback.go) supplies the shared pinned HTTP client; this audit found no direct pinned-HTTP-client test in the replication test files. The callback restart test in [`callback_restart_acceptance_test.go`](../../internal/daemon/callback_restart_acceptance_test.go) exercises **replication** callback work, not proof of this HMAC webhook's end-to-end restart delivery.
954+
955+The URL-redaction, grant-state and queued-source limits above are unresolved implementation boundaries. This specification neither fixes them nor claims tests for stronger guarantees.
956diff --git a/docs/extensions/custom-view-transforms.md b/docs/extensions/custom-view-transforms.md
957new file mode 100644
958index 0000000..e546019
959--- /dev/null
960+++ b/docs/extensions/custom-view-transforms.md
961@@ -0,0 +1,122 @@
962+# Custom-view block transforms
963+
964+| Field | Value |
965+| --- | --- |
966+| Identifier | `custom-view-transforms` |
967+| Status | Experimental |
968+| Contract version | 1; no negotiated wire version |
969+| Owner | `internal/daemon` definitions, scheduling, transform HTTP, artifacts and access; `internal/views` fenced blocks/hash/path contract; `internal/records` relay signing identity. |
970+| Wire | Project management methods, HTTPS JSON transform exchange, relay-signed kind `30078` artifacts and `/views/` media routes. |
971+
972+The [catalog's audit baseline and transport rules](README.md) apply. [NIP-78](https://github.com/nostr-protocol/nips/blob/master/78.md) supplies kind 30078 application data, with NIP-01 addressable events, NIP-40 expiration and NIP-86/NIP-98 management foundations. The `bind.ws/view/` namespace, source/block tags, transform exchange and audience rules are project-defined. Neither the namespace nor the `X-Transform-*` name claims upstream standardization. No upstream revision or complete conformance audit is asserted.
973+
974+This contract renders fenced code blocks. It is not the [reusable view proposal](../view-definitions.md), a generic whole-event transform or the built-in `listviews` summaries. The [Custom views guide](../views.md) covers operation and presentation.
975+
976+## Definition and authority
977+
978+All methods, including listing, require the current `owner` role; moderators, members and agents cannot configure a transform. A permitted author may still trigger an owner-configured write view by publishing matching source content. Defining a view authorizes disclosure of those blocks to that external transform.
979+
980+| Management method | MCP tool | Effect |
981+| --- | --- | --- |
982+| `listcustomviews` | `list_custom_views` | Lists definitions without secrets. |
983+| `addcustomview` | `add_custom_view` | Takes one definition object; creates a new named view and returns its secret once. Existing names are rejected, not updated. |
984+| `removecustomview` | `remove_custom_view` | Deletes the view and its artifacts. |
985+| `pausecustomview` | `pause_custom_view` | Disables transform requests; retains artifacts. |
986+| `resumecustomview` | `resume_custom_view` | Enables the view and clears failure count/status. |
987+| `runcustomview` | `run_custom_view` | Queues a bounded backfill; `refresh=true` rebuilds existing artifacts. A paused view must be resumed first. |
988+
989+Use the [shared management transport](README.md#shared-transport-boundary). Named methods accept a first positional name string or `{"name":"..."}`; run also accepts `{"name":"...","refresh":true}`. MCP uses named `name` and optional `refresh` fields. Source: [`custom_views.go`](../../internal/daemon/custom_views.go) and [`mcp_tools.go`](../../internal/daemon/mcp_tools.go).
990+
991+| Definition field | Version 1 validation |
992+| --- | --- |
993+| `name` | Trimmed, matches `^[a-z0-9-]{1,32}$`. |
994+| `kinds` | 1 to 16 integers, each 0 through 65535; sorted/deduplicated after the count check. |
995+| `transform` | Trimmed HTTPS URL using the [callback URL restrictions](callbacks.md#registration-and-management), including 2,048-byte limit and no credentials/fragment. |
996+| `languages` | 1 to 32 entries, then trimmed/lowercased/deduplicated; each is 1 to 32 bytes using `a-z`, `0-9`, `+`, `-`, `_`, `.` or `#`. |
997+| `trigger` | Trimmed `write` or `hourly`; absent/empty defaults to `write`. |
998+| `audience` | Trimmed `public` or `members`; absent/empty defaults to `public`. |
999+| `max_bytes` | Artifact-byte cap, 1 through 4 MiB; omitted, null or empty string defaults to 1 MiB. Integer or numeric string is accepted by management. |
1000+| `secret` | Same trimmed 16-to-128-byte nonspace ASCII rule as callbacks; generated as 64 lowercase hex characters if omitted/blank. |
1001+
1002+Management additionally accepts kinds as numeric strings or a delimited string and languages as a delimited string (comma, space or newline separators). The MCP schema is narrower; clients should use arrays and integers. Unknown definition object fields are ignored by the management decoder.
1003+
1004+Summaries contain `name`, `kinds`, `transform`, `host`, `trigger`, `audience`, `languages`, `maxBytes`, `enabled`, `state` (`active`/`paused`), `created`, `lastRun`, `lastStatus` and `failures`. `max_bytes` is input spelling; `maxBytes` is output spelling. Run adds `queued`; removal returns `name` and `removed:true`. Neither summaries nor an enabled view imply a completed render.
1005+
1006+## Block selection and identity
1007+
1008+[`internal/views/views.go`](../../internal/views/views.go) defines the shared wire/cache identity:
1009+
1010+- A fence opens with at least three backticks or tildes after trimming the line. The first word of the remaining info string, lowercased, is the language. A closing fence contains only the same marker character with at least the opening run's length. An unterminated fence consumes the remaining text.
1011+- Input CRLF becomes LF. Block source is the lines between fences joined with LF, without an added trailing newline. Index is zero-based across **all** fenced blocks, including unnamed and nonmatching languages.
1012+- Only nonempty languages in the view's configured list are sent. Ordinary source is event content. Kind `30618` instead reads the repository README at the commit referenced by its `HEAD` tag, trying `README.md`, `readme.md`, then `README`; missing/binary/empty files are skipped.
1013+- Hash is lowercase hexadecimal SHA-256 of UTF-8 bytes `lang + "\n" + source`. It is not an artifact-body hash. Cache identity is view name plus hash; equal blocks across events share one artifact. Normal runs reuse existing artifacts; refresh bypasses reuse.
1014+
1015+## Transform request and answer
1016+
1017+One POST per source carries only pending matching blocks:
1018+
1019+```json
1020+{"relay":"https://relay.example","view":"diagrams","source":{"id":"<source key>","kind":30818},"blocks":[{"index":2,"lang":"mermaid","source":"graph TD;\n A-->B;"}]}
1021+```
1022+
1023+`source.id` is the event ID except for kind 30618, where it is the address `30618:<state author pubkey>:<d>`. That special form exposes the repository state author/identifier and is not a 64-character event ID. No full event, separate tags, author or timestamp fields are transmitted; block content and source identity still can disclose private information.
1024+
1025+Headers are `Content-Type: application/json`, `Accept: application/json`, `User-Agent: tinyrelay`, `X-Transform-View`, `X-Transform-Relay` and `X-Transform-Signature`. Their values are the view name, configured public relay URL, and `sha256=` plus lowercase hex HMAC-SHA256 over the exact body bytes with the view secret. Legacy `X-Tiny-View`, `X-Tiny-Relay` and `X-Tiny-Signature` carry identical values. New integrations should use `X-Transform-*`; existing aliases remain part of this version.
1026+
1027+Receivers should verify the body MAC in constant time before rendering. This proves shared-secret possession, not source author approval or semantic correctness. No timestamp/nonce or response signature is negotiated. TLS authenticates the response endpoint. The relay waits up to 30 seconds, refuses redirects and uses the shared pinned-client address restrictions, with the configured local/private-host exception described in [Callbacks](callbacks.md#lifecycle-and-trust-boundaries).
1028+
1029+A successful endpoint returns a 2xx status and JSON such as:
1030+
1031+```json
1032+{"artifacts":[{"block":2,"type":"image/svg+xml","body":"<svg ...></svg>","engine":"mermaid"}],"errors":[{"block":5,"error":"unknown shape"}]}
1033+```
1034+
1035+`artifacts` and `errors` are arrays of the shown objects. No extra response fields are required; unknown object fields are ignored. The body read is limited to `4 * (4 MiB) + 1 MiB`; truncated/malformed JSON is a run failure. `block` must identify a block actually sent. Unsent-block responses and invalid artifacts are refused individually, not treated as transport failures. Missing artifacts leave code (or a prior artifact on refresh) available. `errors` is counted for diagnostics, not a signed decision or automatic retry directive.
1036+
1037+### Artifact validation
1038+
1039+[`checkArtifact`, `checkSVG` and `storeArtifact`](../../internal/daemon/custom_views_transform.go) enforce:
1040+
1041+- Exact type `image/svg+xml` or `image/png`.
1042+- SVG body length at most `max_bytes`. It must contain `<svg` case-insensitively. Regex checks reject script elements, `on...=` handlers, foreign objects and `href`/`xlink:href` targets other than empty strings or internal `#` fragments. The original body is rejected or retained, not sanitized.
1043+- PNG body is trimmed Base64 (standard alphabet, padded or unpadded). Decoded length must fit `max_bytes` and begin with the PNG signature. Storage normalizes it to padded Base64. This is a signature-prefix check, not complete PNG decoding or validation.
1044+- `engine` is optional, trimmed and truncated to 64 bytes before recording.
1045+
1046+The SVG checks are not an XML/CSS parser or proof that every external-load syntax is absent. Consumers must retain the sandbox boundary below. No guarantee of a valid render, safe arbitrary SVG execution or faithful interpretation of source code follows from acceptance. A valid JSON/2xx run resets failures and records `ok` even if every returned artifact is refused or `errors` is nonempty; check actual artifact availability rather than status alone.
1047+
1048+## Signed records and media routes
1049+
1050+[`signArtifact`](../../internal/daemon/custom_views_transform.go) asks [`internal/records`](../../internal/records/identity.go) to sign kind 30078 with the durable relay identity, not the tenant operator or source author. The record content is SVG text or padded Base64 PNG. Tags are:
1051+
1052+- `["-"]`, `["d","bind.ws/view/<name>/<hash>"]`, `["view","<name>"]`.
1053+- One `e` source tag for an ordinary event ID, or `a` source tag for a repository-state address, for each recorded source.
1054+- One `["block","<decimal index>"]` for each source, in the same source order. These block tags follow the source tags; they are not adjacent source/block pairs.
1055+- `["type","image/svg+xml"]` or `["type","image/png"]`, optional `engine`, and optional `expiration`.
1056+
1057+If all sources expire, artifact expiration is the latest source expiration. Any nonexpiring source removes the expiration tag. Source deletion/replacement/maintenance detaches obsolete references and removes an orphan artifact; pending transform work can defer pruning. Expiry is therefore not an unconditional immediate HTTP-route check. Removing a view deletes all its artifacts. Refresh retains prior artifacts until valid replacements arrive.
1058+
1059+Public artifacts use `records.Generate` and enter normal event storage. Members-only artifacts use `records.Sign` and remain signed in the artifact store without being published to normal event queries. The signature attests to the relay's generated output, not endorsement by any source author.
1060+
1061+`GET` and `HEAD /views/<name>/<hash>` serve the stored media type; `.svg` and `.png` aliases are accepted only when they match that type. Invalid/missing paths or type mismatches return 404; other methods return 405. Authentication failures return 401; members-audience requests require role `member`, `moderator` or `owner` and otherwise return 403. **Role `agent` is not admitted by this members-only route.** Public artifacts use the relay's general browse-read rule, not a per-source visibility check.
1062+
1063+Responses set stored `Content-Type`, `Content-Length`, `X-Content-Type-Options: nosniff`, and `Content-Security-Policy: sandbox; default-src 'none'; style-src 'unsafe-inline'`. `ETag` is quoted lowercase SHA-256 of served bytes. Exact `If-None-Match` equality yields 304 after access checks. Cache control is `private, no-cache` for members and `public, no-cache` for public artifacts. See [`custom_views_http.go`](../../internal/daemon/custom_views_http.go).
1064+
1065+**Disclosure boundary:** Audience is attached to the view/artifact, not inherited from a source's room, proposal, wiki approval or repository ACL. A public view can expose an artifact of a nonpublic or pending source; a members view can expose it to members who cannot read that source. Transformation itself uses current source presence, not author-specific read authorization. Only configure a transform/audience appropriate for everything its kinds/languages can select. Hash-based paths and relay signatures are not access control or encryption.
1066+
1067+## Scheduling, errors and discovery
1068+
1069+Write views capture registrations and queue optional work after source acceptance; repository-state work waits for available objects. [`event_followups.go`](../../internal/daemon/event_followups.go) bounds failed planning recovery separately from HTTP retries and excludes later/uncertain same-second registrations during failed-discovery recovery. Registration generation/fingerprint checks prevent old work from targeting a newly created view with the same name.
1070+
1071+Hourly/manual runs consider at most the newest 500 events of the selected kinds; an hourly pass applies a last-run time window with overlap. This is not an unbounded backfill or guarantee to process every event. Transforms serialize per view. Before a POST, the worker checks enabled state, registration generation and whether the source is still current. Those checks do not recall an in-flight request.
1072+
1073+Non-2xx, timeout, connection/read failure or invalid JSON produces a failed run. Two retries follow, after one minute and five minutes, for three attempts total. Twenty consecutive failures pause the view; success/resume clears the counter. Reasons include `HTTP <status>`, `timeout`, `connection failed`, `read failed` and `invalid response`. Management validation uses `invalid:`, authority uses `restricted: only the owner may ...`, and missing definitions use `not found: view`. See the [transport rules](README.md#shared-transport-boundary).
1074+
1075+Discover management names with `supportedmethods`, MCP schemas with `tools/list`, and definitions with the owner-only list method/tool. The frontend receives only enabled view names/languages through `CustomViews`; that is presentation data, not endpoint/secret disclosure or authority to configure the view. There is no transform version capability or handshake.
1076+
1077+## Evidence and audit limits
1078+
1079+- [`views_test.go`](../../internal/views/views_test.go) exercises block parsing, identity, path and presentation behavior.
1080+- [`custom_views_test.go`](../../internal/daemon/custom_views_test.go): `TestCustomViewManagementIsOwnerOnlyAndValidated`, `TestCustomViewTransformSendsBlocksAndKeepsSignedArtifacts`, replacement/reuse/rebuild, retries, artifact validation, members-only storage, hourly runs, repository README and MCP tools.
1081+- [`custom_views_targets_test.go`](../../internal/daemon/custom_views_targets_test.go) tests recreated registration rejection and stable generation backfill; [`custom_views_media_test.go`](../../internal/daemon/custom_views_media_test.go) tests extensionless media routes.
1082+
1083+Repeated identical blocks in one source share a `(view,hash,source)` record with one block index, not one entry per occurrence. There is no explicit duplicate-artifact response rejection, complete PNG/SVG parser, per-source audience inheritance or full remote-renderer conformance suite. These limits, the kind 30618 source-address disclosure and the `ok`/artifact-success distinction are intentional audit disclosures, not newly introduced wire behavior.
1084diff --git a/docs/extensions/tinyclient-read-adapter.md b/docs/extensions/tinyclient-read-adapter.md
1085new file mode 100644
1086index 0000000..8a1f0f1
1087--- /dev/null
1088+++ b/docs/extensions/tinyclient-read-adapter.md
1089@@ -0,0 +1,83 @@
1090+# Tinyclient read adapter
1091+
1092+| Field | Value |
1093+| --- | --- |
1094+| Identifier | `tinyclient-read-adapter` |
1095+| Status | Experimental |
1096+| Contract revision | 1, tied to this repository revision; no version negotiation |
1097+| Owner | `tinyclient` defines the presentation contract; the tinyrelay backend owns data and authorization. |
1098+| Transport | HTTP GET, JSON success bodies, plain-text error bodies. |
1099+| Endpoint | `/api/tinyclient`, under the selected tenant prefix when present. |
1100+
1101+This project extension supports a separately running frontend. It is not a Nostr NIP, a general relay query protocol or a replacement for signed mutation APIs. Run compatible frontend and backend revisions together. No event kinds, tags or signing formats are introduced.
1102+
1103+## Authentication and scope
1104+
1105+The backend resolves the caller using its normal actor resolver. A public-key query parameter or header is not authentication. The standalone frontend forwards the browser session cookie when loading the presentation snapshot. For method and typed reads, an empty requested actor means anonymous access: the frontend omits the cookie even if the browser is signed in. A nonempty requested actor must match the actor authenticated in that request's snapshot; a mismatch fails before contacting the backend. Every backend query and typed read still resolves authentication and applies its own access checks.
1106+
1107+Keep the frontend's public origin and tenant path the same as the backend's configured public URL. The private backend address may differ. Existing NIP-98 proofs remain bound to their original URL, method and payload; the frontend proxies signed requests without translating or replaying them as adapter reads. Signed HTML requests use the integrated renderer.
1108+
1109+Responses carry `Cache-Control: private, no-store`. The frontend must not cache private snapshots for use after an authorization or backend failure. It refuses an unexpected public Host and does not follow redirects when fetching read-adapter responses.
1110+
1111+## Request forms
1112+
1113+All forms use GET. If `op` is nonempty it selects a typed read before `method` is considered.
1114+
1115+### Presentation snapshot
1116+
1117+With no `op` or `method`, the response is a JSON object with:
1118+
1119+- `policy`: Presentation policy, using the public `Policy` structure.
1120+- `url`, `slug`, `identity`: Relay presentation identity.
1121+- `actor`: Public key resolved by the backend, or an empty string for an anonymous caller.
1122+- `version`, `revision`: Backend build metadata.
1123+- `rooms`, `activity`: Whether the backend provides the corresponding optional typed interfaces.
1124+- `share_presence`: The authenticated caller's presence preference.
1125+- `views`: Optional custom-view presentation definitions.
1126+- `read_error`: Optional private-access failure used to render a restricted page.
1127+
1128+Nonowners receive a reduced policy without owner-only operational settings. A caller barred from private relay reads receives a masked presentation snapshot with `read_error`, rather than private relay identity or configuration. The snapshot does not grant permission to perform later reads.
1129+
1130+The authoritative field definitions are [`remoteSnapshot`](../../tinyclient/remote.go), [`Policy`](../../tinyclient/contracts.go) and their referenced public types. Readers should tolerate additional object fields. Changes to existing field meanings require a contract revision and compatibility review.
1131+
1132+### Method reads
1133+
1134+Supply `method=<name>` and optional `params=<JSON-array>`. Method names must appear in the read-only allowlist in [`remoteReadMethods`](../../tinyclient/remote.go). The adapter passes the decoded parameters and authenticated actor to `Backend.Query` and returns its JSON result directly, without a JSON-RPC envelope.
1135+
1136+Methods cover presentation reads such as repository browsing, wiki, rooms, social content, approvals and management lists. The allowlist does not override method authorization. Mutations are never dispatched through this contract. Method parameter and result semantics remain those of the corresponding backend read operation; this revision is coupled to the repository's backend contracts rather than a standalone third-party API.
1137+
1138+### Typed reads
1139+
1140+| `op` | Parameters | Backend contract |
1141+| --- | --- | --- |
1142+| `rooms` | `cursor`, `limit` | `RoomsReader.ListRooms` |
1143+| `room` | `room`, `cursor`, `limit` | `RoomsReader.ReadRoom` |
1144+| `thread` | `room`, `root`, `cursor`, `limit` | `RoomsReader.ReadThread` |
1145+| `activity` | `room`, `root` | `ChatActivityReader.ReadChatActivity` |
1146+
1147+Results use the typed interfaces in [`read_contracts.go`](../../tinyclient/read_contracts.go) and [`chat-activity.go`](../../tinyclient/chat-activity.go). The backend owns room access, cursor validation and limit bounds. The current adapter converts an absent or unparseable limit to zero; callers should send a valid decimal limit rather than rely on that fallback.
1148+
1149+## Errors
1150+
1151+| Status | Meaning |
1152+| --- | --- |
1153+| 200 | A successful JSON read, including a deliberately masked private snapshot. |
1154+| 400 | A method outside the allowlist or malformed JSON method parameters. |
1155+| 401 | The actor resolver rejected authentication. |
1156+| 403 | Private access or a backend operation was refused; also used for an unsupported typed `op`. |
1157+| 405 | Non-GET method; response includes `Allow: GET`. |
1158+| 501 | Requested typed interface is unavailable. |
1159+
1160+Do not parse plain-text errors as stable machine identifiers. These statuses describe the current binding, not a universal error model for the project. The standalone frontend returns 502 if it cannot load a valid snapshot, rather than rendering stale private data.
1161+
1162+## Discovery and compatibility
1163+
1164+The endpoint is configured as part of a compatible tinyrelay deployment; it is not advertised as a NIP capability. `rooms` and `activity` describe optional read interfaces, not full protocol compliance. Clients that do not use the adapter continue using the integrated website and existing relay interfaces.
1165+
1166+The adapter does not migrate sessions across origins, accept another tenant path, own a database or hold a relay signing key. Each standalone frontend process serves one tenant. A root-mounted frontend returns 404 for reserved `/r/<name>` paths before rendering or forwarding any request, including signed requests and WebSocket upgrades. A prefixed frontend accepts only its configured tenant path. Unknown method names fail closed; adding a method requires an authorization review and tests before it enters the allowlist.
1167+
1168+## Verification
1169+
1170+The contract is exercised by [`remote_auth_test.go`](../../tinyclient/remote_auth_test.go), [`remote_rooms_test.go`](../../tinyclient/remote_rooms_test.go), [`remote_proxy_test.go`](../../tinyclient/remote_proxy_test.go), [`remote_browser_test.go`](../../tinyclient/remote_browser_test.go) and the [real-backend integration test](../../internal/daemon/tinyclient_integration_test.go).
1171+
1172+Tests cover valid reads, invalid methods, forged actors, private snapshots, session revocation, signed operation forwarding and frontend rendering parity. Regression coverage in [`remote_feed_test.go`](../../tinyclient/remote_feed_test.go), [`remote_actor_test.go`](../../tinyclient/remote_actor_test.go) and [`remote_scope_test.go`](../../tinyclient/remote_scope_test.go) verifies anonymous feeds with a browser session, actor preservation across method and typed reads, and tenant isolation before rendering or proxying. Run the commands in the [tinyclient guide](../../tinyclient/README.md#test-and-rebuild-browser-bundles).
1173diff --git a/docs/fixi.md b/docs/fixi.md
1174index bf81172..6594b03 100644
1175--- a/docs/fixi.md
1176+++ b/docs/fixi.md
1177@@ -1,6 +1,6 @@
1178 # Vendored Fixi integration
1179
1180-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).
1181+The web UI vendors the all-in-one Fixi bundle at `tinyclient/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).
1182
1183 The vendored file's SHA-256 is:
1184
1185diff --git a/docs/module-contracts.md b/docs/module-contracts.md
1186new file mode 100644
1187index 0000000..fc95c02
1188--- /dev/null
1189+++ b/docs/module-contracts.md
1190@@ -0,0 +1,83 @@
1191+# Module boundaries and protocol contracts
1192+
1193+The project is a monorepo. `tinyrelay` is the relay host, `tinygit` is the Git engine and standalone Git service, and `tinyclient` is the browser interface and standalone frontend. Separate executables do not imply separate Go modules, databases or network services for every package.
1194+
1195+The Git and frontend components, public `protocol/nostr` and `protocol/auth` packages, and `tinygit.ParseMetadata` establish the current boundaries. Blob, room and narrower community/records services below remain proposed follow-up work.
1196+
1197+## Keep protocol rules separate from access policy
1198+
1199+Apply checks in this order:
1200+
1201+1. Parse the wire representation and verify its signature or request proof.
1202+2. Validate the feature's protocol structure and semantics.
1203+3. Apply host policy: membership, grants, visibility, quotas and enabled features.
1204+4. Commit the event, feature projection and required work together.
1205+5. Expose the result through the relevant transport and read model.
1206+
1207+A valid Nostr event can still be refused by host policy. A policy refusal is not evidence of an invalid signature or a protocol violation. Conversely, an owner or maintainer role must never make a malformed signed event valid.
1208+
1209+The client can validate a draft for feedback and verify a signer's result, but server-side validation remains authoritative. HTTP, WebSocket and MCP adapters must use the same feature operation rather than implement separate admission rules.
1210+
1211+## Recommended seams
1212+
1213+| Boundary | Owns | Must not own |
1214+| --- | --- | --- |
1215+| Public `protocol/nostr` | Event and filter types, canonical serialization, event IDs, signatures and common encoding rules. | Tenant configuration, membership, SQL or feature-specific tags. |
1216+| Public `protocol/auth` | Verification for HTTP, WebSocket and service-specific proof profiles; authenticated actor and proof scope. | Deciding what that actor may do. |
1217+| Host access policy | Membership, agent grants, visibility, quotas and admission decisions. | Reinterpreting signed fields or weakening feature protocol validation. |
1218+| `tinygit` | Git repository metadata, signed refs, object storage, smart HTTP, repair and Git-specific protocol rules. | Rooms, browser sessions or general relay management. |
1219+| `tinyclient` | Server-rendered pages, browser interactions, draft construction and signer-result verification. | Authoritative permissions, repository mutation or event persistence. |
1220+| Blob service, a candidate for a later `tinyblob` extraction | Content-addressed storage, upload and download contracts, integrity and applicable Blossom behavior. | Site manifests, room membership or a frontend. |
1221+| Site service | Signed site manifests, routing and site lifecycle over a blob-store contract. | Owning another copy of blob storage or upload authorization. |
1222+| Room service | Room state and message projections, with the room protocols it implements. | General agent grants or all community administration. |
1223+| Host assembly | Tenant lifecycle, transaction coordination, worker ownership and adapter wiring. | A second implementation of feature rules. |
1224+
1225+Keep domain-specific protocol rules with their feature. Do not create a universal `nips` package containing every numbered NIP: that would recreate the coupling under a new name. A NIP that crosses authentication, transport and persistence boundaries should have one documented ownership map and shared conformance fixtures, not one oversized implementation package.
1226+
1227+`records` and `community` are candidates for narrower internal services before another standalone executable. Their existing contracts span several responsibilities; splitting by durable state and operation ownership is more useful than renaming the packages.
1228+
1229+## Define the contract before moving dependencies
1230+
1231+Each public feature boundary should document:
1232+
1233+- Inputs, outputs and stable error categories.
1234+- The authenticated actor and authorization context it accepts.
1235+- Which protocol checks it performs and which policy decisions the host supplies.
1236+- Whether it owns a transaction or joins one supplied by the host.
1237+- Idempotency, ordering and retry behavior.
1238+- Cancellation, resource ownership and shutdown.
1239+- Side effects and the point at which they become visible.
1240+- Capability differences between standalone and integrated operation.
1241+
1242+Use small, consumer-owned interfaces for access decisions, persistence and optional transports. Prefer feature-specific read models to a universal backend interface. Moving packages must not break the atomicity of event persistence, projections and durable work.
1243+
1244+`tinygit.Event` and the frontend room event type now originate in public `protocol/nostr`. Public `protocol/auth` depends on those primitives, not host services. Internal aliases preserve one implementation, type identity, verifier replay state and error sentinels. Feature kind constants, room-reply helpers and private-kind search exclusions remain internal rather than becoming generic protocol rules.
1245+
1246+`tinygit` still shares internal policy and SQLite storage with the host. A narrow persistence contract is a later seam, not completed by moving event types. External-consumer and transitive dependency tests guard the public protocol packages. `tinygit.ParseMetadata` validates metadata shape without asserting signature validity or authority; its admission entry points combine these checks before consulting host policy.
1247+
1248+## Describe standards and project extensions separately
1249+
1250+Protocol documentation should classify each behavior as one of:
1251+
1252+- **Upstream protocol:** Behavior specified by a named NIP, GRASP specification, Blossom document or other upstream specification. Cite the document and the revision used by conformance tests.
1253+- **Supported profile:** The subset or combination implemented by a particular executable, with omissions and required options stated explicitly. Do not imply full compliance when required behavior is absent.
1254+- **Project extension:** Additional wire behavior defined by this project. Describe how an ordinary client behaves without it and how peers discover support.
1255+- **Local implementation detail:** Storage paths, queues, indexes or internal APIs that are not an interoperability contract.
1256+
1257+For each project extension, create a specification under `docs/extensions/` when its behavior has been audited. Each specification should include a stable identifier, status, version, owning module, upstream references, exact event kinds and tag shapes or endpoint schemas, validation rules, authorization requirements, errors, discovery, security considerations and compatibility rules. Include valid and invalid fixtures and link the implementation and tests.
1258+
1259+The [extension catalog](extensions/README.md) indexes specifications for agent grants and grant requests, callbacks, custom-view transforms and the tinyclient HTTP read adapter. These describe implemented behavior and its audited limits; they are not full upstream conformance certificates. Continue auditing remaining custom bindings without repeating each specification in the catalog. Existing behavior is not automatically standardized, and an implementation detail should not be promoted into a wire extension merely because it is unusual.
1260+
1261+Keep transport-specific method names in their adapters. A feature operation may have HTTP, MCP and browser bindings, but its domain contract and validation must have one owner. Generate discovery and schema checks from the same definitions where practical; do not create a parallel hand-maintained registry with different defaults.
1262+
1263+## Compatibility and terminology
1264+
1265+Use `tinyrelay` for the host, `tinygit` for Git functionality and `tinyclient` for frontend functionality in package documentation, command help and architecture descriptions. Reserve “module” for a functional boundary unless explicitly discussing a Go module.
1266+
1267+Do not apply blanket replacements to protocol or persisted identifiers. Existing event kinds, tags, endpoints, database schemas, Git hooks, `.tinyrelay` journal paths and `tiny.*` browser APIs remain compatibility surfaces until an explicit migration is designed and tested. A historical storage name can remain while surrounding documentation identifies its current owner accurately.
1268+
1269+## Verification
1270+
1271+Keep the existing feature tests when moving implementation files. Add external-consumer build checks, standalone service tests and dependency checks that reject importing the host assembly from a standalone component. Use shared protocol fixtures for server validation and browser construction where their responsibilities overlap.
1272+
1273+For each extraction, compare the integrated service with the baseline on the same fixtures. Cover valid and invalid events, authorization failures, persistence and restart, HTTP behavior and rendered content. Test the standalone profile separately and state unsupported features; a passing standalone subset is not full integrated parity.
1274diff --git a/docs/testing.md b/docs/testing.md
1275index 2656f74..0fa1054 100644
1276--- a/docs/testing.md
1277+++ b/docs/testing.md
1278@@ -11,6 +11,20 @@ make test-race
1279
1280 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.
1281
1282+## Component checks
1283+
1284+Build all executables with `make build`. Run the focused component suites with:
1285+
1286+```sh
1287+go test -race ./protocol/... ./internal/auth ./internal/event ./tinygit ./cmd/tinygit ./tinyclient ./cmd/tinyclient
1288+go test ./internal/daemon -run TestStandaloneTinyclient
1289+npm run test:tinyclient
1290+```
1291+
1292+The tinygit command test exercises a real binary through signed metadata publication, Git push, clone, unauthorized-ref rejection and restart. The tinyclient integration test compares the standalone frontend with the integrated renderer using a real backend, including session authentication, signed writes, logout and private metadata masking. Public-consumer and dependency checks guard the package boundaries.
1293+
1294+Run `make verify` for the full integrated regression suite before shipping. Standalone tests verify the documented component profiles, not every integrated capability. During an extraction, also compare the baseline and refactored binaries using the same fixture data and check that moved browser assets and existing tests were retained. See the [module contracts](module-contracts.md#verification).
1295+
1296 ## Remote Linux host
1297
1298 `scripts/test-linux.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 `~/tinyrelay-tests/` in the remote home directory; it never runs global Docker cleanup and never edits host configuration.
1299@@ -88,6 +102,6 @@ The raw JSON records failures as failures; interrupted or absent phases are not
1300
1301 ## Git collaboration checks
1302
1303-Run `npm run test:collaboration` to check signed issue, reply, status and pull request events. Run `go test ./internal/daemon ./internal/gitrelay ./internal/replication ./internal/webui` for collaboration queries, access controls, synchronization, Git repair and page rendering.
1304+Run `npm run test:collaboration` to check signed issue, reply, status and pull request events. Run `go test ./internal/daemon ./tinygit ./internal/replication ./tinyclient` for collaboration queries, access controls, synchronization, Git repair and page rendering.
1305
1306 The UX fixture includes an issue, a threaded comment, a status change and a pull request with a real Git diff. Use a disposable daemon when testing signed browser publishing.
1307diff --git a/docs/views.md b/docs/views.md
1308index 9b7c0bc..36b7d67 100644
1309--- a/docs/views.md
1310+++ b/docs/views.md
1311@@ -6,6 +6,8 @@ Block transforms can support diagrams, charts or other code-based images. They d
1312
1313 A relay with no block transforms keeps showing code. The source remains available beneath each image. If an image fails to load, the page opens its source when JavaScript is available; without JavaScript, open **Source** to read it.
1314
1315+The [custom-view transform contract](extensions/custom-view-transforms.md) defines this project extension, its wire exchange, artifact records and audited limits. It is not a general event-transform standard.
1316+
1317 ## Define a view
1318
1319 Call the `addcustomview` management method, or the `add_custom_view` MCP tool, as the owner:
1320@@ -35,7 +37,7 @@ A view posts one request for each source event, carrying only the blocks:
1321 {"relay": "https://relay.example", "view": "diagrams", "source": {"id": "<source>", "kind": 30818}, "blocks": [{"index": 2, "lang": "mermaid", "source": "graph TD;\n a-->b;"}]}
1322 ```
1323
1324-`index` counts every fenced block in the text from zero, whether or not it carries a language, so an answer can name the block it rendered. No event content outside the blocks, and no tags, author or timestamp, leaves the relay. Blocks in other languages are dropped before the request is built, and a block whose artifact the relay already holds is reused rather than sent again.
1325+`index` counts every fenced block in the text from zero, whether or not it carries a language, so an answer can name the block it rendered. The request does not include a full event, separate tags or timestamp. For repository state, `source.id` is an address containing the state author and repository identifier; for other kinds it is the event ID. Blocks and source identities can disclose private information. Blocks in other languages are dropped before the request is built, and a block whose artifact the relay already holds is reused rather than sent again.
1326
1327 | Header | Value |
1328 | --- | --- |
1329@@ -53,7 +55,7 @@ The relay also sends the equivalent `X-Tiny-View`, `X-Tiny-Relay` and `X-Tiny-Si
1330 {"artifacts": [{"block": 2, "type": "image/svg+xml", "body": "<svg ...>", "engine": "mermaid"}], "errors": [{"block": 5, "error": "unknown shape on line 3"}]}
1331 ```
1332
1333-Each artifact names the block it rendered, its `type`, either `image/svg+xml` or `image/png`, and its `body`, the SVG source or a base64 PNG. `engine` is optional and is recorded on the artifact. An artifact larger than the view's `max_bytes` is refused. SVG bodies are checked before they are stored: script elements, event handlers, foreign objects and external references are rejected rather than stripped, and the whole artifact is refused. A block listed under `errors`, or left out of the answer entirely, keeps showing its code.
1334+Each artifact names the block it rendered, its `type`, either `image/svg+xml` or `image/png`, and its `body`, the SVG source or a base64 PNG. `engine` is optional and is recorded on the artifact. An artifact larger than the view's `max_bytes` is refused. SVG bodies are screened with regular expressions for script elements, event handlers, foreign objects and certain external-reference attributes. Matching artifacts are rejected, not sanitized. These checks are not a complete XML/CSS security validator; PNG validation checks its signature prefix rather than decoding the complete image. A block listed under `errors`, or left out of the answer entirely, keeps showing its code.
1335
1336 SVG artifacts must include their own colors and background rules. Page styles do not reach into the SVG document. Diagramzip's `/transform/blocks` endpoint defaults to `auto-transparent`, which omits the canvas and includes light and dark palettes. The diagram follows the page's color scheme. After changing a transform's styling, rebuild the stored artifacts to apply the change.
1337
1338@@ -63,13 +65,13 @@ An artifact is a relay-signed kind 30078 record addressed `bind.ws/view/<name>/<
1339
1340 Artifacts follow their sources. An artifact expires when its last source expires, and one that outlives every source that referred to it is deleted. Editing an event detaches the blocks it no longer carries, and the artifact is dropped once nothing points at it. Removing a view deletes every artifact it produced.
1341
1342-Each artifact is served at `/views/<name>/<hash>` with its stored media type, under a sandbox policy that blocks scripts and outside loads. The `.svg` and `.png` URLs remain available. A members-only view answers only members; a public one follows the relay's read rule. The relay's pages embed the artifact as an image with a native **Source** disclosure. Browsers revalidate images so a rebuild can replace them.
1343+Each artifact is served at `/views/<name>/<hash>` with its stored media type, under a sandbox policy that blocks scripts and outside loads. The `.svg` and `.png` URLs remain available. A members-only view admits the member, moderator and owner roles, not the agent role; a public view follows the relay's general read rule. Neither audience inherits the source event's access controls. A public artifact can expose material from a private or pending source, and a members-only artifact can expose it to members who cannot read that source. Configure transforms and audiences only for content you intend to disclose. The relay's pages embed the artifact as an image with a native **Source** disclosure. Browsers revalidate images so a rebuild can replace them.
1344
1345 ## Failures
1346
1347 A valid event is accepted even when its optional transform work cannot be planned immediately. The relay makes up to three background planning attempts within 15 minutes, rechecks the source and view, and skips work that is no longer relevant. Recovery fills missing work without restarting completed transforms or backfilling views registered after the event.
1348
1349-A response outside 2xx, a timeout or a connection failure counts as a failure. The relay tries the event again after one minute and once more after five, three attempts in all, then drops it. After 20 failures in a row the view is paused and its status records the reason. A successful run resets the failure count.
1350+A response outside 2xx, a timeout or a connection failure counts as a failure. The relay tries the event again after one minute and once more after five, three attempts in all, then drops it. After 20 failures in a row the view is paused and its status records the reason. A valid JSON response with a 2xx status resets the failure count and can record `ok` even if every artifact is rejected. Check the actual artifact before treating a run as a successful render.
1351
1352 ## Manage views
1353
1354diff --git a/internal/auth/auth.go b/internal/auth/auth.go
1355index 32bd14c..f417347 100644
1356--- a/internal/auth/auth.go
1357+++ b/internal/auth/auth.go
1358@@ -1,369 +1,22 @@
1359 package auth
1360
1361 import (
1362- "crypto/rand"
1363- "crypto/sha256"
1364- "encoding/base64"
1365- "encoding/hex"
1366- "errors"
1367- "fmt"
1368- "net/url"
1369- "strings"
1370- "sync"
1371 "time"
1372
1373- "github.com/FelineStateMachine/tinyrelay/internal/event"
1374+ proof "github.com/FelineStateMachine/tinyrelay/protocol/auth"
1375 )
1376
1377-const tokenSkew = time.Minute
1378+// Validator and ChallengeManager preserve public verifier and replay-store
1379+// identity for callers that have not migrated to protocol/auth.
1380+type Validator = proof.Validator
1381+type ChallengeManager = proof.ChallengeManager
1382
1383-var ErrMissingAuthorization = errors.New("auth-required: missing Nostr Authorization header")
1384+var ErrMissingAuthorization = proof.ErrMissingAuthorization
1385+var ErrGRASP08Unauthorized = proof.ErrGRASP08Unauthorized
1386
1387-type Validator struct {
1388- now func() time.Time
1389- mu sync.Mutex
1390- seen map[string]time.Time
1391-}
1392-
1393-// NewValidator uses now to check proof freshness and track replay windows.
1394-// A nil clock selects the system clock.
1395-func NewValidator(now func() time.Time) *Validator {
1396- if now == nil {
1397- now = time.Now
1398- }
1399- return &Validator{now: now, seen: make(map[string]time.Time)}
1400-}
1401-
1402-// VerifyNIP98 validates a kind 27235 request proof against the exact URL,
1403-// method and body supplied by the caller. Successfully verified event IDs are
1404-// retained for the one-minute replay window.
1405-func (v *Validator) VerifyNIP98(header, rawURL, method, body string) (event.Event, error) {
1406- e, err := decodeToken(header, "NIP-98")
1407- if err != nil {
1408- return event.Event{}, err
1409- }
1410- if err := event.Validate(e); err != nil {
1411- return event.Event{}, authError(err.Error())
1412- }
1413- if e.Kind != 27235 {
1414- return event.Event{}, authError("token must be kind 27235")
1415- }
1416- now := v.now()
1417- if outsideSkew(now, e.CreatedAt) {
1418- return event.Event{}, authError("token expired")
1419- }
1420- if err := sameRequestURL(event.Tag(e, "u"), rawURL); err != nil {
1421- return event.Event{}, err
1422- }
1423- if strings.ToUpper(event.Tag(e, "method")) != strings.ToUpper(method) {
1424- return event.Event{}, authError("token was signed for another method")
1425- }
1426- if body != "" && event.Tag(e, "payload") != hashBytes([]byte(body)) {
1427- return event.Event{}, authError("token payload hash does not match the body")
1428- }
1429- if err := v.mark(e.ID, now); err != nil {
1430- return event.Event{}, err
1431- }
1432- return e, nil
1433-}
1434-
1435-// VerifyBlossom validates a BUD-11 authorization for action. It accepts the
1436-// protocol's reusable authorization form and checks its action and expiry.
1437-func (v *Validator) VerifyBlossom(header, action string) (event.Event, error) {
1438- return v.verifyBlossom(header, action, "", "", false)
1439-}
1440-
1441-// VerifyBlossomRequest validates a BUD-11 authorization with optional server
1442-// and blob-hash scope. Upload, delete, mirror and media actions require an x
1443-// tag when this scoped form is used.
1444-func (v *Validator) VerifyBlossomRequest(header, action, server, blobHash string) (event.Event, error) {
1445- return v.verifyBlossom(header, action, server, blobHash, true)
1446-}
1447-
1448-// IsBlossomAuthorization reports whether an Authorization header contains a
1449-// signed BUD-11 event. Malformed tokens return false so callers can produce
1450-// their normal authentication error through the protocol-specific verifier.
1451-func IsBlossomAuthorization(header string) bool {
1452- e, err := decodeToken(header, "Blossom")
1453- return err == nil && e.Kind == 24242
1454-}
1455-
1456-func (v *Validator) verifyBlossom(header, action, server, blobHash string, strict bool) (event.Event, error) {
1457- e, err := decodeToken(header, "Blossom")
1458- if err != nil {
1459- return event.Event{}, err
1460- }
1461- if err := event.Validate(e); err != nil {
1462- return event.Event{}, authError(err.Error())
1463- }
1464- if e.Kind != 24242 {
1465- return event.Event{}, authError("token must be kind 24242")
1466- }
1467- if event.Tag(e, "t") != action {
1468- return event.Event{}, authError(fmt.Sprintf("token is not for %s", action))
1469- }
1470- if strict && server != "" && !blossomServerScope(e, server) {
1471- return event.Event{}, authError("token is not scoped to this server")
1472- }
1473- now := v.now()
1474- expiration := event.Expiration(e)
1475- if expiration == 0 || expiration <= now.Unix() {
1476- return event.Event{}, authError("token expired")
1477- }
1478- // BUD-11 authorization timestamps may not be from the future. The
1479- // expiration tag, rather than an arbitrary maximum age, controls how long
1480- // an otherwise valid token remains usable.
1481- if e.CreatedAt > now.Unix() {
1482- return event.Event{}, authError("token is from the future")
1483- }
1484- if strict && requiresBlobScope(action) && len(event.TagValues(e, "x")) == 0 {
1485- return event.Event{}, authError("token must include an x tag")
1486- }
1487- if strict && blobHash != "" && len(event.TagValues(e, "x")) > 0 && !contains(event.TagValues(e, "x"), blobHash) {
1488- return event.Event{}, authError("token x tag does not name this blob")
1489- }
1490- return e, nil
1491-}
1492-
1493-func requiresBlobScope(action string) bool {
1494- switch action {
1495- case "upload", "delete", "mirror", "media":
1496- return true
1497- default:
1498- return false
1499- }
1500-}
1501-
1502-func blossomServerScope(e event.Event, server string) bool {
1503- for _, value := range event.TagValues(e, "server") {
1504- if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(server)) {
1505- return true
1506- }
1507- }
1508- return len(event.TagValues(e, "server")) == 0
1509-}
1510-
1511-func (v *Validator) WhoAsks(header, rawURL, method, body, action, blobHash string) ([]string, error) {
1512- if strings.TrimSpace(header) == "" {
1513- return nil, nil
1514- }
1515- e, err := decodeToken(header, "authorization")
1516- if err != nil {
1517- return nil, err
1518- }
1519- if e.Kind == 24242 {
1520- if action == "" {
1521- return nil, authError("this door takes a NIP-98 signature")
1522- }
1523- e, err = v.VerifyBlossom(header, action)
1524- if err != nil {
1525- return nil, err
1526- }
1527- xs := event.TagValues(e, "x")
1528- if blobHash != "" && len(xs) > 0 && !contains(xs, blobHash) {
1529- return nil, authError("token x tag does not name this blob")
1530- }
1531- return []string{e.PubKey}, nil
1532- }
1533- e, err = v.VerifyNIP98(header, rawURL, method, body)
1534- if err != nil {
1535- return nil, err
1536- }
1537- return []string{e.PubKey}, nil
1538-}
1539-
1540-// ValidateBlobPayload checks the hash binding on a token that was already
1541-// authenticated by WhoAsks. It deliberately does not mark the event as seen a
1542-// second time: upload authorization and the streamed body validator inspect
1543-// the same proof at two different points in the request.
1544-func (v *Validator) ValidateBlobPayload(header, actualSHA string) error {
1545- e, err := decodeToken(header, "authorization")
1546- if err != nil {
1547- return err
1548- }
1549- if err := event.Validate(e); err != nil {
1550- return authError(err.Error())
1551- }
1552- switch e.Kind {
1553- case 24242:
1554- if values := event.TagValues(e, "x"); len(values) > 0 && !contains(values, actualSHA) {
1555- return authError("token x tag does not name this blob")
1556- }
1557- case 27235:
1558- if event.Tag(e, "payload") != actualSHA {
1559- return authError("token payload hash does not match the blob hash")
1560- }
1561- default:
1562- return authError("token must be kind 24242 or 27235")
1563- }
1564- return nil
1565-}
1566-
1567-// ValidateBlossomPayload rechecks the content hash for a Blossom-authenticated
1568-// operation after a streaming body has been materialized. It does not mark the
1569-// authorization event as seen a second time.
1570-func (v *Validator) ValidateBlossomPayload(header, action, actualSHA string) error {
1571- e, err := v.VerifyBlossom(header, action)
1572- if err != nil {
1573- return err
1574- }
1575- if values := event.TagValues(e, "x"); len(values) > 0 && !contains(values, actualSHA) {
1576- return authError("token x tag does not name this blob")
1577- }
1578- return nil
1579-}
1580-
1581-type ChallengeManager struct {
1582- relayURL string
1583- now func() time.Time
1584- mu sync.Mutex
1585- active map[string]time.Time
1586-}
1587-
1588-// NewChallengeManager creates a NIP-42 challenge store for relayURL. Issued
1589-// challenges are single-use and expire with the validator replay window.
1590+func NewValidator(now func() time.Time) *Validator { return proof.NewValidator(now) }
1591 func NewChallengeManager(relayURL string, now func() time.Time) *ChallengeManager {
1592- if now == nil {
1593- now = time.Now
1594- }
1595- return &ChallengeManager{relayURL: relayURL, now: now, active: make(map[string]time.Time)}
1596-}
1597-
1598-// Issue creates and stores a cryptographically random NIP-42 challenge.
1599-func (m *ChallengeManager) Issue() (string, error) {
1600- raw := make([]byte, 32)
1601- if _, err := rand.Read(raw); err != nil {
1602- return "", fmt.Errorf("create NIP-42 challenge: %w", err)
1603- }
1604- challenge := hex.EncodeToString(raw)
1605- now := m.now()
1606- m.mu.Lock()
1607- m.prune(now)
1608- m.active[challenge] = now.Add(tokenSkew)
1609- m.mu.Unlock()
1610- return challenge, nil
1611-}
1612-
1613-// Verify validates and consumes a NIP-42 AUTH event for the configured relay.
1614-func (m *ChallengeManager) Verify(e event.Event) error {
1615- if err := event.Validate(e); err != nil {
1616- return authError(err.Error())
1617- }
1618- if e.Kind != 22242 {
1619- return authError("AUTH event must be kind 22242")
1620- }
1621- now := m.now()
1622- if outsideSkew(now, e.CreatedAt) {
1623- return authError("AUTH event timestamp expired")
1624- }
1625- if event.Tag(e, "relay") != m.relayURL {
1626- return authError("AUTH event names another relay")
1627- }
1628- challenge := event.Tag(e, "challenge")
1629- m.mu.Lock()
1630- defer m.mu.Unlock()
1631- m.prune(now)
1632- if _, ok := m.active[challenge]; !ok {
1633- return authError("AUTH challenge is unknown or already used")
1634- }
1635- delete(m.active, challenge)
1636- return nil
1637-}
1638-
1639-func (m *ChallengeManager) prune(now time.Time) {
1640- for challenge, expires := range m.active {
1641- if !expires.After(now) {
1642- delete(m.active, challenge)
1643- }
1644- }
1645-}
1646-
1647-func (v *Validator) mark(id string, now time.Time) error {
1648- v.mu.Lock()
1649- defer v.mu.Unlock()
1650- for seenID, at := range v.seen {
1651- if now.Sub(at) > tokenSkew {
1652- delete(v.seen, seenID)
1653- }
1654- }
1655- if _, ok := v.seen[id]; ok {
1656- return authError("token replay detected")
1657- }
1658- v.seen[id] = now
1659- return nil
1660-}
1661-
1662-func decodeToken(header, tokenName string) (event.Event, error) {
1663- parts := strings.Fields(header)
1664- if len(parts) != 2 || !strings.EqualFold(parts[0], "Nostr") {
1665- return event.Event{}, ErrMissingAuthorization
1666- }
1667- raw, err := decodeBase64(parts[1])
1668- if err != nil {
1669- return event.Event{}, authError(fmt.Sprintf("malformed %s token", tokenName))
1670- }
1671- e, err := event.Parse(raw)
1672- if err != nil {
1673- return event.Event{}, authError(err.Error())
1674- }
1675- return e, nil
1676-}
1677-
1678-func decodeBase64(value string) ([]byte, error) {
1679- for _, encoding := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
1680- if raw, err := encoding.DecodeString(value); err == nil {
1681- return raw, nil
1682- }
1683- }
1684- return nil, errors.New("invalid base64")
1685-}
1686-
1687-func sameRequestURL(signed, requested string) error {
1688- want, ok := normalizedRequestURL(requested)
1689- if !ok {
1690- return authError("token was signed for another URL")
1691- }
1692- got, ok := normalizedRequestURL(signed)
1693- if !ok || got != want {
1694- return authError("token was signed for another URL")
1695- }
1696- return nil
1697-}
1698-
1699-func normalizedRequestURL(raw string) (string, bool) {
1700- u, err := url.Parse(strings.TrimSpace(raw))
1701- if err != nil || u.Scheme == "" || u.Host == "" || u.User != nil || u.Fragment != "" || u.Opaque != "" {
1702- return "", false
1703- }
1704- path := u.EscapedPath()
1705- if path == "" {
1706- path = "/"
1707- }
1708- // URL.Host preserves a possibly meaningful port while hostname matching is
1709- // case insensitive. Query strings remain part of the NIP-98 binding.
1710- host := strings.ToLower(u.Hostname())
1711- if port := u.Port(); port != "" {
1712- host += ":" + port
1713- }
1714- return strings.ToLower(u.Scheme) + "://" + host + path + "?" + u.RawQuery, true
1715-}
1716-
1717-func outsideSkew(now time.Time, createdAt int64) bool {
1718- delta := now.Unix() - createdAt
1719- return delta > int64(tokenSkew/time.Second) || delta < -int64(tokenSkew/time.Second)
1720-}
1721-
1722-func hashBytes(value []byte) string {
1723- sum := sha256.Sum256(value)
1724- return hex.EncodeToString(sum[:])
1725-}
1726-
1727-func authError(reason string) error { return fmt.Errorf("auth-required: %s", reason) }
1728-func contains(values []string, value string) bool {
1729- for _, candidate := range values {
1730- if candidate == value {
1731- return true
1732- }
1733- }
1734- return false
1735+ return proof.NewChallengeManager(relayURL, now)
1736 }
1737+func IsBlossomAuthorization(header string) bool { return proof.IsBlossomAuthorization(header) }
1738+func GRASP08RepositoryRoot(raw string) (string, bool) { return proof.GRASP08RepositoryRoot(raw) }
1739diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
1740deleted file mode 100644
1741index 0bfe60a..0000000
1742--- a/internal/auth/auth_test.go
1743+++ /dev/null
1744@@ -1,248 +0,0 @@
1745-package auth
1746-
1747-import (
1748- "crypto/sha256"
1749- "encoding/base64"
1750- "encoding/hex"
1751- "strings"
1752- "testing"
1753- "time"
1754-
1755- "github.com/FelineStateMachine/tinyrelay/internal/event"
1756-)
1757-
1758-func TestNIP98ValidatesRequestAndRejectsReplay(t *testing.T) {
1759- now := time.Unix(1_700_000_000, 0)
1760- validator := NewValidator(func() time.Time { return now })
1761- e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api?ignored=1"}, {"method", "POST"}, {"payload", ""}})
1762- // A non-empty request body must carry its exact SHA-256 tag.
1763- e = signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api?ignored=1"}, {"method", "POST"}, {"payload", sha256Hex("hello")}})
1764- header := token(t, e)
1765- got, err := validator.VerifyNIP98(header, "https://RELAY.example/api?ignored=1", "post", "hello")
1766- if err != nil || got.PubKey == "" {
1767- t.Fatalf("verify valid token: %v", err)
1768- }
1769- if _, err := validator.VerifyNIP98(header, "https://relay.example/api?ignored=1", "POST", "hello"); err == nil || !strings.Contains(err.Error(), "replay") {
1770- t.Fatalf("expected replay rejection, got %v", err)
1771- }
1772-}
1773-
1774-func TestNIP98ExpiryAndPayload(t *testing.T) {
1775- now := time.Unix(1_700_000_000, 0)
1776- validator := NewValidator(func() time.Time { return now })
1777- e := signedEvent(t, 27235, now.Add(-61*time.Second).Unix(), "", [][]string{{"u", "https://relay.example/api"}, {"method", "POST"}, {"payload", sha256Hex("hello")}})
1778- if _, err := validator.VerifyNIP98(token(t, e), "https://relay.example/api", "POST", "hello"); err == nil || !strings.Contains(err.Error(), "expired") {
1779- t.Fatalf("expected expiry, got %v", err)
1780- }
1781- e = signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api"}, {"method", "POST"}, {"payload", sha256Hex("other")}})
1782- if _, err := validator.VerifyNIP98(token(t, e), "https://relay.example/api", "POST", "hello"); err == nil || !strings.Contains(err.Error(), "payload") {
1783- t.Fatalf("expected payload rejection, got %v", err)
1784- }
1785-}
1786-
1787-func TestNIP98BindsSchemeQueryAndPath(t *testing.T) {
1788- now := time.Unix(1_700_000_000, 0)
1789- validator := NewValidator(func() time.Time { return now })
1790- for _, requested := range []string{
1791- "http://relay.example/api?a=1",
1792- "https://relay.example/other?a=1",
1793- "https://relay.example/api?a=2",
1794- "https://relay.example/api?a=1#fragment",
1795- "https://user:pass@relay.example/api?a=1",
1796- } {
1797- e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api?a=1"}, {"method", "GET"}})
1798- if _, err := validator.VerifyNIP98(token(t, e), requested, "GET", ""); err == nil {
1799- t.Fatalf("accepted request URL %q", requested)
1800- }
1801- }
1802-}
1803-
1804-func TestGRASP08ReusesRepositoryRootProofAcrossSmartHTTP(t *testing.T) {
1805- now := time.Unix(1_700_000_000, 0)
1806- validator := NewValidator(func() time.Time { return now })
1807- root := "https://relay.example/npub1owner/repo.git"
1808- e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", root}, {"method", "GET"}, {"payload", sha256Hex("different body")}})
1809- header := token(t, e)
1810- for _, request := range []string{root + "/info/refs?service=git-upload-pack", root + "/git-upload-pack"} {
1811- if _, err := validator.VerifyGRASP08(header, request); err != nil {
1812- t.Fatalf("verify reusable proof for %s: %v", request, err)
1813- }
1814- }
1815-}
1816-
1817-func TestGRASP08RepositoryRootHandlesGitSubstringInSlug(t *testing.T) {
1818- got, ok := GRASP08RepositoryRoot("https://relay.example/npub1owner/foo.git-tools.git/info/refs?service=git-upload-pack")
1819- if !ok || got != "https://relay.example/npub1owner/foo.git-tools.git" {
1820- t.Fatalf("root=%q ok=%v", got, ok)
1821- }
1822-}
1823-
1824-func TestGRASP08RejectsWrongRootMethodAndAgeWithUniformError(t *testing.T) {
1825- now := time.Unix(1_700_000_000, 0)
1826- validator := NewValidator(func() time.Time { return now })
1827- for _, tc := range []struct {
1828- name string
1829- tags [][]string
1830- at int64
1831- url string
1832- }{
1833- {"wrong root", [][]string{{"u", "https://relay.example/npub1owner/other.git"}, {"method", "GET"}}, now.Unix(), "https://relay.example/npub1owner/repo.git/info/refs"},
1834- {"wrong method", [][]string{{"u", "https://relay.example/npub1owner/repo.git"}, {"method", "POST"}}, now.Unix(), "https://relay.example/npub1owner/repo.git/info/refs"},
1835- {"expired", [][]string{{"u", "https://relay.example/npub1owner/repo.git"}, {"method", "GET"}}, now.Add(-61 * time.Second).Unix(), "https://relay.example/npub1owner/repo.git/info/refs"},
1836- } {
1837- t.Run(tc.name, func(t *testing.T) {
1838- _, err := validator.VerifyGRASP08(token(t, signedEvent(t, 27235, tc.at, "", tc.tags)), tc.url)
1839- if err != ErrGRASP08Unauthorized {
1840- t.Fatalf("error=%v, want uniform error", err)
1841- }
1842- })
1843- }
1844-}
1845-
1846-func TestGRASP08RejectsUnsupportedGitEndpoint(t *testing.T) {
1847- now := time.Unix(1_700_000_000, 0)
1848- validator := NewValidator(func() time.Time { return now })
1849- e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/npub1owner/repo.git"}, {"method", "GET"}})
1850- for _, path := range []string{"/objects/pack/a", "/info/refs/extra", "/"} {
1851- if _, err := validator.VerifyGRASP08(token(t, e), "https://relay.example/npub1owner/repo.git"+path); err != ErrGRASP08Unauthorized {
1852- t.Fatalf("endpoint %s accepted with error %v", path, err)
1853- }
1854- }
1855-}
1856-
1857-func TestBlossomTokenMayBeReusedAcrossHeadAndPut(t *testing.T) {
1858- now := time.Unix(1_700_000_000, 0)
1859- validator := NewValidator(func() time.Time { return now })
1860- e := signedEvent(t, 24242, now.Unix(), "upload", [][]string{{"t", "upload"}, {"expiration", "1700000300"}})
1861- header := token(t, e)
1862- if _, err := validator.VerifyBlossom(header, "upload"); err != nil {
1863- t.Fatalf("verify first Blossom request: %v", err)
1864- }
1865- if _, err := validator.VerifyBlossom(header, "upload"); err != nil {
1866- t.Fatalf("verify reused Blossom request: %v", err)
1867- }
1868-}
1869-
1870-func TestValidateBlobPayloadBindsBlossomAndNIP98Hashes(t *testing.T) {
1871- now := time.Unix(1_700_000_000, 0)
1872- validator := NewValidator(func() time.Time { return now })
1873- hash := sha256Hex("blob")
1874- blossom := signedEvent(t, 24242, now.Unix(), "upload", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"x", hash}})
1875- if err := validator.ValidateBlobPayload(token(t, blossom), hash); err != nil {
1876- t.Fatalf("valid Blossom hash: %v", err)
1877- }
1878- if err := validator.ValidateBlobPayload(token(t, blossom), sha256Hex("other")); err == nil || !strings.Contains(err.Error(), "blob") {
1879- t.Fatalf("expected Blossom hash rejection, got %v", err)
1880- }
1881- nip98 := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/upload"}, {"method", "PUT"}, {"payload", hash}})
1882- if err := validator.ValidateBlobPayload(token(t, nip98), hash); err != nil {
1883- t.Fatalf("valid NIP98 hash: %v", err)
1884- }
1885- if err := validator.ValidateBlobPayload(token(t, nip98), sha256Hex("other")); err == nil || !strings.Contains(err.Error(), "payload") {
1886- t.Fatalf("expected NIP98 hash rejection, got %v", err)
1887- }
1888-}
1889-
1890-func TestValidateBlossomPayloadBindsMirrorHash(t *testing.T) {
1891- now := time.Unix(1_700_000_000, 0)
1892- validator := NewValidator(func() time.Time { return now })
1893- hash := sha256Hex("blob")
1894- tokenEvent := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "mirror"}, {"expiration", "1700000300"}, {"x", hash}})
1895- header := token(t, tokenEvent)
1896- if err := validator.ValidateBlossomPayload(header, "mirror", hash); err != nil {
1897- t.Fatalf("valid mirror hash: %v", err)
1898- }
1899- if err := validator.ValidateBlossomPayload(header, "mirror", sha256Hex("other")); err == nil {
1900- t.Fatal("accepted mismatched mirror hash")
1901- }
1902-}
1903-
1904-func TestVerifyBlossomRequestRequiresScopeAndServer(t *testing.T) {
1905- now := time.Unix(1_700_000_000, 0)
1906- validator := NewValidator(func() time.Time { return now })
1907- hash := sha256Hex("blob")
1908- valid := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "relay.example"}, {"x", hash}})
1909- if _, err := validator.VerifyBlossomRequest(token(t, valid), "upload", "relay.example", hash); err != nil {
1910- t.Fatalf("valid scoped Blossom token: %v", err)
1911- }
1912- wrongServer := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "other.example"}, {"x", hash}})
1913- if _, err := validator.VerifyBlossomRequest(token(t, wrongServer), "upload", "relay.example", hash); err == nil {
1914- t.Fatal("accepted token scoped to another server")
1915- }
1916- missingHash := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "relay.example"}})
1917- if _, err := validator.VerifyBlossomRequest(token(t, missingHash), "upload", "relay.example", ""); err == nil {
1918- t.Fatal("accepted upload token without x scope")
1919- }
1920-}
1921-
1922-func TestVerifyBlossomRequestAllowsUnscopedGet(t *testing.T) {
1923- now := time.Unix(1_700_000_000, 0)
1924- validator := NewValidator(func() time.Time { return now })
1925- hash := sha256Hex("blob")
1926- e := signedEvent(t, 24242, now.Add(-24*time.Hour).Unix(), "", [][]string{{"t", "get"}, {"expiration", "1700086400"}})
1927- if _, err := validator.VerifyBlossomRequest(token(t, e), "get", "relay.example", hash); err != nil {
1928- t.Fatalf("unscoped GET token rejected: %v", err)
1929- }
1930-}
1931-
1932-func TestVerifyBlossomRequestRejectsFutureCreatedAt(t *testing.T) {
1933- now := time.Unix(1_700_000_000, 0)
1934- validator := NewValidator(func() time.Time { return now })
1935- e := signedEvent(t, 24242, now.Add(time.Second).Unix(), "", [][]string{{"t", "get"}, {"expiration", "1700000300"}})
1936- if _, err := validator.VerifyBlossomRequest(token(t, e), "get", "relay.example", ""); err == nil || !strings.Contains(err.Error(), "future") {
1937- t.Fatalf("future token accepted: %v", err)
1938- }
1939-}
1940-
1941-func TestChallengeManagerBindsRelayAndConsumesChallenge(t *testing.T) {
1942- now := time.Unix(1_700_000_000, 0)
1943- m := NewChallengeManager("wss://relay.example/tenant", func() time.Time { return now })
1944- challenge, err := m.Issue()
1945- if err != nil {
1946- t.Fatalf("issue challenge: %v", err)
1947- }
1948- e := signedEvent(t, 22242, now.Unix(), "", [][]string{{"relay", "wss://relay.example/tenant"}, {"challenge", challenge}})
1949- if err := m.Verify(e); err != nil {
1950- t.Fatalf("verify challenge: %v", err)
1951- }
1952- if err := m.Verify(e); err == nil || !strings.Contains(err.Error(), "challenge") {
1953- t.Fatalf("expected one-time challenge rejection, got %v", err)
1954- }
1955-}
1956-
1957-func signedEvent(t *testing.T, kind int, createdAt int64, content string, tags [][]string) event.Event {
1958- t.Helper()
1959- e := event.Event{CreatedAt: createdAt, Kind: kind, Content: content, Tags: tags}
1960- if err := event.Sign(&e, strings.Repeat("0", 63)+"1"); err != nil {
1961- t.Fatalf("sign event: %v", err)
1962- }
1963- return e
1964-}
1965-
1966-func token(t *testing.T, e event.Event) string {
1967- t.Helper()
1968- raw, err := event.Canonical(e)
1969- if err != nil {
1970- t.Fatalf("encode event: %v", err)
1971- }
1972- return "Nostr " + base64.StdEncoding.EncodeToString(raw)
1973-}
1974-
1975-func sha256Hex(value string) string {
1976- sum := sha256.Sum256([]byte(value))
1977- return hex.EncodeToString(sum[:])
1978-}
1979-
1980-func TestGRASP08RepositoryRootKeepsClientEscaping(t *testing.T) {
1981- now := time.Unix(1_700_000_000, 0)
1982- validator := NewValidator(func() time.Time { return now })
1983- root := "https://relay.example/npub1owner/a%3Ab.git"
1984- got, ok := GRASP08RepositoryRoot(root + "/info/refs?service=git-upload-pack")
1985- if !ok || got != root {
1986- t.Fatalf("root=%q ok=%v", got, ok)
1987- }
1988- e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", root}, {"method", "GET"}})
1989- if _, err := validator.VerifyGRASP08(token(t, e), root+"/git-upload-pack"); err != nil {
1990- t.Fatalf("verify proof for an escaped identifier: %v", err)
1991- }
1992-}
1993diff --git a/internal/auth/compatibility_test.go b/internal/auth/compatibility_test.go
1994new file mode 100644
1995index 0000000..ffd4af6
1996--- /dev/null
1997+++ b/internal/auth/compatibility_test.go
1998@@ -0,0 +1,40 @@
1999+package auth_test
2000+
2001+import (
2002+ "encoding/base64"
2003+ "strings"
2004+ "testing"
2005+ "time"
2006+
2007+ legacy "github.com/FelineStateMachine/tinyrelay/internal/auth"
2008+ "github.com/FelineStateMachine/tinyrelay/protocol/auth"
2009+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
2010+)
2011+
2012+func TestCompatibilitySharesReplayStateAndErrors(t *testing.T) {
2013+ if legacy.ErrMissingAuthorization != auth.ErrMissingAuthorization || legacy.ErrGRASP08Unauthorized != auth.ErrGRASP08Unauthorized {
2014+ t.Fatal("compatibility facade changed error identity")
2015+ }
2016+ now := time.Unix(1700000000, 0)
2017+ old := legacy.NewValidator(func() time.Time { return now })
2018+ var public *auth.Validator = old
2019+ e := nostr.Event{Kind: 27235, CreatedAt: now.Unix(), Tags: [][]string{{"u", "https://relay.example"}, {"method", "GET"}}}
2020+ if err := nostr.Sign(&e, strings.Repeat("0", 63)+"1"); err != nil {
2021+ t.Fatal(err)
2022+ }
2023+ raw, err := nostr.Canonical(e)
2024+ if err != nil {
2025+ t.Fatal(err)
2026+ }
2027+ header := "Nostr " + base64.StdEncoding.EncodeToString(raw)
2028+ if _, err := old.VerifyNIP98(header, "https://relay.example", "GET", ""); err != nil {
2029+ t.Fatal(err)
2030+ }
2031+ if _, err := public.VerifyNIP98(header, "https://relay.example", "GET", ""); err == nil || !strings.Contains(err.Error(), "replay") {
2032+ t.Fatalf("facade lost replay state: %v", err)
2033+ }
2034+ var manager *auth.ChallengeManager = legacy.NewChallengeManager("wss://relay.example", nil)
2035+ if _, err := manager.Issue(); err != nil {
2036+ t.Fatal(err)
2037+ }
2038+}
2039diff --git a/internal/auth/doc.go b/internal/auth/doc.go
2040index a3bd9cc..d8d5d0e 100644
2041--- a/internal/auth/doc.go
2042+++ b/internal/auth/doc.go
2043@@ -1,6 +1,5 @@
2044-// Package auth verifies the signed proofs used at HTTP and WebSocket
2045-// boundaries. It handles NIP-98 request signatures, Blossom authorization,
2046-// GRASP-08 repository proofs and NIP-42 challenges. Callers use the returned
2047-// public key to apply relay policy and keep authorization state outside this
2048-// package.
2049+// Package auth is a transitional compatibility facade for protocol/auth.
2050+// Its aliases share the public verifier types and replay state; proof parsing
2051+// and validation live only in the public package. New consumers should import
2052+// protocol/auth directly.
2053 package auth
2054diff --git a/internal/auth/grasp08.go b/internal/auth/grasp08.go
2055deleted file mode 100644
2056index 39ee850..0000000
2057--- a/internal/auth/grasp08.go
2058+++ /dev/null
2059@@ -1,68 +0,0 @@
2060-package auth
2061-
2062-import (
2063- "errors"
2064- "net/url"
2065- "strings"
2066-
2067- "github.com/FelineStateMachine/tinyrelay/internal/event"
2068-)
2069-
2070-// ErrGRASP08Unauthorized is the detail-free GRASP-08 authentication error
2071-// returned for every failed repository proof.
2072-var ErrGRASP08Unauthorized = errors.New("auth-required:")
2073-
2074-// VerifyGRASP08 validates a reusable kind 27235 GET proof for the repository
2075-// root named by a GRASP-08 Smart HTTP request. The proof remains valid for
2076-// one minute across the repository's supported Git requests.
2077-func (v *Validator) VerifyGRASP08(header, rawURL string) (event.Event, error) {
2078- e, err := decodeToken(header, "GRASP-08")
2079- if err != nil {
2080- return event.Event{}, ErrGRASP08Unauthorized
2081- }
2082- if err := event.Validate(e); err != nil || e.Kind != 27235 {
2083- return event.Event{}, ErrGRASP08Unauthorized
2084- }
2085- now := v.now()
2086- if e.CreatedAt < now.Unix()-60 || e.CreatedAt > now.Unix()+60 {
2087- return event.Event{}, ErrGRASP08Unauthorized
2088- }
2089- if event.Tag(e, "method") != "GET" {
2090- return event.Event{}, ErrGRASP08Unauthorized
2091- }
2092- root, ok := GRASP08RepositoryRoot(rawURL)
2093- if !ok || sameRequestURL(event.Tag(e, "u"), root) != nil {
2094- return event.Event{}, ErrGRASP08Unauthorized
2095- }
2096- return e, nil
2097-}
2098-
2099-// GRASP08RepositoryRoot returns the canonical repository URL for a supported
2100-// Smart HTTP request beneath a .git path.
2101-func GRASP08RepositoryRoot(raw string) (string, bool) {
2102- u, err := url.Parse(raw)
2103- if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil {
2104- return "", false
2105- }
2106- // Keep the client's percent-encoding so the root compares equal to the
2107- // signed u tag, which is also kept as sent.
2108- path := u.EscapedPath()
2109- i := strings.LastIndex(path, ".git")
2110- if i < 0 || (i+4 < len(path) && path[i+4] != '/') {
2111- return "", false
2112- }
2113- rootPath := path[:i+4]
2114- suffix := path[i+4:]
2115- if suffix != "" && suffix != "/info/refs" && suffix != "/git-upload-pack" && suffix != "/git-receive-pack" {
2116- return "", false
2117- }
2118- if strings.HasSuffix(rootPath, "/") {
2119- return "", false
2120- }
2121- unescaped, err := url.PathUnescape(rootPath)
2122- if err != nil {
2123- return "", false
2124- }
2125- u.Path, u.RawPath, u.RawQuery, u.Fragment = unescaped, rootPath, "", ""
2126- return u.String(), true
2127-}
2128diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go
2129index aa7186a..a22651b 100644
2130--- a/internal/daemon/client_browse.go
2131+++ b/internal/daemon/client_browse.go
2132@@ -15,7 +15,7 @@ import (
2133 "github.com/FelineStateMachine/tinyrelay/internal/blob"
2134 "github.com/FelineStateMachine/tinyrelay/internal/community"
2135 "github.com/FelineStateMachine/tinyrelay/internal/event"
2136- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2137+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2138 "github.com/FelineStateMachine/tinyrelay/internal/relay"
2139 "github.com/FelineStateMachine/tinyrelay/internal/storage"
2140 )
2141diff --git a/internal/daemon/client_browse_http.go b/internal/daemon/client_browse_http.go
2142index 99836a3..ff03f3d 100644
2143--- a/internal/daemon/client_browse_http.go
2144+++ b/internal/daemon/client_browse_http.go
2145@@ -12,7 +12,7 @@ import (
2146 "time"
2147
2148 "github.com/FelineStateMachine/tinyrelay/internal/blob"
2149- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2150+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2151 )
2152
2153 func (t *Tenant) tryBrowseHTTP(w http.ResponseWriter, r *http.Request) bool {
2154diff --git a/internal/daemon/client_files_test.go b/internal/daemon/client_files_test.go
2155index f2fdcbb..5ac7e06 100644
2156--- a/internal/daemon/client_files_test.go
2157+++ b/internal/daemon/client_files_test.go
2158@@ -10,7 +10,7 @@ import (
2159
2160 "github.com/FelineStateMachine/tinyrelay/internal/blob"
2161 "github.com/FelineStateMachine/tinyrelay/internal/event"
2162- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2163+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2164 "github.com/FelineStateMachine/tinyrelay/internal/relay"
2165 "github.com/FelineStateMachine/tinyrelay/internal/sites"
2166 )
2167diff --git a/internal/daemon/collaboration_approvals.go b/internal/daemon/collaboration_approvals.go
2168index f360475..71d7561 100644
2169--- a/internal/daemon/collaboration_approvals.go
2170+++ b/internal/daemon/collaboration_approvals.go
2171@@ -18,7 +18,7 @@ import (
2172
2173 "github.com/FelineStateMachine/tinyrelay/internal/community"
2174 "github.com/FelineStateMachine/tinyrelay/internal/event"
2175- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2176+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2177 "github.com/FelineStateMachine/tinyrelay/internal/storage"
2178 )
2179
2180diff --git a/internal/daemon/collaboration_browse.go b/internal/daemon/collaboration_browse.go
2181index ce09854..1202ef2 100644
2182--- a/internal/daemon/collaboration_browse.go
2183+++ b/internal/daemon/collaboration_browse.go
2184@@ -10,7 +10,7 @@ import (
2185 "time"
2186
2187 "github.com/FelineStateMachine/tinyrelay/internal/event"
2188- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2189+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2190 "github.com/FelineStateMachine/tinyrelay/internal/relay"
2191 "github.com/FelineStateMachine/tinyrelay/internal/storage"
2192 )
2193diff --git a/internal/daemon/custom_views.go b/internal/daemon/custom_views.go
2194index ee88ea9..fe76335 100644
2195--- a/internal/daemon/custom_views.go
2196+++ b/internal/daemon/custom_views.go
2197@@ -23,7 +23,7 @@ import (
2198
2199 "github.com/FelineStateMachine/tinyrelay/internal/community"
2200 "github.com/FelineStateMachine/tinyrelay/internal/event"
2201- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2202+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2203 "github.com/FelineStateMachine/tinyrelay/internal/records"
2204 "github.com/FelineStateMachine/tinyrelay/internal/storage"
2205 "github.com/FelineStateMachine/tinyrelay/internal/telemetry"
2206diff --git a/internal/daemon/custom_views_transform.go b/internal/daemon/custom_views_transform.go
2207index 3d7151e..e24b758 100644
2208--- a/internal/daemon/custom_views_transform.go
2209+++ b/internal/daemon/custom_views_transform.go
2210@@ -27,7 +27,7 @@ import (
2211 "time"
2212
2213 "github.com/FelineStateMachine/tinyrelay/internal/event"
2214- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2215+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2216 "github.com/FelineStateMachine/tinyrelay/internal/replication"
2217 "github.com/FelineStateMachine/tinyrelay/internal/views"
2218 "github.com/FelineStateMachine/tinyrelay/internal/work"
2219diff --git a/internal/daemon/event_commit.go b/internal/daemon/event_commit.go
2220index b7fd3d6..afd4de7 100644
2221--- a/internal/daemon/event_commit.go
2222+++ b/internal/daemon/event_commit.go
2223@@ -7,7 +7,7 @@ import (
2224
2225 "github.com/FelineStateMachine/tinyrelay/internal/community"
2226 "github.com/FelineStateMachine/tinyrelay/internal/event"
2227- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2228+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2229 "github.com/FelineStateMachine/tinyrelay/internal/policy"
2230 "github.com/FelineStateMachine/tinyrelay/internal/replication"
2231 "github.com/FelineStateMachine/tinyrelay/internal/sites"
2232diff --git a/internal/daemon/git_agent_maintainer_test.go b/internal/daemon/git_agent_maintainer_test.go
2233index 1912242..3f21251 100644
2234--- a/internal/daemon/git_agent_maintainer_test.go
2235+++ b/internal/daemon/git_agent_maintainer_test.go
2236@@ -11,7 +11,7 @@ import (
2237 "time"
2238
2239 "github.com/FelineStateMachine/tinyrelay/internal/event"
2240- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2241+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2242 )
2243
2244 const testOwnerNPub = "npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"
2245diff --git a/internal/daemon/git_live.go b/internal/daemon/git_live.go
2246index dcbc192..8ca0fdd 100644
2247--- a/internal/daemon/git_live.go
2248+++ b/internal/daemon/git_live.go
2249@@ -9,7 +9,7 @@ import (
2250 "time"
2251
2252 "github.com/FelineStateMachine/tinyrelay/internal/event"
2253- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2254+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2255 "github.com/FelineStateMachine/tinyrelay/internal/replication"
2256 "github.com/FelineStateMachine/tinyrelay/internal/storage"
2257 )
2258diff --git a/internal/daemon/git_live_test.go b/internal/daemon/git_live_test.go
2259index e76804b..4e47a64 100644
2260--- a/internal/daemon/git_live_test.go
2261+++ b/internal/daemon/git_live_test.go
2262@@ -7,7 +7,7 @@ import (
2263 "time"
2264
2265 "github.com/FelineStateMachine/tinyrelay/internal/event"
2266- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2267+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2268 )
2269
2270 func TestGitLiveEnabledRequiresGRASP02(t *testing.T) {
2271diff --git a/internal/daemon/git_outbox_sync.go b/internal/daemon/git_outbox_sync.go
2272index 4251c63..d6fcaa9 100644
2273--- a/internal/daemon/git_outbox_sync.go
2274+++ b/internal/daemon/git_outbox_sync.go
2275@@ -7,7 +7,7 @@ import (
2276 "time"
2277
2278 "github.com/FelineStateMachine/tinyrelay/internal/event"
2279- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2280+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2281 "github.com/FelineStateMachine/tinyrelay/internal/replication"
2282 "github.com/FelineStateMachine/tinyrelay/internal/storage"
2283 )
2284diff --git a/internal/daemon/git_payload_test.go b/internal/daemon/git_payload_test.go
2285index 2885b98..89c20c7 100644
2286--- a/internal/daemon/git_payload_test.go
2287+++ b/internal/daemon/git_payload_test.go
2288@@ -13,7 +13,7 @@ import (
2289 "testing"
2290
2291 "github.com/FelineStateMachine/tinyrelay/internal/event"
2292- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2293+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2294 )
2295
2296 type repeatedGitByte struct{}
2297diff --git a/internal/daemon/git_services.go b/internal/daemon/git_services.go
2298index c99d8d0..ce6aee3 100644
2299--- a/internal/daemon/git_services.go
2300+++ b/internal/daemon/git_services.go
2301@@ -13,7 +13,7 @@ import (
2302 "time"
2303
2304 "github.com/FelineStateMachine/tinyrelay/internal/event"
2305- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2306+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2307 "github.com/FelineStateMachine/tinyrelay/internal/replication"
2308 "github.com/FelineStateMachine/tinyrelay/internal/storage"
2309 "github.com/FelineStateMachine/tinyrelay/internal/syncprotocol"
2310diff --git a/internal/daemon/git_services_test.go b/internal/daemon/git_services_test.go
2311index 81b001f..1e064e9 100644
2312--- a/internal/daemon/git_services_test.go
2313+++ b/internal/daemon/git_services_test.go
2314@@ -4,7 +4,7 @@ import (
2315 "strings"
2316 "testing"
2317
2318- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2319+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2320 )
2321
2322 func TestGitEventFiltersCoverRepositoryAndCollaborationScopes(t *testing.T) {
2323diff --git a/internal/daemon/git_sync_acceptance_test.go b/internal/daemon/git_sync_acceptance_test.go
2324index 0b8be48..64630ed 100644
2325--- a/internal/daemon/git_sync_acceptance_test.go
2326+++ b/internal/daemon/git_sync_acceptance_test.go
2327@@ -11,7 +11,7 @@ import (
2328 "time"
2329
2330 "github.com/FelineStateMachine/tinyrelay/internal/event"
2331- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2332+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2333 "github.com/FelineStateMachine/tinyrelay/internal/relay"
2334 "github.com/FelineStateMachine/tinyrelay/internal/storage"
2335 "github.com/coder/websocket"
2336diff --git a/internal/daemon/git_sync_cursor_test.go b/internal/daemon/git_sync_cursor_test.go
2337index 2ecb9de..a9b1835 100644
2338--- a/internal/daemon/git_sync_cursor_test.go
2339+++ b/internal/daemon/git_sync_cursor_test.go
2340@@ -12,7 +12,7 @@ import (
2341 "time"
2342
2343 "github.com/FelineStateMachine/tinyrelay/internal/event"
2344- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2345+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2346 "github.com/FelineStateMachine/tinyrelay/internal/replication"
2347 "github.com/FelineStateMachine/tinyrelay/internal/storage"
2348 )
2349diff --git a/internal/daemon/git_sync_integration_test.go b/internal/daemon/git_sync_integration_test.go
2350index c3f077c..1bbafe7 100644
2351--- a/internal/daemon/git_sync_integration_test.go
2352+++ b/internal/daemon/git_sync_integration_test.go
2353@@ -9,7 +9,7 @@ import (
2354 "time"
2355
2356 "github.com/FelineStateMachine/tinyrelay/internal/event"
2357- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2358+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2359 "github.com/FelineStateMachine/tinyrelay/internal/replication"
2360 "github.com/FelineStateMachine/tinyrelay/internal/storage"
2361 )
2362diff --git a/internal/daemon/landing.go b/internal/daemon/landing.go
2363index 10e678c..20b9419 100644
2364--- a/internal/daemon/landing.go
2365+++ b/internal/daemon/landing.go
2366@@ -10,11 +10,11 @@ import (
2367 "strings"
2368 "time"
2369
2370- "github.com/FelineStateMachine/tinyrelay/internal/auth"
2371 "github.com/FelineStateMachine/tinyrelay/internal/catalog"
2372 "github.com/FelineStateMachine/tinyrelay/internal/policy"
2373 "github.com/FelineStateMachine/tinyrelay/internal/storage"
2374 "github.com/FelineStateMachine/tinyrelay/internal/templates"
2375+ "github.com/FelineStateMachine/tinyrelay/protocol/auth"
2376 )
2377
2378 // ServeLanding handles only the process landing page and permanent relay
2379diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go
2380index 85d0378..d7b6692 100644
2381--- a/internal/daemon/mcp_tools.go
2382+++ b/internal/daemon/mcp_tools.go
2383@@ -23,7 +23,7 @@ import (
2384 "github.com/FelineStateMachine/tinyrelay/internal/wiki"
2385 )
2386
2387-// The MCP tool table mirrors the browser tools in internal/webui/webmcp.js.
2388+// The MCP tool table mirrors the browser tools in tinyclient/webmcp.js.
2389 // Reads run through the browse methods, controls through the NIP-86
2390 // management path, and writes through the same publish path as POST /events,
2391 // so every tool is executed as the signed-in pubkey with the tenant's
2392diff --git a/internal/daemon/private_peer.go b/internal/daemon/private_peer.go
2393index e582698..4cf68ce 100644
2394--- a/internal/daemon/private_peer.go
2395+++ b/internal/daemon/private_peer.go
2396@@ -11,8 +11,8 @@ import (
2397 "net/url"
2398 "strings"
2399
2400- "github.com/FelineStateMachine/tinyrelay/internal/auth"
2401 "github.com/FelineStateMachine/tinyrelay/internal/policy"
2402+ "github.com/FelineStateMachine/tinyrelay/protocol/auth"
2403 )
2404
2405 func (t *Tenant) privateHTTPAuth(ctx context.Context, method, rawURL, payloadHash string) (string, error) {
2406diff --git a/internal/daemon/private_peer_acceptance_test.go b/internal/daemon/private_peer_acceptance_test.go
2407index 6d6f029..78bf7a7 100644
2408--- a/internal/daemon/private_peer_acceptance_test.go
2409+++ b/internal/daemon/private_peer_acceptance_test.go
2410@@ -14,7 +14,7 @@ import (
2411 "time"
2412
2413 "github.com/FelineStateMachine/tinyrelay/internal/event"
2414- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2415+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2416 "github.com/FelineStateMachine/tinyrelay/internal/relay"
2417 "github.com/FelineStateMachine/tinyrelay/internal/storage"
2418 )
2419diff --git a/internal/daemon/replication_services.go b/internal/daemon/replication_services.go
2420index 6358a99..a79ea0e 100644
2421--- a/internal/daemon/replication_services.go
2422+++ b/internal/daemon/replication_services.go
2423@@ -22,7 +22,7 @@ import (
2424
2425 "github.com/FelineStateMachine/tinyrelay/internal/configport"
2426 "github.com/FelineStateMachine/tinyrelay/internal/event"
2427- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2428+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2429 "github.com/FelineStateMachine/tinyrelay/internal/policy"
2430 "github.com/FelineStateMachine/tinyrelay/internal/records"
2431 "github.com/FelineStateMachine/tinyrelay/internal/relay"
2432diff --git a/internal/daemon/repository_maintainers.go b/internal/daemon/repository_maintainers.go
2433index 70096fa..da7d58b 100644
2434--- a/internal/daemon/repository_maintainers.go
2435+++ b/internal/daemon/repository_maintainers.go
2436@@ -4,7 +4,7 @@ import (
2437 "context"
2438 "time"
2439
2440- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2441+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2442 )
2443
2444 // Repository maintainers. The owner and the keys in an announcement's
2445diff --git a/internal/daemon/room_attachments.go b/internal/daemon/room_attachments.go
2446index c18ce61..d57e69b 100644
2447--- a/internal/daemon/room_attachments.go
2448+++ b/internal/daemon/room_attachments.go
2449@@ -16,9 +16,9 @@ import (
2450 "regexp"
2451 "strings"
2452
2453- "github.com/FelineStateMachine/tinyrelay/internal/auth"
2454 "github.com/FelineStateMachine/tinyrelay/internal/blob"
2455 "github.com/FelineStateMachine/tinyrelay/internal/community"
2456+ "github.com/FelineStateMachine/tinyrelay/protocol/auth"
2457 )
2458
2459 const roomAttachmentMaxBytes int64 = 32 << 20
2460diff --git a/internal/daemon/rooms_browse.go b/internal/daemon/rooms_browse.go
2461index 4e7e74c..d49fafa 100644
2462--- a/internal/daemon/rooms_browse.go
2463+++ b/internal/daemon/rooms_browse.go
2464@@ -10,7 +10,7 @@ import (
2465 "github.com/FelineStateMachine/tinyrelay/internal/community"
2466 "github.com/FelineStateMachine/tinyrelay/internal/event"
2467 "github.com/FelineStateMachine/tinyrelay/internal/storage"
2468- "github.com/FelineStateMachine/tinyrelay/internal/webui"
2469+ webui "github.com/FelineStateMachine/tinyrelay/tinyclient"
2470 )
2471
2472 var (
2473diff --git a/internal/daemon/services.go b/internal/daemon/services.go
2474index 0f05137..875f697 100644
2475--- a/internal/daemon/services.go
2476+++ b/internal/daemon/services.go
2477@@ -16,12 +16,10 @@ import (
2478 "strings"
2479 "time"
2480
2481- "github.com/FelineStateMachine/tinyrelay/internal/auth"
2482 "github.com/FelineStateMachine/tinyrelay/internal/blob"
2483 "github.com/FelineStateMachine/tinyrelay/internal/community"
2484 "github.com/FelineStateMachine/tinyrelay/internal/configport"
2485 "github.com/FelineStateMachine/tinyrelay/internal/event"
2486- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2487 "github.com/FelineStateMachine/tinyrelay/internal/policy"
2488 "github.com/FelineStateMachine/tinyrelay/internal/records"
2489 "github.com/FelineStateMachine/tinyrelay/internal/relay"
2490@@ -29,8 +27,10 @@ import (
2491 "github.com/FelineStateMachine/tinyrelay/internal/sites"
2492 "github.com/FelineStateMachine/tinyrelay/internal/storage"
2493 "github.com/FelineStateMachine/tinyrelay/internal/views"
2494- "github.com/FelineStateMachine/tinyrelay/internal/webui"
2495 "github.com/FelineStateMachine/tinyrelay/internal/work"
2496+ "github.com/FelineStateMachine/tinyrelay/protocol/auth"
2497+ webui "github.com/FelineStateMachine/tinyrelay/tinyclient"
2498+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2499 )
2500
2501 // initServices constructs feature services around the tenant store and policy
2502diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go
2503index 8ce2e04..219581e 100644
2504--- a/internal/daemon/tenant.go
2505+++ b/internal/daemon/tenant.go
2506@@ -12,14 +12,12 @@ import (
2507 "sync"
2508 "time"
2509
2510- "github.com/FelineStateMachine/tinyrelay/internal/auth"
2511 "github.com/FelineStateMachine/tinyrelay/internal/blob"
2512 "github.com/FelineStateMachine/tinyrelay/internal/catalog"
2513 "github.com/FelineStateMachine/tinyrelay/internal/community"
2514 "github.com/FelineStateMachine/tinyrelay/internal/configport"
2515 "github.com/FelineStateMachine/tinyrelay/internal/event"
2516 "github.com/FelineStateMachine/tinyrelay/internal/gates"
2517- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
2518 "github.com/FelineStateMachine/tinyrelay/internal/mcp"
2519 "github.com/FelineStateMachine/tinyrelay/internal/policy"
2520 "github.com/FelineStateMachine/tinyrelay/internal/records"
2521@@ -29,7 +27,9 @@ import (
2522 "github.com/FelineStateMachine/tinyrelay/internal/storage"
2523 "github.com/FelineStateMachine/tinyrelay/internal/syncprotocol"
2524 "github.com/FelineStateMachine/tinyrelay/internal/webpush"
2525- "github.com/FelineStateMachine/tinyrelay/internal/webui"
2526+ "github.com/FelineStateMachine/tinyrelay/protocol/auth"
2527+ webui "github.com/FelineStateMachine/tinyrelay/tinyclient"
2528+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
2529 )
2530
2531 type tenantConfig struct {
2532diff --git a/internal/daemon/tinyclient_integration_test.go b/internal/daemon/tinyclient_integration_test.go
2533new file mode 100644
2534index 0000000..18108dd
2535--- /dev/null
2536+++ b/internal/daemon/tinyclient_integration_test.go
2537@@ -0,0 +1,109 @@
2538+package daemon
2539+
2540+import (
2541+ "context"
2542+ "encoding/json"
2543+ "io"
2544+ "net/http"
2545+ "net/http/httptest"
2546+ "net/url"
2547+ "strings"
2548+ "testing"
2549+
2550+ "github.com/FelineStateMachine/tinyrelay/tinyclient"
2551+)
2552+
2553+func TestStandaloneTinyclientAuthenticatesAgainstRealRelay(t *testing.T) {
2554+ _, tenant := testTenant(t)
2555+ upstream := httptest.NewServer(tenant)
2556+ defer upstream.Close()
2557+ handler, err := tinyclient.NewRemote(tinyclient.RemoteOptions{BackendURL: upstream.URL, PublicURL: "http://relay.test"})
2558+ if err != nil {
2559+ t.Fatal(err)
2560+ }
2561+ frontend := httptest.NewServer(handler)
2562+ defer frontend.Close()
2563+ frontendURL, _ := url.Parse(frontend.URL)
2564+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
2565+ issue := func(method, path, body string, cookie *http.Cookie, signed bool) (*http.Response, string) {
2566+ t.Helper()
2567+ request := httptest.NewRequest(method, "http://relay.test"+path, strings.NewReader(body))
2568+ request.Header.Set("Content-Type", "application/json")
2569+ request.Header.Set("Origin", "http://relay.test")
2570+ if cookie != nil {
2571+ request.AddCookie(cookie)
2572+ }
2573+ if signed {
2574+ signRequest(t, request, body)
2575+ }
2576+ request.URL.Scheme, request.URL.Host = frontendURL.Scheme, frontendURL.Host
2577+ request.RequestURI = ""
2578+ response, err := client.Do(request)
2579+ if err != nil {
2580+ t.Fatal(err)
2581+ }
2582+ raw, err := io.ReadAll(response.Body)
2583+ response.Body.Close()
2584+ if err != nil {
2585+ t.Fatal(err)
2586+ }
2587+ return response, string(raw)
2588+ }
2589+ login, body := issue("POST", "/session", "", nil, true)
2590+ if login.StatusCode != 200 || len(login.Cookies()) != 1 {
2591+ t.Fatalf("signed login: %d %s", login.StatusCode, body)
2592+ }
2593+ cookie := login.Cookies()[0]
2594+ if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode {
2595+ t.Fatalf("unsafe cookie: %+v", cookie)
2596+ }
2597+ for _, path := range []string{"/", "/account", "/manage/rules", "/chat", "/scripts/bridge.js", "/manifest.webmanifest"} {
2598+ response, body := issue("GET", path, "", cookie, false)
2599+ integrated := httptest.NewRecorder()
2600+ request := httptest.NewRequest("GET", "http://relay.test"+path, nil)
2601+ request.AddCookie(cookie)
2602+ tenant.ServeHTTP(integrated, request)
2603+ if response.StatusCode != integrated.Code || body != integrated.Body.String() {
2604+ t.Fatalf("standalone parity %s: status=%d want=%d body lengths=%d/%d", path, response.StatusCode, integrated.Code, len(body), integrated.Body.Len())
2605+ }
2606+ }
2607+ denied, _ := issue("POST", "/manage/rpc", `{"method":"stats"}`, cookie, false)
2608+ if denied.StatusCode != 401 {
2609+ t.Fatalf("cookie-only write accepted: %d", denied.StatusCode)
2610+ }
2611+ signed, body := issue("POST", "/manage/rpc", `{"method":"stats"}`, cookie, true)
2612+ if signed.StatusCode != 200 {
2613+ t.Fatalf("signed management request lost URL binding: %d %s", signed.StatusCode, body)
2614+ }
2615+ // The new read bridge ignores claimed pubkeys and still requires a real session.
2616+ forged, body := issue("GET", tinyclient.BackendPath+"?actor="+tenant.Policy().Owner, "", nil, false)
2617+ var snapshot struct {
2618+ Actor string `json:"actor"`
2619+ }
2620+ if forged.StatusCode != 200 || json.Unmarshal([]byte(body), &snapshot) != nil || snapshot.Actor != "" {
2621+ t.Fatalf("forged actor accepted: %d %s", forged.StatusCode, body)
2622+ }
2623+ logout, body := issue("POST", "/session/logout", "", cookie, false)
2624+ if logout.StatusCode != 200 {
2625+ t.Fatalf("logout: %d %s", logout.StatusCode, body)
2626+ }
2627+ response, body := issue("GET", "/account", "", cookie, false)
2628+ if response.StatusCode != 200 || !strings.Contains(body, "Sign in to open your account.") {
2629+ t.Fatalf("revoked session survived: %d", response.StatusCode)
2630+ }
2631+ p := tenant.Policy()
2632+ p.Features.Grasp, p.Features.Grasp08, p.Reads, p.Name = true, true, "members", "private secret project"
2633+ if err := tenant.applyPolicy(context.Background(), p); err != nil {
2634+ t.Fatal(err)
2635+ }
2636+ for _, path := range []string{"/", tinyclient.BackendPath} {
2637+ response, body := issue("GET", path, "", cookie, false)
2638+ if response.StatusCode != 200 || strings.Contains(body, p.Name) || strings.Contains(body, p.Owner) {
2639+ t.Fatalf("private metadata leaked through %s: %d", path, response.StatusCode)
2640+ }
2641+ }
2642+ response, _ = issue("GET", "/social", "", cookie, false)
2643+ if response.StatusCode != 401 {
2644+ t.Fatalf("private read without membership: %d", response.StatusCode)
2645+ }
2646+}
2647diff --git a/internal/daemon/webui_rooms.go b/internal/daemon/webui_rooms.go
2648index dab78e1..9999f16 100644
2649--- a/internal/daemon/webui_rooms.go
2650+++ b/internal/daemon/webui_rooms.go
2651@@ -8,7 +8,7 @@ import (
2652 "github.com/FelineStateMachine/tinyrelay/internal/community"
2653 "github.com/FelineStateMachine/tinyrelay/internal/event"
2654 "github.com/FelineStateMachine/tinyrelay/internal/storage"
2655- "github.com/FelineStateMachine/tinyrelay/internal/webui"
2656+ webui "github.com/FelineStateMachine/tinyrelay/tinyclient"
2657 )
2658
2659 var _ webui.RoomsReader = (*Tenant)(nil)
2660diff --git a/internal/event/compatibility_test.go b/internal/event/compatibility_test.go
2661new file mode 100644
2662index 0000000..ae863af
2663--- /dev/null
2664+++ b/internal/event/compatibility_test.go
2665@@ -0,0 +1,32 @@
2666+package event
2667+
2668+import (
2669+ "reflect"
2670+ "testing"
2671+
2672+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
2673+)
2674+
2675+func TestCompatibilityPreservesPublicTypeIdentity(t *testing.T) {
2676+ if reflect.TypeOf(Event{}).PkgPath() != reflect.TypeOf(nostr.Event{}).PkgPath() || reflect.TypeOf(Filter{}).PkgPath() != reflect.TypeOf(nostr.Filter{}).PkgPath() {
2677+ t.Fatal("compatibility values must alias the public types")
2678+ }
2679+}
2680+
2681+func TestPrivateKindClassificationAndSearchCompatibility(t *testing.T) {
2682+ if !IsPrivate(4) || IsPrivate(KIND_DM) {
2683+ t.Fatal("kind classification mismatch")
2684+ }
2685+ for _, kind := range []int{4, 13, KIND_WRAP, 21059, KIND_NOSTR_CONNECT, KIND_PUSH_REGISTRATION} {
2686+ e := Event{Kind: kind, Content: "hello world"}
2687+ if Matches(Filter{Search: "hello"}, e) {
2688+ t.Errorf("private kind %d matched content search", kind)
2689+ }
2690+ if !Matches(Filter{Search: "lang:en"}, e) || !Matches(Filter{}, e) {
2691+ t.Errorf("private kind %d changed non-content matching", kind)
2692+ }
2693+ }
2694+ if !Matches(Filter{Search: "HELLO"}, Event{Kind: 1, Content: "hello world"}) {
2695+ t.Fatal("public content no longer matches")
2696+ }
2697+}
2698diff --git a/internal/event/doc.go b/internal/event/doc.go
2699index 763a8d4..72313a8 100644
2700--- a/internal/event/doc.go
2701+++ b/internal/event/doc.go
2702@@ -1,5 +1,6 @@
2703-// Package event defines the relay's Nostr event and filter values. It owns
2704-// wire parsing, canonical serialization, Schnorr signing and signature
2705-// validation, plus the common kind and tag helpers used by storage and
2706-// protocol gates.
2707+// Package event keeps relay feature kind, job and room-reference semantics
2708+// separate from the reusable values in protocol/nostr. Event and Filter are
2709+// transitional aliases for the public types; parsing, encoding, signing and
2710+// generic matching delegate to that package. Matches additionally preserves
2711+// the host's private-kind search exclusion for existing consumers.
2712 package event
2713diff --git a/internal/event/event.go b/internal/event/event.go
2714index 0257c8b..0791d43 100644
2715--- a/internal/event/event.go
2716+++ b/internal/event/event.go
2717@@ -1,284 +1,28 @@
2718 package event
2719
2720-import (
2721- "bytes"
2722- "crypto/rand"
2723- "crypto/sha256"
2724- "encoding/hex"
2725- "encoding/json"
2726- "errors"
2727- "fmt"
2728- "strconv"
2729- "strings"
2730-
2731- "fiatjaf.com/nostr"
2732- "fiatjaf.com/nostr/nip13"
2733- "github.com/btcsuite/btcd/btcec/v2"
2734- "github.com/btcsuite/btcd/btcec/v2/schnorr"
2735-)
2736-
2737-type Event struct {
2738- ID string `json:"id"`
2739- PubKey string `json:"pubkey"`
2740- CreatedAt int64 `json:"created_at"`
2741- Kind int `json:"kind"`
2742- Tags [][]string `json:"tags"`
2743- Content string `json:"content"`
2744- Sig string `json:"sig"`
2745-}
2746-
2747-// Validate checks the wire shape, canonical ID and Schnorr signature of e.
2748-// Kind-specific checks are applied by the protocol gate that owns the kind.
2749-func Validate(e Event) error {
2750- if !hex64(e.ID) {
2751- return errors.New("invalid: bad id")
2752- }
2753- if !hex64(e.PubKey) {
2754- return errors.New("invalid: bad pubkey")
2755- }
2756- if len(e.Sig) != 128 || !isHex(e.Sig) {
2757- return errors.New("invalid: bad sig")
2758- }
2759- if e.Kind < 0 || e.Kind > 65535 {
2760- return errors.New("invalid: kind out of range")
2761- }
2762- if e.CreatedAt < 0 {
2763- return errors.New("invalid: bad created_at")
2764- }
2765- if e.Tags == nil {
2766- return errors.New("invalid: tags must be a list")
2767- }
2768- for _, tag := range e.Tags {
2769- if len(tag) == 0 {
2770- return errors.New("invalid: bad tag")
2771- }
2772- }
2773- serialized, err := serializeForID(e)
2774- if err != nil {
2775- return errors.New("invalid: cannot serialize event")
2776- }
2777- sum := sha256.Sum256(serialized)
2778- if e.ID != hex.EncodeToString(sum[:]) || !verifySignature(e, sum[:]) {
2779- return errors.New("invalid: id or signature does not match content")
2780- }
2781- return nil
2782-}
2783-
2784-// Parse decodes and validates one complete JSON event object.
2785-func Parse(raw []byte) (Event, error) {
2786- if err := requireFields(raw); err != nil {
2787- return Event{}, err
2788- }
2789- var e Event
2790- if err := json.Unmarshal(raw, &e); err != nil {
2791- return Event{}, fmt.Errorf("invalid event JSON: %w", err)
2792- }
2793- if err := Validate(e); err != nil {
2794- return Event{}, err
2795- }
2796- return e, nil
2797-}
2798-
2799-// Canonical encodes e as the JSON representation used on relay boundaries.
2800-func Canonical(e Event) ([]byte, error) {
2801- var out bytes.Buffer
2802- encoder := json.NewEncoder(&out)
2803- encoder.SetEscapeHTML(false)
2804- if err := encoder.Encode(e); err != nil {
2805- return nil, err
2806- }
2807- return bytes.TrimSuffix(out.Bytes(), []byte{'\n'}), nil
2808-}
2809-
2810-// Tag returns the first value for name, or an empty string when the tag is
2811-// absent or has no value.
2812-func Tag(e Event, name string) string {
2813- for _, tag := range e.Tags {
2814- if len(tag) > 0 && tag[0] == name && len(tag) > 1 {
2815- return tag[1]
2816- }
2817- }
2818- return ""
2819-}
2820-
2821-// TagValues returns every first value belonging to tags named name.
2822-func TagValues(e Event, name string) []string {
2823- values := make([]string, 0)
2824- for _, tag := range e.Tags {
2825- if len(tag) > 1 && tag[0] == name {
2826- values = append(values, tag[1])
2827- }
2828- }
2829- return values
2830-}
2831-
2832-// Expiration returns the positive Unix timestamp in the expiration tag.
2833-// Malformed, missing and nonpositive values return zero.
2834-func Expiration(e Event) int64 {
2835- v, err := strconv.ParseInt(Tag(e, "expiration"), 10, 64)
2836- if err != nil || v <= 0 {
2837- return 0
2838- }
2839- return v
2840-}
2841-
2842-// Difficulty returns the NIP-13 proof-of-work difficulty encoded by id.
2843-func Difficulty(id string) int {
2844- parsed, err := nostr.IDFromHex(id)
2845- if err != nil {
2846- return 0
2847- }
2848- return nip13.Difficulty(parsed)
2849-}
2850-
2851-// CommittedDifficulty returns the target in a nonce tag's third field.
2852-func CommittedDifficulty(e Event) int {
2853- for _, tag := range e.Tags {
2854- if len(tag) > 2 && tag[0] == "nonce" {
2855- n, _ := strconv.Atoi(tag[2])
2856- return n
2857- }
2858- }
2859- return 0
2860-}
2861-
2862-// Sign derives the public key, ID and Schnorr signature for e from secretHex.
2863-// A nil tag slice is encoded as an empty tag list before signing.
2864-func Sign(e *Event, secretHex string) error {
2865- secret, err := decodeSecret(secretHex)
2866- if err != nil {
2867- return err
2868- }
2869- if e.Tags == nil {
2870- e.Tags = [][]string{}
2871- }
2872- key, pubkey := btcec.PrivKeyFromBytes(secret[:])
2873- e.PubKey = hex.EncodeToString(schnorr.SerializePubKey(pubkey))
2874- serialized, err := serializeForID(*e)
2875- if err != nil {
2876- return fmt.Errorf("serialize event: %w", err)
2877- }
2878- sum := sha256.Sum256(serialized)
2879- signature, err := schnorr.Sign(key, sum[:])
2880- if err != nil {
2881- return fmt.Errorf("sign event: %w", err)
2882- }
2883- e.ID = hex.EncodeToString(sum[:])
2884- e.Sig = hex.EncodeToString(signature.Serialize())
2885- return nil
2886-}
2887-
2888-// GenerateKey creates a random nonzero 32-byte private key in hexadecimal.
2889-func GenerateKey() (string, error) {
2890- for {
2891- key := make([]byte, 32)
2892- if _, err := rand.Read(key); err != nil {
2893- return "", fmt.Errorf("generate private key: %w", err)
2894- }
2895- if !allZero(key) {
2896- return hex.EncodeToString(key), nil
2897- }
2898- }
2899-}
2900-
2901-// PublicKey derives the x-only Schnorr public key for secretHex.
2902-func PublicKey(secretHex string) (string, error) {
2903- secret, err := decodeSecret(secretHex)
2904- if err != nil {
2905- return "", err
2906- }
2907- _, pubkey := btcec.PrivKeyFromBytes(secret[:])
2908- return hex.EncodeToString(schnorr.SerializePubKey(pubkey)), nil
2909-}
2910-
2911-// IsEphemeral reports whether k is in Nostr's ephemeral kind range.
2912-func IsEphemeral(k int) bool { return k >= 20000 && k < 30000 }
2913-
2914-// IsReplaceable reports whether k is a replaceable event kind.
2915-func IsReplaceable(k int) bool { return k == 0 || k == 3 || k >= 10000 && k < 20000 }
2916-
2917-// IsAddressable reports whether k is an addressable event kind.
2918-func IsAddressable(k int) bool { return k >= 30000 && k < 40000 }
2919-
2920-// IsPrivate reports whether k carries recipient-scoped content.
2921+import "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
2922+
2923+// Event is the public Nostr wire value. The alias keeps feature consumers
2924+// source-compatible while they migrate to protocol/nostr.
2925+type Event = nostr.Event
2926+
2927+func Validate(e Event) error { return nostr.Validate(e) }
2928+func Parse(raw []byte) (Event, error) { return nostr.Parse(raw) }
2929+func Canonical(e Event) ([]byte, error) { return nostr.Canonical(e) }
2930+func Tag(e Event, name string) string { return nostr.Tag(e, name) }
2931+func TagValues(e Event, name string) []string { return nostr.TagValues(e, name) }
2932+func Expiration(e Event) int64 { return nostr.Expiration(e) }
2933+func Difficulty(id string) int { return nostr.Difficulty(id) }
2934+func CommittedDifficulty(e Event) int { return nostr.CommittedDifficulty(e) }
2935+func Sign(e *Event, secretHex string) error { return nostr.Sign(e, secretHex) }
2936+func GenerateKey() (string, error) { return nostr.GenerateKey() }
2937+func PublicKey(secretHex string) (string, error) { return nostr.PublicKey(secretHex) }
2938+func IsEphemeral(k int) bool { return nostr.IsEphemeral(k) }
2939+func IsReplaceable(k int) bool { return nostr.IsReplaceable(k) }
2940+func IsAddressable(k int) bool { return nostr.IsAddressable(k) }
2941+
2942+// IsPrivate is the host's recipient-scoped kind classification, not a generic
2943+// Nostr range. Keep feature privacy decisions outside protocol/nostr.
2944 func IsPrivate(k int) bool {
2945 return k == 4 || k == 13 || k == KIND_WRAP || k == 21059 || k == KIND_NOSTR_CONNECT || k == KIND_PUSH_REGISTRATION
2946 }
2947-
2948-func hex64(s string) bool { return len(s) == 64 && isHex(s) }
2949-func isHex(s string) bool {
2950- for _, char := range s {
2951- if char >= '0' && char <= '9' || char >= 'a' && char <= 'f' {
2952- continue
2953- }
2954- return false
2955- }
2956- return true
2957-}
2958-
2959-func serializeForID(e Event) ([]byte, error) {
2960- value := []any{0, e.PubKey, e.CreatedAt, e.Kind, e.Tags, e.Content}
2961- var out bytes.Buffer
2962- encoder := json.NewEncoder(&out)
2963- encoder.SetEscapeHTML(false)
2964- if err := encoder.Encode(value); err != nil {
2965- return nil, err
2966- }
2967- serialized := bytes.TrimSuffix(out.Bytes(), []byte{'\n'})
2968- serialized = bytes.ReplaceAll(serialized, []byte(`\u2028`), []byte("\u2028"))
2969- serialized = bytes.ReplaceAll(serialized, []byte(`\u2029`), []byte("\u2029"))
2970- return serialized, nil
2971-}
2972-
2973-func verifySignature(e Event, hash []byte) bool {
2974- pubkey, err := hex.DecodeString(e.PubKey)
2975- if err != nil {
2976- return false
2977- }
2978- key, err := schnorr.ParsePubKey(pubkey)
2979- if err != nil {
2980- return false
2981- }
2982- signature, err := hex.DecodeString(e.Sig)
2983- if err != nil {
2984- return false
2985- }
2986- sig, err := schnorr.ParseSignature(signature)
2987- return err == nil && sig.Verify(hash, key)
2988-}
2989-
2990-func decodeSecret(value string) ([32]byte, error) {
2991- var secret [32]byte
2992- if len(value) != 64 {
2993- return secret, errors.New("invalid secret key: expected 64 hex characters")
2994- }
2995- if _, err := hex.Decode(secret[:], []byte(strings.ToLower(value))); err != nil {
2996- return secret, fmt.Errorf("invalid secret key: %w", err)
2997- }
2998- if allZero(secret[:]) {
2999- return secret, errors.New("invalid secret key")
3000- }
3001- return secret, nil
3002-}
3003-
3004-func allZero(value []byte) bool {
3005- for _, item := range value {
3006- if item != 0 {
3007- return false
3008- }
3009- }
3010- return true
3011-}
3012-
3013-func requireFields(raw []byte) error {
3014- var fields map[string]json.RawMessage
3015- if err := json.Unmarshal(raw, &fields); err != nil {
3016- return fmt.Errorf("invalid event JSON: %w", err)
3017- }
3018- for _, field := range []string{"id", "pubkey", "created_at", "kind", "tags", "content", "sig"} {
3019- if _, ok := fields[field]; !ok {
3020- return fmt.Errorf("invalid: missing %s", field)
3021- }
3022- }
3023- return nil
3024-}
3025diff --git a/internal/event/event_test.go b/internal/event/event_test.go
3026deleted file mode 100644
3027index bd0852d..0000000
3028--- a/internal/event/event_test.go
3029+++ /dev/null
3030@@ -1,127 +0,0 @@
3031-package event
3032-
3033-import (
3034- "bytes"
3035- "crypto/sha256"
3036- "encoding/hex"
3037- "encoding/json"
3038- "strings"
3039- "testing"
3040-)
3041-
3042-func TestSignParseValidateCanonical(t *testing.T) {
3043- secret := strings.Repeat("0", 63) + "1"
3044- e := Event{CreatedAt: 1, Kind: 1, Tags: [][]string{{"t", "go"}}, Content: "hello"}
3045- if err := Sign(&e, secret); err != nil {
3046- t.Fatalf("sign: %v", err)
3047- }
3048- if err := Validate(e); err != nil {
3049- t.Fatalf("validate: %v", err)
3050- }
3051- got, err := Parse(mustJSON(t, e))
3052- if err != nil {
3053- t.Fatalf("parse: %v", err)
3054- }
3055- canonical, err := Canonical(e)
3056- if err != nil {
3057- t.Fatalf("canonical: %v", err)
3058- }
3059- if string(canonical) != string(mustJSON(t, got)) {
3060- t.Fatalf("canonical changed event: %s", canonical)
3061- }
3062-}
3063-
3064-func TestFilterTagJSONAndEmptyLists(t *testing.T) {
3065- f, err := ParseFilter([]byte(`{"#t":["go"],"kinds":[],"limit":0}`))
3066- if err != nil {
3067- t.Fatalf("parse filter: %v", err)
3068- }
3069- if len(f.Tags["t"]) != 1 || len(f.Kinds) != 0 || f.Kinds == nil {
3070- t.Fatalf("filter fields lost: %#v", f)
3071- }
3072- e := Event{ID: strings.Repeat("a", 64), PubKey: strings.Repeat("b", 64), Kind: 1, Tags: [][]string{{"t", "go"}}}
3073- if Matches(f, e) {
3074- t.Fatal("empty kinds must match nothing")
3075- }
3076- f.Kinds = nil
3077- if !Matches(f, e) {
3078- t.Fatal("tag filter should match")
3079- }
3080- raw, err := json.Marshal(f)
3081- if err != nil || !strings.Contains(string(raw), `"#t"`) {
3082- t.Fatalf("marshal tags: %s (%v)", raw, err)
3083- }
3084-}
3085-
3086-func TestSearchTermsAndKinds(t *testing.T) {
3087- terms := SearchTerms("hello lang:en world:")
3088- if len(terms) != 1 || terms[0] != "hello" {
3089- t.Fatalf("terms: %#v", terms)
3090- }
3091- if !IsEphemeral(20001) || IsEphemeral(30000) || !IsReplaceable(10001) || !IsAddressable(30001) || !IsPrivate(4) || IsPrivate(KIND_DM) {
3092- t.Fatal("kind classification mismatch")
3093- }
3094-}
3095-
3096-func TestNIP01CanonicalEscapingMatchesJSONStringify(t *testing.T) {
3097- e := Event{CreatedAt: 1700000000, Kind: 1, Tags: [][]string{{"x", "a\b\f\u2028\u2029é"}}, Content: "quote \" slash \\ newline\n <tag>"}
3098- got, err := serializeForID(e)
3099- if err != nil {
3100- t.Fatal(err)
3101- }
3102- if bytes.Contains(got, []byte(`\u003c`)) || !bytes.Contains(got, []byte(`\b`)) || !bytes.Contains(got, []byte(`\f`)) {
3103- t.Fatalf("unexpected HTML/control escaping: %s", got)
3104- }
3105- if strings.Contains(string(got), `\u2028`) || strings.Contains(string(got), `\u2029`) {
3106- t.Fatalf("U+2028/U+2029 must remain literal like JSON.stringify: %s", got)
3107- }
3108-}
3109-
3110-func TestParseRequiresEveryWireField(t *testing.T) {
3111- e := Event{CreatedAt: 1, Kind: 1, Tags: [][]string{}, Content: ""}
3112- if err := Sign(&e, strings.Repeat("0", 63)+"1"); err != nil {
3113- t.Fatal(err)
3114- }
3115- raw, err := json.Marshal(e)
3116- if err != nil {
3117- t.Fatal(err)
3118- }
3119- for _, field := range []string{"id", "pubkey", "created_at", "kind", "tags", "content", "sig"} {
3120- var values map[string]json.RawMessage
3121- if err := json.Unmarshal(raw, &values); err != nil {
3122- t.Fatal(err)
3123- }
3124- delete(values, field)
3125- missing, err := json.Marshal(values)
3126- if err != nil {
3127- t.Fatal(err)
3128- }
3129- if _, err := Parse(missing); err == nil {
3130- t.Errorf("missing %s accepted", field)
3131- }
3132- }
3133-}
3134-
3135-func TestNIP01IDIsSHA256OfCanonicalArray(t *testing.T) {
3136- e := Event{CreatedAt: 1, Kind: 1, Tags: [][]string{}, Content: "hello"}
3137- if err := Sign(&e, strings.Repeat("0", 63)+"1"); err != nil {
3138- t.Fatal(err)
3139- }
3140- serialized, err := serializeForID(e)
3141- if err != nil {
3142- t.Fatal(err)
3143- }
3144- sum := sha256.Sum256(serialized)
3145- if e.ID != hex.EncodeToString(sum[:]) {
3146- t.Fatalf("id %s does not hash canonical array %x", e.ID, sum)
3147- }
3148-}
3149-
3150-func mustJSON(t *testing.T, value any) []byte {
3151- t.Helper()
3152- b, err := json.Marshal(value)
3153- if err != nil {
3154- t.Fatalf("marshal: %v", err)
3155- }
3156- return b
3157-}
3158diff --git a/internal/event/filter.go b/internal/event/filter.go
3159index 8f804d2..68bd075 100644
3160--- a/internal/event/filter.go
3161+++ b/internal/event/filter.go
3162@@ -1,193 +1,18 @@
3163 package event
3164
3165-import (
3166- "encoding/json"
3167- "fmt"
3168- "strings"
3169-)
3170+import "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
3171
3172-// Filter describes the event fields and tags a subscription or query accepts.
3173-// Fields combine with AND; values within a list are alternatives. Nil IDs,
3174-// Authors and Kinds leave that field unrestricted, while an empty non-nil list
3175-// matches nothing. Tags uses names without the wire prefix #. Since and Until
3176-// are inclusive Unix timestamps. Limit controls a historical result batch;
3177-// Matches evaluates each event independently of that limit.
3178-type Filter struct {
3179- IDs []string `json:"ids,omitempty"`
3180- Authors []string `json:"authors,omitempty"`
3181- Kinds []int `json:"kinds,omitempty"`
3182- Tags map[string][]string `json:"-"`
3183- Since *int64 `json:"since,omitempty"`
3184- Until *int64 `json:"until,omitempty"`
3185- Limit *int `json:"limit,omitempty"`
3186- Search string `json:"search,omitempty"`
3187-}
3188+// Filter is the public subscription and query value.
3189+type Filter = nostr.Filter
3190
3191-// ParseFilter decodes supported Nostr filter fields, including #tag keys, and
3192-// checks their JSON types.
3193-func ParseFilter(raw []byte) (Filter, error) {
3194- var values map[string]json.RawMessage
3195- if err := json.Unmarshal(raw, &values); err != nil {
3196- return Filter{}, errorsFilter("filter must be an object")
3197- }
3198- f := Filter{Tags: make(map[string][]string)}
3199- for key, value := range values {
3200- switch {
3201- case key == "ids", key == "authors":
3202- if !isJSONArray(value) {
3203- return Filter{}, errorsFilter(key + " must be a list of strings")
3204- }
3205- var out []string
3206- if err := json.Unmarshal(value, &out); err != nil {
3207- return Filter{}, errorsFilter(key + " must be a list of strings")
3208- }
3209- if key == "ids" {
3210- f.IDs = out
3211- } else {
3212- f.Authors = out
3213- }
3214- case key == "kinds":
3215- if !isJSONArray(value) {
3216- return Filter{}, errorsFilter("kinds must be a list of integers")
3217- }
3218- if err := json.Unmarshal(value, &f.Kinds); err != nil {
3219- return Filter{}, errorsFilter("kinds must be a list of integers")
3220- }
3221- case key == "since", key == "until":
3222- var n int64
3223- if err := json.Unmarshal(value, &n); err != nil {
3224- return Filter{}, errorsFilter(key + " must be an integer")
3225- }
3226- if key == "since" {
3227- f.Since = &n
3228- } else {
3229- f.Until = &n
3230- }
3231- case key == "limit":
3232- var n int
3233- if err := json.Unmarshal(value, &n); err != nil {
3234- return Filter{}, errorsFilter("limit must be an integer")
3235- }
3236- f.Limit = &n
3237- case key == "search":
3238- if err := json.Unmarshal(value, &f.Search); err != nil {
3239- return Filter{}, errorsFilter("search must be a string")
3240- }
3241- case len(key) == 2 && key[0] == '#':
3242- if !isJSONArray(value) {
3243- return Filter{}, errorsFilter(key + " must be a list of strings")
3244- }
3245- var out []string
3246- if err := json.Unmarshal(value, &out); err != nil {
3247- return Filter{}, errorsFilter(key + " must be a list of strings")
3248- }
3249- f.Tags[key[1:]] = out
3250- }
3251- }
3252- return f, nil
3253-}
3254+func ParseFilter(raw []byte) (Filter, error) { return nostr.ParseFilter(raw) }
3255+func SearchTerms(query string) []string { return nostr.SearchTerms(query) }
3256
3257-// MarshalJSON encodes tag constraints with their Nostr #name wire keys.
3258-func (f Filter) MarshalJSON() ([]byte, error) {
3259- values := make(map[string]json.RawMessage, 7+len(f.Tags))
3260- if f.IDs != nil {
3261- values["ids"] = mustRaw(f.IDs)
3262- }
3263- if f.Authors != nil {
3264- values["authors"] = mustRaw(f.Authors)
3265- }
3266- if f.Kinds != nil {
3267- values["kinds"] = mustRaw(f.Kinds)
3268- }
3269- if f.Since != nil {
3270- values["since"] = mustRaw(f.Since)
3271- }
3272- if f.Until != nil {
3273- values["until"] = mustRaw(f.Until)
3274- }
3275- if f.Limit != nil {
3276- values["limit"] = mustRaw(f.Limit)
3277- }
3278- if f.Search != "" {
3279- values["search"] = mustRaw(f.Search)
3280- }
3281- for key, tags := range f.Tags {
3282- values["#"+key] = mustRaw(tags)
3283- }
3284- return json.Marshal(values)
3285-}
3286-
3287-// Matches reports whether e satisfies every populated constraint in f.
3288-// Search terms are matched against public content and exclude private kinds.
3289+// Matches preserves the host's search visibility rule for existing consumers.
3290+// Generic field, tag and content matching lives in protocol/nostr.
3291 func Matches(f Filter, e Event) bool {
3292- if f.IDs != nil && !contains(f.IDs, e.ID) || f.Authors != nil && !contains(f.Authors, e.PubKey) || f.Kinds != nil && !containsInt(f.Kinds, e.Kind) {
3293- return false
3294- }
3295- if f.Since != nil && e.CreatedAt < *f.Since || f.Until != nil && e.CreatedAt > *f.Until {
3296+ if IsPrivate(e.Kind) && len(nostr.SearchTerms(f.Search)) > 0 {
3297 return false
3298 }
3299- for name, values := range f.Tags {
3300- if !tagMatch(e, name, values) {
3301- return false
3302- }
3303- }
3304- terms := SearchTerms(f.Search)
3305- if len(terms) == 0 {
3306- return true
3307- }
3308- if IsPrivate(e.Kind) {
3309- return false
3310- }
3311- content := strings.ToLower(e.Content)
3312- for _, term := range terms {
3313- if !strings.Contains(content, strings.ToLower(term)) {
3314- return false
3315- }
3316- }
3317- return true
3318-}
3319-
3320-// SearchTerms splits a search query into terms and drops protocol-style
3321-// name:value tokens.
3322-func SearchTerms(query string) []string { return slicesFilter(strings.Fields(query)) }
3323-
3324-func slicesFilter(words []string) []string {
3325- out := make([]string, 0, len(words))
3326- for _, word := range words {
3327- if !strings.Contains(word, ":") {
3328- out = append(out, word)
3329- }
3330- }
3331- return out
3332-}
3333-func contains(values []string, want string) bool {
3334- for _, value := range values {
3335- if value == want {
3336- return true
3337- }
3338- }
3339- return false
3340-}
3341-func containsInt(values []int, want int) bool {
3342- for _, value := range values {
3343- if value == want {
3344- return true
3345- }
3346- }
3347- return false
3348-}
3349-func tagMatch(e Event, name string, values []string) bool {
3350- for _, tag := range e.Tags {
3351- if len(tag) > 1 && tag[0] == name && contains(values, tag[1]) {
3352- return true
3353- }
3354- }
3355- return false
3356-}
3357-func mustRaw(value any) json.RawMessage { data, _ := json.Marshal(value); return data }
3358-func errorsFilter(message string) error { return fmt.Errorf("%s", message) }
3359-
3360-func isJSONArray(value json.RawMessage) bool {
3361- trimmed := strings.TrimSpace(string(value))
3362- return len(trimmed) > 0 && trimmed[0] == '['
3363+ return nostr.Matches(f, e)
3364 }
3365diff --git a/internal/event/sign_empty_tags_test.go b/internal/event/sign_empty_tags_test.go
3366deleted file mode 100644
3367index 7790671..0000000
3368--- a/internal/event/sign_empty_tags_test.go
3369+++ /dev/null
3370@@ -1,24 +0,0 @@
3371-package event
3372-
3373-import (
3374- "encoding/json"
3375- "strings"
3376- "testing"
3377-)
3378-
3379-func TestSignEmptyTagsProducesValidWireEvent(t *testing.T) {
3380- e := Event{Kind: 0, CreatedAt: 1, Content: "{}"}
3381- if err := Sign(&e, strings.Repeat("0", 63)+"1"); err != nil {
3382- t.Fatal(err)
3383- }
3384- raw, err := json.Marshal(e)
3385- if err != nil {
3386- t.Fatal(err)
3387- }
3388- if _, err := Parse(raw); err != nil {
3389- t.Fatalf("signed event rejected: %s: %v", raw, err)
3390- }
3391- if !strings.Contains(string(raw), `"tags":[]`) {
3392- t.Fatalf("tags must be an array: %s", raw)
3393- }
3394-}
3395diff --git a/internal/gitrelay/admission.go b/internal/gitrelay/admission.go
3396deleted file mode 100644
3397index 4c072ad..0000000
3398--- a/internal/gitrelay/admission.go
3399+++ /dev/null
3400@@ -1,674 +0,0 @@
3401-package gitrelay
3402-
3403-import (
3404- "context"
3405- "database/sql"
3406- "encoding/json"
3407- "errors"
3408- "fmt"
3409- "os"
3410- "os/exec"
3411- "path/filepath"
3412- "sort"
3413- "strings"
3414- "time"
3415-
3416- "github.com/FelineStateMachine/tinyrelay/internal/event"
3417- "github.com/FelineStateMachine/tinyrelay/internal/storage"
3418-)
3419-
3420-// ValidateEvent checks the event's signature and structural validity.
3421-func (g *GitRelay) ValidateEvent(e event.Event) error { return event.Validate(e) }
3422-
3423-// Validate performs GRASP admission and returns the repository authority
3424-// that the caller must associate with its event transaction. It has no store
3425-// side effects.
3426-func (g *GitRelay) Validate(ctx context.Context, e event.Event) (Repository, error) {
3427- if err := event.Validate(e); err != nil {
3428- return Repository{}, err
3429- }
3430- if e.Kind != 30617 && e.Kind != 30618 {
3431- return Repository{}, fmt.Errorf("unsupported: event kind %d is not GRASP repository metadata", e.Kind)
3432- }
3433- r, err := g.parseRepository(e)
3434- if err != nil {
3435- return Repository{}, err
3436- }
3437- if e.Kind == 30617 {
3438- if err := g.validateArchiveAnnouncement(r); err != nil {
3439- return Repository{}, err
3440- }
3441- }
3442- if g.authorize != nil {
3443- if err := g.authorize(ctx, e, r); err != nil {
3444- return Repository{}, err
3445- }
3446- } else if !g.IsMaintainer(ctx, r, e.PubKey) {
3447- return Repository{}, errors.New("blocked: repository metadata must be signed by owner")
3448- }
3449- if e.Kind == 30618 {
3450- if r.EventID == "" {
3451- return Repository{}, errors.New("blocked: repository announcement is missing")
3452- }
3453- if err := g.validateRefs(r.Refs); err != nil {
3454- return Repository{}, err
3455- }
3456- // Missing objects are a durable pending transition. The signed state is
3457- // accepted, but remains invisible until receive-pack supplies every tip.
3458- }
3459- return r, nil
3460-}
3461-
3462-// CommitAfterStore applies the Git-side transition after the caller has
3463-// durably saved the event. It intentionally does not call Store.Save.
3464-func (g *GitRelay) CommitAfterStore(ctx context.Context, e event.Event, repo Repository) error {
3465- return g.commitAfterStore(ctx, e, repo, true)
3466-}
3467-
3468-// CommitAfterStoreNoNotify stages the Git transition without invoking the
3469-// host fanout callback. Use this while already inside the relay publish
3470-// fence; the durable work intent will release visibility afterward.
3471-func (g *GitRelay) CommitAfterStoreNoNotify(ctx context.Context, e event.Event, repo Repository) error {
3472- return g.commitAfterStore(ctx, e, repo, false)
3473-}
3474-
3475-func (g *GitRelay) commitAfterStore(ctx context.Context, e event.Event, repo Repository, notify bool) error {
3476- g.commitMu.Lock()
3477- err := g.stageAfterStore(ctx, e, repo)
3478- pending := g.IsPending(e.ID)
3479- g.commitMu.Unlock()
3480- // The host callback enters its publish fence. Never hold the Git lock
3481- // here: synchronous publication already enters these locks in reverse.
3482- if err == nil && !pending && notify && g.onPromote != nil {
3483- return g.onPromote(ctx, e.ID, repo)
3484- }
3485- return err
3486-}
3487-
3488-func (g *GitRelay) stageAfterStore(ctx context.Context, e event.Event, repo Repository) error {
3489- current, err := g.currentMetadata(ctx, e)
3490- if err != nil {
3491- return err
3492- }
3493- if !current {
3494- // A queued intent may outlive a newer addressable announcement/state.
3495- // Never let that stale intent replace the currently authorized hook.
3496- return nil
3497- }
3498- if e.Kind == 30617 {
3499- // Announcements and ref state are separate addressable events. An
3500- // announcement update must retain the current signed refs and HEAD.
3501- var raw string
3502- err := g.store.DB().QueryRowContext(ctx, `SELECT raw FROM events WHERE kind=30618 AND pubkey=? AND d=? ORDER BY created_at DESC,id ASC LIMIT 1`, e.PubKey, event.Tag(e, "d")).Scan(&raw)
3503- if err != nil && !errors.Is(err, sql.ErrNoRows) {
3504- return err
3505- }
3506- if err == nil {
3507- state, err := event.Parse([]byte(raw))
3508- if err != nil {
3509- return err
3510- }
3511- stateRepo, err := g.parseRepository(state)
3512- if err != nil {
3513- return err
3514- }
3515- repo.Refs, repo.Head, repo.EventID = stateRepo.Refs, stateRepo.Head, state.ID
3516- }
3517- }
3518- jr := journalRecord{Repository: key(repo.Owner, repo.Identifier), EventID: e.ID, Kind: e.Kind, Refs: repo.Refs, Head: repo.Head}
3519- if err := g.writeJournal(repo, jr); err != nil {
3520- return err
3521- }
3522- g.mu.Lock()
3523- g.repos[key(repo.Owner, repo.Identifier)] = repo
3524- g.mu.Unlock()
3525- if err := g.ensureRepo(repo); err != nil {
3526- return err
3527- }
3528- pending := false
3529- if e.Kind == 30618 {
3530- var err error
3531- if g.stateObjectsPresent(ctx, repo) {
3532- err = g.writeState(repo)
3533- } else {
3534- err = g.writePendingState(repo)
3535- g.mu.Lock()
3536- g.pending[e.ID] = struct{}{}
3537- g.mu.Unlock()
3538- pending = true
3539- }
3540- if err != nil {
3541- return err
3542- }
3543- }
3544- if pending {
3545- return nil
3546- }
3547- if err := g.commitJournal(repo, jr); err != nil {
3548- return err
3549- }
3550- return nil
3551-}
3552-
3553-func (g *GitRelay) currentMetadata(ctx context.Context, e event.Event) (bool, error) {
3554- if e.Kind != 30617 && e.Kind != 30618 {
3555- return true, nil
3556- }
3557- var id string
3558- err := g.store.DB().QueryRowContext(ctx, `SELECT events.id FROM events JOIN tags ON tags.event_id=events.id AND tags.name='d' AND tags.value=? WHERE events.pubkey=? AND events.kind=? ORDER BY events.created_at DESC,events.id ASC LIMIT 1`, event.Tag(e, "d"), e.PubKey, e.Kind).Scan(&id)
3559- if errors.Is(err, sql.ErrNoRows) {
3560- return false, nil
3561- }
3562- if err != nil {
3563- return false, err
3564- }
3565- return id == e.ID, nil
3566-}
3567-
3568-// IsPending lets the event admission/read layer hide a metadata event until
3569-// its signed Git tips are verified locally.
3570-func (g *GitRelay) IsPending(eventID string) bool {
3571- g.mu.RLock()
3572- _, ok := g.pending[eventID]
3573- g.mu.RUnlock()
3574- return ok
3575-}
3576-
3577-// ExpirePending removes the file and in-memory authorities for state events
3578-// whose retained deadline has elapsed. The SQL event sweep calls this before
3579-// deleting the corresponding event so a stale journal cannot promote it after
3580-// a restart.
3581-func (g *GitRelay) ExpirePending(ctx context.Context, eventIDs []string) error {
3582- if g == nil || len(eventIDs) == 0 {
3583- return nil
3584- }
3585- want := make(map[string]struct{}, len(eventIDs))
3586- for _, id := range eventIDs {
3587- want[id] = struct{}{}
3588- }
3589- entries, err := os.ReadDir(g.journalDir())
3590- if err != nil && !errors.Is(err, os.ErrNotExist) {
3591- return err
3592- }
3593- for _, entry := range entries {
3594- if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
3595- continue
3596- }
3597- path := filepath.Join(g.journalDir(), entry.Name())
3598- body, err := os.ReadFile(path)
3599- if err != nil {
3600- return err
3601- }
3602- var jr journalRecord
3603- if err := json.Unmarshal(body, &jr); err != nil {
3604- return err
3605- }
3606- if _, ok := want[jr.EventID]; !ok {
3607- continue
3608- }
3609- owner, identifier, ok := strings.Cut(jr.Repository, "\x00")
3610- if ok {
3611- _ = os.Remove(g.pendingStatePath(Repository{Owner: owner, Identifier: identifier}))
3612- _ = os.Remove(g.pendingHeadPath(Repository{Owner: owner, Identifier: identifier}))
3613- }
3614- if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
3615- return err
3616- }
3617- }
3618- g.mu.Lock()
3619- for id := range want {
3620- delete(g.pending, id)
3621- }
3622- g.mu.Unlock()
3623- return ctx.Err()
3624-}
3625-
3626-// PruneRef removes an expired native Git ref before its retained metadata is
3627-// deleted. repository may be the storage key or its identifier.
3628-func (g *GitRelay) PruneRef(ctx context.Context, repository, ref string) error {
3629- if g == nil || !validRef(ref) {
3630- return errors.New("git relay: invalid expired ref")
3631- }
3632- g.mu.RLock()
3633- var found *Repository
3634- for _, candidate := range g.repos {
3635- if key(candidate.Owner, candidate.Identifier) == repository || candidate.Identifier == repository {
3636- copy := candidate
3637- found = &copy
3638- break
3639- }
3640- }
3641- g.mu.RUnlock()
3642- if found == nil {
3643- return nil
3644- }
3645- if err := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(*found), "update-ref", "-d", ref).Run(); err != nil {
3646- return err
3647- }
3648- g.mu.Lock()
3649- if current, ok := g.repos[key(found.Owner, found.Identifier)]; ok {
3650- delete(current.Refs, ref)
3651- g.repos[key(found.Owner, found.Identifier)] = current
3652- }
3653- g.mu.Unlock()
3654- return nil
3655-}
3656-
3657-// PromotePending verifies and exposes a previously accepted state event. It
3658-// is useful for local receive-pack integrations; ServeHTTP invokes it after a
3659-// smart push as well.
3660-func (g *GitRelay) PromotePending(ctx context.Context, repo Repository) error {
3661- g.commitMu.Lock()
3662- promoted, err := g.promotePending(ctx, repo)
3663- g.commitMu.Unlock()
3664- if err == nil && promoted && g.onPromote != nil {
3665- return g.onPromote(ctx, repo.EventID, repo)
3666- }
3667- return err
3668-}
3669-
3670-func (g *GitRelay) promotePending(ctx context.Context, repo Repository) (bool, error) {
3671- var current int
3672- if err := g.store.DB().QueryRowContext(ctx, `SELECT count(*) FROM events WHERE id=? AND kind=30618`, repo.EventID).Scan(&current); err != nil {
3673- return false, err
3674- }
3675- if current == 0 {
3676- return false, nil
3677- }
3678- if !g.stateObjectsPresent(ctx, repo) {
3679- return false, nil
3680- }
3681- g.mu.RLock()
3682- _, tracked := g.pending[repo.EventID]
3683- g.mu.RUnlock()
3684- if _, err := os.Stat(g.pendingStatePath(repo)); err != nil && !tracked {
3685- return false, nil
3686- }
3687- if err := g.writeState(repo); err != nil {
3688- return false, err
3689- }
3690- _ = os.Remove(g.pendingStatePath(repo))
3691- _ = os.Remove(g.pendingHeadPath(repo))
3692- g.mu.Lock()
3693- delete(g.pending, repo.EventID)
3694- g.mu.Unlock()
3695- jr := journalRecord{Repository: key(repo.Owner, repo.Identifier), EventID: repo.EventID, Kind: 30618, Refs: repo.Refs, Head: repo.Head}
3696- if err := g.commitJournal(repo, jr); err != nil {
3697- return false, err
3698- }
3699- return true, nil
3700-}
3701-
3702-// Publish ingests a signed repository announcement or state event. Callers
3703-// must validate the event before invoking this method; ValidateEvent is
3704-// provided for callers that want one explicit boundary.
3705-func (g *GitRelay) Publish(ctx context.Context, e event.Event) error {
3706- if err := event.Validate(e); err != nil {
3707- return err
3708- }
3709- if e.Kind != 30617 && e.Kind != 30618 {
3710- if !((g.grasp06 || g.policy().Features.Grasp06) && (e.Kind == 1617 || e.Kind == 1618 || e.Kind == 1619)) {
3711- return fmt.Errorf("unsupported: event kind %d is not GRASP repository metadata", e.Kind)
3712- }
3713- g.commitMu.Lock()
3714- defer g.commitMu.Unlock()
3715- return g.publishPR(ctx, e)
3716- }
3717- repo, err := g.Validate(ctx, e)
3718- if err != nil {
3719- return err
3720- }
3721- if _, err := g.store.Save(ctx, e, storage.SaveOptions{Now: time.Now().Unix()}); err != nil {
3722- return err
3723- }
3724- return g.CommitAfterStore(ctx, e, repo)
3725-}
3726-
3727-func (g *GitRelay) parseRepository(e event.Event) (Repository, error) {
3728- id := event.Tag(e, "d")
3729- if !validIdentifier(id) {
3730- return Repository{}, errors.New("invalid: repository identifier")
3731- }
3732- r := Repository{Owner: e.PubKey, Identifier: id, EventID: e.ID, Refs: map[string]string{}}
3733- if e.Kind == 30617 {
3734- for _, tag := range e.Tags {
3735- if len(tag) < 2 {
3736- continue
3737- }
3738- switch tag[0] {
3739- case "private":
3740- r.Private = tag[1] == "true"
3741- case "clone":
3742- r.Clone = append(r.Clone, tag[1:]...)
3743- case "relays":
3744- r.Relays = append(r.Relays, tag[1:]...)
3745- case "maintainers":
3746- r.Maintainers = append(r.Maintainers, tag[1:]...)
3747- }
3748- }
3749- return r, nil
3750- }
3751- // State is replaceable: bind it to the current announcement owner and
3752- // carry the announcement's identity into the hook/state file.
3753- state := Repository{Owner: e.PubKey, Identifier: id, EventID: e.ID, Refs: map[string]string{}}
3754- for _, tag := range e.Tags {
3755- if len(tag) == 0 {
3756- continue
3757- }
3758- if tag[0] == "HEAD" {
3759- if len(tag) != 2 || state.Head != "" {
3760- return Repository{}, errors.New("invalid: repository state has multiple HEAD tags")
3761- }
3762- state.Head = tag[1]
3763- continue
3764- }
3765- if len(tag) < 2 {
3766- continue
3767- }
3768- if strings.HasPrefix(tag[0], "refs/") {
3769- if _, duplicate := state.Refs[tag[0]]; duplicate || tag[1] == "" || !isObjectID(tag[1]) || strings.Trim(tag[1], "0") == "" {
3770- return Repository{}, fmt.Errorf("invalid: ref %s is not a SHA-1", tag[0])
3771- }
3772- state.Refs[tag[0]] = tag[1]
3773- }
3774- }
3775- g.mu.RLock()
3776- ann := g.repos[key(e.PubKey, id)]
3777- g.mu.RUnlock()
3778- if ann.Owner == "" {
3779- q, err := g.store.Query(context.Background(), event.Filter{Kinds: []int{30617}, Tags: map[string][]string{"d": {id}}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 0})
3780- if err != nil {
3781- return Repository{}, err
3782- }
3783- if len(q.Events) == 0 {
3784- return Repository{}, errors.New("blocked: repository announcement is missing")
3785- }
3786- for _, candidate := range q.Events {
3787- ann, err = g.parseRepository(candidate)
3788- if err == nil && g.IsMaintainer(context.Background(), ann, e.PubKey) {
3789- break
3790- }
3791- ann = Repository{}
3792- }
3793- if ann.Owner == "" {
3794- return Repository{}, errors.New("blocked: state author is not an accepted repository maintainer")
3795- }
3796- }
3797- if !g.IsMaintainer(context.Background(), ann, e.PubKey) {
3798- return Repository{}, errors.New("blocked: state author is not an accepted repository maintainer")
3799- }
3800- state.Owner, state.Private = ann.Owner, ann.Private
3801- state.Clone = append([]string(nil), ann.Clone...)
3802- state.Relays = append([]string(nil), ann.Relays...)
3803- state.Maintainers = append([]string(nil), ann.Maintainers...)
3804- state.EventID = e.ID
3805- if err := validateHead(state.Head, state.Refs); err != nil {
3806- return Repository{}, err
3807- }
3808- return state, nil
3809-}
3810-
3811-func validateHead(head string, refs map[string]string) error {
3812- if head == "" {
3813- return nil
3814- }
3815- if !strings.HasPrefix(head, "ref: ") {
3816- return errors.New("invalid: HEAD must use ref: syntax")
3817- }
3818- ref := strings.TrimPrefix(head, "ref: ")
3819- if !strings.HasPrefix(ref, "refs/heads/") && !strings.HasPrefix(ref, "refs/tags/") {
3820- return errors.New("invalid: HEAD must name a branch or tag")
3821- }
3822- if !validRef(ref) {
3823- return errors.New("invalid: HEAD ref")
3824- }
3825- return nil
3826-}
3827-
3828-func (g *GitRelay) validateRefs(refs map[string]string) error {
3829- for ref, oid := range refs {
3830- if !validRef(ref) || oid != "" && !isObjectID(oid) {
3831- return fmt.Errorf("invalid: ref %s", ref)
3832- }
3833- }
3834- return nil
3835-}
3836-
3837-// ErrIncomplete reports a synchronization pass that saved progress and has
3838-// more history to fetch. The scheduler continues it soon without counting a
3839-// failure.
3840-var ErrIncomplete = errors.New("synchronization incomplete")
3841-
3842-// Capabilities returns only protocol surfaces that this instance can serve.
3843-func (g *GitRelay) Capabilities() []string {
3844- p := g.policy()
3845- c := []string{"GRASP-01"}
3846- if p.Features.Grasp02 && g.eventSync != nil {
3847- c = append(c, "GRASP-02")
3848- if p.Features.Grasp03 {
3849- c = append(c, "GRASP-03")
3850- }
3851- }
3852- if g.grasp06 || p.Features.Grasp06 {
3853- c = append(c, "GRASP-06")
3854- }
3855- if p.PrivateServiceEnabled() {
3856- c = append(c, "GRASP-08")
3857- }
3858- return c
3859-}
3860-
3861-// SupportedGRASPs is the NIP-11-facing capability list. It deliberately
3862-// mirrors Capabilities so callers cannot advertise preview protocols by hand.
3863-func (g *GitRelay) SupportedGRASPs() []string { return g.Capabilities() }
3864-
3865-// DeletePRRef removes a temporary GRASP-06 ref before its Nostr metadata is
3866-// deleted. Identity accepts the source coordinates used by delivery jobs:
3867-// pr:<owner>:<identifier> or 30617:<owner>:<identifier>.
3868-func (g *GitRelay) DeletePRRef(ctx context.Context, identity, ref string) error {
3869- parts := strings.SplitN(identity, ":", 3)
3870- if len(parts) != 3 || (parts[0] != "pr" && parts[0] != "30617") || !isPubKey(parts[1]) || parts[2] == "" || strings.ContainsAny(parts[2], `/\\`) {
3871- return errors.New("invalid: PR repository identity")
3872- }
3873- if !strings.HasPrefix(ref, "refs/nostr/") || len(strings.TrimPrefix(ref, "refs/nostr/")) != 64 {
3874- return errors.New("invalid: PR ref")
3875- }
3876- r := Repository{Owner: parts[1], Identifier: parts[2], Alternative: true, Refs: map[string]string{}}
3877- if err := g.ensureRepo(r); err != nil {
3878- return err
3879- }
3880- cmd := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(r), "update-ref", "-d", ref)
3881- if out, err := cmd.CombinedOutput(); err != nil {
3882- return fmt.Errorf("delete PR ref: %w: %s", err, strings.TrimSpace(string(out)))
3883- }
3884- return g.removeRefFromPending(r, ref)
3885-}
3886-
3887-func (g *GitRelay) removeRefFromPending(r Repository, ref string) error {
3888- b, err := os.ReadFile(g.pendingStatePath(r))
3889- if errors.Is(err, os.ErrNotExist) {
3890- return nil
3891- }
3892- if err != nil {
3893- return err
3894- }
3895- var kept map[string]string = map[string]string{}
3896- for _, line := range strings.Split(string(b), "\n") {
3897- fields := strings.Fields(line)
3898- if len(fields) == 2 && fields[0] != ref {
3899- kept[fields[0]] = fields[1]
3900- }
3901- }
3902- if len(kept) == 0 {
3903- return os.Remove(g.pendingStatePath(r))
3904- }
3905- return g.writeRefsFile(g.pendingStatePath(r), kept)
3906-}
3907-
3908-// Reload rebuilds repository and pending indexes after a restore has inserted
3909-// events and native Git repositories on disk.
3910-func (g *GitRelay) Reload(ctx context.Context) error {
3911- g.commitMu.Lock()
3912- defer g.commitMu.Unlock()
3913- g.repoConfigMu.Lock()
3914- g.configured = nil
3915- g.repoConfigMu.Unlock()
3916- if err := g.recoverJournals(); err != nil {
3917- return err
3918- }
3919- q, err := g.store.Query(ctx, event.Filter{Kinds: []int{30617}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 0})
3920- if err != nil {
3921- return err
3922- }
3923- repos := make(map[string]Repository)
3924- for _, raw := range q.Events {
3925- r, parseErr := g.parseRepository(raw)
3926- if parseErr == nil {
3927- repos[key(r.Owner, r.Identifier)] = r
3928- }
3929- }
3930- // Recover only native refs that match the published signed authority.
3931- // Incoming receive-pack refs may still be awaiting promotion.
3932- for coordinate, announcement := range repos {
3933- if err := g.ensureRepo(announcement); err != nil {
3934- return err
3935- }
3936- refs, refsErr := g.publishedNativeRefs(announcement)
3937- if refsErr != nil {
3938- return refsErr
3939- }
3940- if len(refs) > 0 {
3941- announcement.Refs = refs
3942- if head, headErr := exec.Command("git", "--git-dir", g.repoPath(announcement), "symbolic-ref", "-q", "HEAD").Output(); headErr == nil {
3943- announcement.Head = "ref: " + strings.TrimSpace(string(head))
3944- }
3945- repos[coordinate] = announcement
3946- }
3947- }
3948- // If no native refs exist (for example an older store written before native
3949- // projection), recover the newest complete signed state as a fallback.
3950- for coordinate, announcement := range repos {
3951- if _, err := os.Stat(g.statePath(announcement)); err == nil {
3952- continue
3953- } else if !errors.Is(err, os.ErrNotExist) {
3954- return err
3955- }
3956- if _, err := os.Stat(g.pendingStatePath(announcement)); err == nil {
3957- continue
3958- } else if !errors.Is(err, os.ErrNotExist) {
3959- return err
3960- }
3961- states, stateErr := g.store.Query(ctx, event.Filter{Authors: append([]string{announcement.Owner}, announcement.Maintainers...), Kinds: []int{30618}, Tags: map[string][]string{"d": {announcement.Identifier}}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 0})
3962- if stateErr != nil {
3963- return stateErr
3964- }
3965- if len(states.Events) == 0 {
3966- continue
3967- }
3968- sort.Slice(states.Events, func(i, j int) bool { return states.Events[i].CreatedAt > states.Events[j].CreatedAt })
3969- state, parseErr := g.parseRepository(states.Events[0])
3970- if parseErr != nil || state.Owner != announcement.Owner || !g.stateObjectsPresent(ctx, state) {
3971- continue
3972- }
3973- repos[coordinate] = state
3974- }
3975- prs, prErr := g.store.Query(ctx, event.Filter{Kinds: []int{1617, 1618, 1619}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 0})
3976- if prErr != nil {
3977- return prErr
3978- }
3979- for _, raw := range prs.Events {
3980- identifier := event.Tag(raw, "d")
3981- if identifier == "" {
3982- identifier = event.Tag(raw, "a")
3983- }
3984- commit := event.Tag(raw, "c")
3985- if !validIdentifier(identifier) || !isObjectID(commit) {
3986- continue
3987- }
3988- pr := Repository{Owner: raw.PubKey, Identifier: identifier, EventID: raw.ID, Alternative: true, Refs: map[string]string{}}
3989- if _, err := os.Stat(g.repoPath(pr)); errors.Is(err, os.ErrNotExist) {
3990- continue
3991- } else if err != nil {
3992- return err
3993- }
3994- if refs, refsErr := g.publishedNativeRefs(pr); refsErr == nil && len(refs) > 0 {
3995- pr.Refs = refs
3996- }
3997- repos[key(pr.Owner, pr.Identifier)] = pr
3998- }
3999- entries, err := os.ReadDir(g.journalDir())
4000- if err != nil && !errors.Is(err, os.ErrNotExist) {
4001- return err
4002- }
4003- refreshedPending := make(map[string]struct{})
4004- for _, entry := range entries {
4005- if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
4006- continue
4007- }
4008- b, readErr := os.ReadFile(filepath.Join(g.journalDir(), entry.Name()))
4009- if readErr != nil {
4010- return readErr
4011- }
4012- var jr journalRecord
4013- if json.Unmarshal(b, &jr) == nil && jr.EventID != "" {
4014- refreshedPending[jr.EventID] = struct{}{}
4015- }
4016- }
4017- g.mu.Lock()
4018- g.repos = repos
4019- g.pending = refreshedPending
4020- g.mu.Unlock()
4021- return nil
4022-}
4023-
4024-func (g *GitRelay) publishPR(ctx context.Context, e event.Event) error {
4025- id := event.Tag(e, "d")
4026- if id == "" {
4027- id = event.Tag(e, "a")
4028- }
4029- if !validIdentifier(id) {
4030- return errors.New("invalid: PR repository identifier")
4031- }
4032- r := Repository{Owner: e.PubKey, Identifier: id, EventID: e.ID, Alternative: true, Refs: map[string]string{}}
4033- commit := event.Tag(e, "c")
4034- if commit != "" && !isObjectID(commit) {
4035- return errors.New("invalid: PR commit is not a SHA-1")
4036- }
4037- if commit != "" {
4038- r.Refs["refs/nostr/"+e.ID] = commit
4039- }
4040- if g.authorize != nil {
4041- if err := g.authorize(ctx, e, r); err != nil {
4042- return err
4043- }
4044- }
4045- if err := g.writeJournal(r, journalRecord{Repository: key(r.Owner, r.Identifier), EventID: e.ID, Kind: e.Kind, Refs: r.Refs, Head: r.Head}); err != nil {
4046- return err
4047- }
4048- if _, err := g.store.Save(ctx, e, storage.SaveOptions{Now: time.Now().Unix()}); err != nil {
4049- return err
4050- }
4051- if err := g.ensureRepo(r); err != nil {
4052- return err
4053- }
4054- g.mu.Lock()
4055- g.repos[key(r.Owner, r.Identifier)] = r
4056- g.mu.Unlock()
4057- pending := !g.stateObjectsPresent(ctx, r)
4058- if !pending {
4059- if err := g.writeState(r); err != nil {
4060- return err
4061- }
4062- } else {
4063- if err := g.writePendingState(r); err != nil {
4064- return err
4065- }
4066- g.mu.Lock()
4067- g.pending[e.ID] = struct{}{}
4068- g.mu.Unlock()
4069- }
4070- if pending {
4071- return nil
4072- }
4073- return g.commitJournal(r, journalRecord{Repository: key(r.Owner, r.Identifier), EventID: e.ID, Kind: e.Kind, Refs: r.Refs, Head: r.Head})
4074-}
4075diff --git a/internal/gitrelay/archive.go b/internal/gitrelay/archive.go
4076deleted file mode 100644
4077index 854c260..0000000
4078--- a/internal/gitrelay/archive.go
4079+++ /dev/null
4080@@ -1,79 +0,0 @@
4081-package gitrelay
4082-
4083-import (
4084- "context"
4085- "errors"
4086- "net/url"
4087- "strings"
4088-
4089- "github.com/FelineStateMachine/tinyrelay/internal/event"
4090- "github.com/FelineStateMachine/tinyrelay/internal/policy"
4091- "github.com/FelineStateMachine/tinyrelay/internal/storage"
4092-)
4093-
4094-// validateArchiveAnnouncement implements the GRASP-05 admission distinction:
4095-// an archive may announce a repository without clone or relay URLs, but the
4096-// archive profile depends on GRASP-02. A configured service keeps the normal
4097-// GRASP-01 requirement for announcements unless the operator enables both
4098-// extensions. Embedded GitRelay users without a public service URL retain the
4099-// permissive library behavior used by local integrations.
4100-func (g *GitRelay) validateArchiveAnnouncement(r Repository) error {
4101- if g.serviceURL == "" {
4102- return nil
4103- }
4104- if g.serviceListed(r) {
4105- return nil
4106- }
4107- if g.archiveRelated(context.Background(), r) {
4108- return nil
4109- }
4110- p := policy.Policy{}
4111- if g.policy != nil {
4112- p = g.policy()
4113- }
4114- if len(r.Clone) == 0 && len(r.Relays) == 0 && p.Features.Grasp02 && p.Features.Grasp05 {
4115- return nil
4116- }
4117- return errors.New("blocked: archive repository admission requires GRASP-02 and GRASP-05")
4118-}
4119-
4120-func (g *GitRelay) serviceListed(r Repository) bool {
4121- base, err := url.Parse(g.serviceURL)
4122- if err != nil || base.Host == "" {
4123- return false
4124- }
4125- cloneOK, relayOK := false, false
4126- for _, raw := range r.Clone {
4127- u, parseErr := url.Parse(raw)
4128- if parseErr == nil && (u.Scheme == "http" || u.Scheme == "https") && strings.EqualFold(u.Host, base.Host) && strings.HasSuffix(strings.TrimRight(u.Path, "/"), "/"+r.Identifier+".git") {
4129- cloneOK = true
4130- }
4131- }
4132- for _, raw := range r.Relays {
4133- u, parseErr := url.Parse(raw)
4134- if parseErr == nil && (u.Scheme == "ws" || u.Scheme == "wss") && strings.EqualFold(u.Host, base.Host) {
4135- relayOK = true
4136- }
4137- }
4138- return cloneOK && relayOK
4139-}
4140-
4141-func (g *GitRelay) archiveRelated(ctx context.Context, r Repository) bool {
4142- if g.store == nil {
4143- return false
4144- }
4145- q, err := g.store.Query(ctx, event.Filter{Kinds: []int{30617}, Tags: map[string][]string{"d": {r.Identifier}}}, storage.QueryOptions{Now: 0, Access: storage.Access{All: true}, Limit: 0})
4146- if err != nil {
4147- return false
4148- }
4149- for _, candidate := range q.Events {
4150- parsed, parseErr := g.parseRepository(candidate)
4151- if parseErr != nil || parsed.Identifier != r.Identifier {
4152- continue
4153- }
4154- if candidate.PubKey == r.Owner || contains(parsed.Maintainers, r.Owner) {
4155- return true
4156- }
4157- }
4158- return false
4159-}
4160diff --git a/internal/gitrelay/archive_test.go b/internal/gitrelay/archive_test.go
4161deleted file mode 100644
4162index 02afda1..0000000
4163--- a/internal/gitrelay/archive_test.go
4164+++ /dev/null
4165@@ -1,86 +0,0 @@
4166-package gitrelay
4167-
4168-import (
4169- "context"
4170- "path/filepath"
4171- "strings"
4172- "testing"
4173-
4174- "github.com/FelineStateMachine/tinyrelay/internal/event"
4175- "github.com/FelineStateMachine/tinyrelay/internal/policy"
4176- "github.com/FelineStateMachine/tinyrelay/internal/storage"
4177-)
4178-
4179-func TestArchiveAnnouncementRequiresOptInWhenServiceConfigured(t *testing.T) {
4180- root := t.TempDir()
4181- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
4182- if err != nil {
4183- t.Fatal(err)
4184- }
4185- defer store.Close()
4186- secret := strings.Repeat("0", 63) + "1"
4187- makeEvent := func() event.Event {
4188- e := event.Event{Kind: 30617, CreatedAt: 1, Tags: [][]string{{"d", "archive"}}}
4189- if err := event.Sign(&e, secret); err != nil {
4190- t.Fatal(err)
4191- }
4192- return e
4193- }
4194- for name, p := range map[string]policy.Policy{
4195- "disabled": {},
4196- "enabled": {Features: policy.Features{Grasp02: true, Grasp05: true}},
4197- } {
4198- t.Run(name, func(t *testing.T) {
4199- g, err := New(Config{Store: store, Root: filepath.Join(root, name), PublicURL: "https://relay.example", Policy: func() policy.Policy { return p }})
4200- if err != nil {
4201- t.Fatal(err)
4202- }
4203- _, err = g.Validate(context.Background(), makeEvent())
4204- if name == "disabled" && err == nil {
4205- t.Fatal("archive announcement accepted while GRASP-05 is disabled")
4206- }
4207- if name == "enabled" && err != nil {
4208- t.Fatalf("archive announcement rejected: %v", err)
4209- }
4210- })
4211- }
4212-}
4213-
4214-func TestArchiveAdmissionRecognizesServiceAndMaintainerExceptions(t *testing.T) {
4215- root := t.TempDir()
4216- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
4217- if err != nil {
4218- t.Fatal(err)
4219- }
4220- defer store.Close()
4221- ownerSecret := strings.Repeat("0", 63) + "1"
4222- maintainerSecret := strings.Repeat("0", 63) + "2"
4223- _, _ = event.PublicKey(ownerSecret)
4224- maintainer, _ := event.PublicKey(maintainerSecret)
4225- p := policy.Policy{Features: policy.Features{Grasp02: true, Grasp05: true}}
4226- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), PublicURL: "https://relay.example", Policy: func() policy.Policy { return p }, Authorize: func(context.Context, event.Event, Repository) error { return nil }})
4227- if err != nil {
4228- t.Fatal(err)
4229- }
4230- ownerAnnouncement := event.Event{Kind: 30617, CreatedAt: 1, Tags: [][]string{{"d", "repo"}, {"clone", "https://relay.example/repo.git"}, {"relays", "wss://relay.example"}, {"maintainers", maintainer}}}
4231- if err := event.Sign(&ownerAnnouncement, ownerSecret); err != nil {
4232- t.Fatal(err)
4233- }
4234- if err := g.Publish(context.Background(), ownerAnnouncement); err != nil {
4235- t.Fatal(err)
4236- }
4237- maintainerAnnouncement := event.Event{Kind: 30617, CreatedAt: 2, Tags: [][]string{{"d", "repo"}}}
4238- if err := event.Sign(&maintainerAnnouncement, maintainerSecret); err != nil {
4239- t.Fatal(err)
4240- }
4241- if _, err := g.Validate(context.Background(), maintainerAnnouncement); err != nil {
4242- t.Fatalf("recognized maintainer archive announcement rejected: %v", err)
4243- }
4244- wrong := event.Event{Kind: 30617, CreatedAt: 3, Tags: [][]string{{"d", "other"}, {"clone", "https://elsewhere.example/other.git"}, {"relays", "wss://elsewhere.example"}}}
4245- if err := event.Sign(&wrong, ownerSecret); err != nil {
4246- t.Fatal(err)
4247- }
4248- if _, err := g.Validate(context.Background(), wrong); err == nil {
4249- t.Fatal("announcement naming another service was accepted")
4250- }
4251-}
4252diff --git a/internal/gitrelay/backup_import.go b/internal/gitrelay/backup_import.go
4253deleted file mode 100644
4254index 8a515b1..0000000
4255--- a/internal/gitrelay/backup_import.go
4256+++ /dev/null
4257@@ -1,209 +0,0 @@
4258-package gitrelay
4259-
4260-// This is the delivery-job seam for converting bind.ws git-sqlite/1 backups
4261-// into native bare repositories. Validation happens before any object or ref
4262-// is written; Git remains the final object database and ref transaction.
4263-
4264-import (
4265- "bytes"
4266- "compress/zlib"
4267- "context"
4268- "crypto/sha1"
4269- "encoding/base64"
4270- "encoding/hex"
4271- "encoding/json"
4272- "errors"
4273- "fmt"
4274- "os/exec"
4275- "sort"
4276- "strings"
4277-)
4278-
4279-// BackupObject is one validated Git object and its compressed content chunks.
4280-type BackupObject struct {
4281- OID string `json:"oid"`
4282- Type string `json:"type"`
4283- Size int `json:"size"`
4284- Chunks []string `json:"chunks"`
4285-}
4286-
4287-// BackupRepository contains the objects and refs for one imported repository.
4288-type BackupRepository struct {
4289- Key string `json:"key"`
4290- Owner string `json:"owner"`
4291- Identifier string `json:"identifier"`
4292- Alternative bool `json:"alternative"`
4293- Objects []BackupObject `json:"objects"`
4294- Refs map[string]string `json:"refs"`
4295-}
4296-
4297-// GitBackup is the complete bind.ws/git-sqlite/1 import document.
4298-type GitBackup struct {
4299- Format string `json:"format"`
4300- Repositories []BackupRepository `json:"repositories"`
4301-}
4302-
4303-// ValidateGitBackup validates an import document without writing Git state.
4304-func ValidateGitBackup(input []byte) (GitBackup, error) {
4305- var backup GitBackup
4306- if err := json.Unmarshal(input, &backup); err != nil {
4307- return GitBackup{}, fmt.Errorf("invalid Git backup JSON: %w", err)
4308- }
4309- if backup.Format != "bind.ws/git-sqlite/1" {
4310- return GitBackup{}, errors.New("invalid Git backup format")
4311- }
4312- seen := make(map[string]struct{}, len(backup.Repositories))
4313- for i := range backup.Repositories {
4314- r := &backup.Repositories[i]
4315- if !isObjectID64(r.Key) || !isPubKey(r.Owner) || !validIdentifier(r.Identifier) {
4316- return GitBackup{}, errors.New("invalid Git repository identity")
4317- }
4318- if _, ok := seen[r.Key]; ok {
4319- return GitBackup{}, errors.New("duplicate Git repository")
4320- }
4321- seen[r.Key] = struct{}{}
4322- for _, obj := range r.Objects {
4323- if err := validateBackupObject(obj); err != nil {
4324- return GitBackup{}, err
4325- }
4326- }
4327- for ref, oid := range r.Refs {
4328- if !validRef(ref) || !isObjectID(oid) {
4329- return GitBackup{}, errors.New("invalid Git backup ref")
4330- }
4331- }
4332- }
4333- return backup, nil
4334-}
4335-
4336-func validateBackupObject(obj BackupObject) error {
4337- if !isObjectID(obj.OID) || obj.Size < 0 || (obj.Type != "blob" && obj.Type != "tree" && obj.Type != "commit" && obj.Type != "tag") || len(obj.Chunks) == 0 {
4338- return errors.New("invalid Git backup object")
4339- }
4340- data, err := decodeBackupObject(obj)
4341- if err != nil {
4342- return err
4343- }
4344- h := sha1.Sum(append([]byte(fmt.Sprintf("%s %d\x00", obj.Type, len(data))), data...))
4345- if len(data) != obj.Size || hex.EncodeToString(h[:]) != obj.OID {
4346- return errors.New("Git backup object hash mismatch")
4347- }
4348- return nil
4349-}
4350-
4351-func decodeBackupObject(obj BackupObject) ([]byte, error) {
4352- var compressed bytes.Buffer
4353- for _, chunk := range obj.Chunks {
4354- b, err := base64.StdEncoding.DecodeString(chunk)
4355- if err != nil {
4356- return nil, errors.New("invalid Git backup chunk")
4357- }
4358- compressed.Write(b)
4359- }
4360- r, err := zlib.NewReader(bytes.NewReader(compressed.Bytes()))
4361- if err != nil {
4362- return nil, errors.New("invalid Git backup compression")
4363- }
4364- var data bytes.Buffer
4365- if _, err := data.ReadFrom(r); err != nil {
4366- _ = r.Close()
4367- return nil, err
4368- }
4369- if err := r.Close(); err != nil {
4370- return nil, err
4371- }
4372- return data.Bytes(), nil
4373-}
4374-
4375-func isObjectID64(s string) bool { return len(s) == 64 && strings.Trim(s, "0123456789abcdef") == "" }
4376-func isPubKey(s string) bool { return len(s) == 64 && strings.Trim(s, "0123456789abcdef") == "" }
4377-func validRef(s string) bool {
4378- if !strings.HasPrefix(s, "refs/") || s == "refs/" || strings.Contains(s, "//") || strings.Contains(s, "..") || strings.Contains(s, "@{") {
4379- return false
4380- }
4381- if strings.ContainsAny(s, "~^:?*[\\") {
4382- return false
4383- }
4384- for _, r := range s {
4385- if r <= ' ' || r == 127 {
4386- return false
4387- }
4388- }
4389- for _, part := range strings.Split(s, "/") {
4390- if part == "" || strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".") || strings.HasSuffix(part, ".lock") {
4391- return false
4392- }
4393- }
4394- return true
4395-}
4396-
4397-func validIdentifier(id string) bool {
4398- if id == "" || id == "." || id == ".." || strings.ContainsAny(id, `/\\\"'$`+"`;&|()<>") {
4399- return false
4400- }
4401- for _, r := range id {
4402- if r <= ' ' {
4403- return false
4404- }
4405- }
4406- return true
4407-}
4408-
4409-// ImportGitBackup installs a validated backup into empty native repositories.
4410-// It refuses pre-existing refs or repositories to avoid silently mixing
4411-// namespaces; callers can stage an import into a fresh Git root and swap it.
4412-func (g *GitRelay) ImportGitBackup(ctx context.Context, input []byte) error {
4413- backup, err := ValidateGitBackup(input)
4414- if err != nil {
4415- return err
4416- }
4417- for _, r := range backup.Repositories {
4418- repo := Repository{Owner: r.Owner, Identifier: r.Identifier, Alternative: r.Alternative, Refs: r.Refs}
4419- if err := g.ensureRepo(repo); err != nil {
4420- return err
4421- }
4422- for _, ref := range r.Refs {
4423- if out, err := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(repo), "show-ref", "--verify", ref).CombinedOutput(); err == nil {
4424- return fmt.Errorf("Git backup target is not empty: %s", strings.TrimSpace(string(out)))
4425- }
4426- }
4427- for _, obj := range r.Objects {
4428- data, _ := decodeBackupObject(obj)
4429- cmd := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(repo), "hash-object", "-w", "--stdin", "-t", obj.Type)
4430- cmd.Stdin = bytes.NewReader(data)
4431- if out, err := cmd.CombinedOutput(); err != nil || strings.TrimSpace(string(out)) != obj.OID {
4432- return fmt.Errorf("install Git object %s: %w", obj.OID, err)
4433- }
4434- }
4435- for ref, oid := range r.Refs {
4436- if out, err := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(repo), "update-ref", ref, oid).CombinedOutput(); err != nil {
4437- return fmt.Errorf("install Git ref %s: %w: %s", ref, err, strings.TrimSpace(string(out)))
4438- }
4439- }
4440- if head := backupHead(r.Refs); head != "" {
4441- if out, err := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(repo), "symbolic-ref", "HEAD", head).CombinedOutput(); err != nil {
4442- return fmt.Errorf("install Git HEAD: %w: %s", err, strings.TrimSpace(string(out)))
4443- }
4444- }
4445- }
4446- return nil
4447-}
4448-
4449-func backupHead(refs map[string]string) string {
4450- for _, candidate := range []string{"refs/heads/main", "refs/heads/master"} {
4451- if _, ok := refs[candidate]; ok {
4452- return candidate
4453- }
4454- }
4455- branches := make([]string, 0)
4456- for ref := range refs {
4457- if strings.HasPrefix(ref, "refs/heads/") {
4458- branches = append(branches, ref)
4459- }
4460- }
4461- sort.Strings(branches)
4462- if len(branches) > 0 {
4463- return branches[0]
4464- }
4465- return ""
4466-}
4467diff --git a/internal/gitrelay/browse.go b/internal/gitrelay/browse.go
4468deleted file mode 100644
4469index d85c4df..0000000
4470--- a/internal/gitrelay/browse.go
4471+++ /dev/null
4472@@ -1,415 +0,0 @@
4473-package gitrelay
4474-
4475-import (
4476- "bufio"
4477- "bytes"
4478- "context"
4479- "errors"
4480- "fmt"
4481- "io"
4482- "os"
4483- "os/exec"
4484- "path"
4485- "sort"
4486- "strconv"
4487- "strings"
4488- "unicode/utf8"
4489-)
4490-
4491-const previewBytes = 256 * 1024
4492-
4493-type BrowseRequest struct {
4494- Owner string `json:"owner"`
4495- Repo string `json:"repo"`
4496- Ref string `json:"ref"`
4497- Path string `json:"path"`
4498- View string `json:"view"`
4499- Offset int `json:"offset"`
4500- Limit int `json:"limit"`
4501-}
4502-
4503-type BrowseEntry struct {
4504- Name string `json:"name"`
4505- Path string `json:"path"`
4506- Mode string `json:"mode"`
4507- Type string `json:"type"`
4508- OID string `json:"oid"`
4509- Size int64 `json:"size"`
4510-}
4511-
4512-type BrowseCommit struct {
4513- OID string `json:"oid"`
4514- Parents []string `json:"parents"`
4515- Author string `json:"author"`
4516- Time int64 `json:"time"`
4517- Subject string `json:"subject"`
4518-}
4519-
4520-type BrowsePage struct {
4521- Owner string `json:"owner"`
4522- Identifier string `json:"identifier"`
4523- Head string `json:"head"`
4524- Private bool `json:"private"`
4525- Clone []string `json:"clone"`
4526- Relays []string `json:"relays"`
4527- // Maintainers is filled by the host, which knows about agent grants.
4528- Maintainers []Maintainer `json:"maintainers,omitempty"`
4529- Refs map[string]string `json:"refs"`
4530- Commit string `json:"commit"`
4531- Path string `json:"path"`
4532- View string `json:"view"`
4533- Entries []BrowseEntry `json:"entries"`
4534- Content string `json:"content"`
4535- Binary bool `json:"binary"`
4536- Truncated bool `json:"truncated"`
4537- Commits []BrowseCommit `json:"commits"`
4538- Diff string `json:"diff"`
4539- NextOffset int `json:"next_offset"`
4540-}
4541-
4542-// Repositories returns detached snapshots. Callers must enforce tenant and
4543-// repository read permissions before revealing these records.
4544-func (g *GitRelay) Repositories() []Repository {
4545- g.mu.RLock()
4546- defer g.mu.RUnlock()
4547- rows := make([]Repository, 0, len(g.repos))
4548- for _, r := range g.repos {
4549- rows = append(rows, cloneBrowseRepo(r))
4550- }
4551- sort.Slice(rows, func(i, j int) bool {
4552- return key(rows[i].Owner, rows[i].Identifier) < key(rows[j].Owner, rows[j].Identifier)
4553- })
4554- return rows
4555-}
4556-
4557-func cloneBrowseRepo(r Repository) Repository {
4558- refs := make(map[string]string, len(r.Refs))
4559- for name, oid := range r.Refs {
4560- refs[name] = oid
4561- }
4562- r.Refs = refs
4563- r.Clone = append([]string(nil), r.Clone...)
4564- r.Relays = append([]string(nil), r.Relays...)
4565- r.Maintainers = append([]string(nil), r.Maintainers...)
4566- return r
4567-}
4568-
4569-func (g *GitRelay) BrowseRepository(owner, identifier string) (Repository, error) {
4570- g.mu.RLock()
4571- defer g.mu.RUnlock()
4572- r, ok := g.repos[key(owner, identifier)]
4573- if !ok {
4574- return Repository{}, os.ErrNotExist
4575- }
4576- return cloneBrowseRepo(r), nil
4577-}
4578-
4579-func validBrowsePath(value string) bool {
4580- return value == "" || (!strings.ContainsRune(value, 0) && !strings.HasPrefix(value, "/") && path.Clean(value) == value && value != ".." && !strings.HasPrefix(value, "../"))
4581-}
4582-
4583-func newBrowsePage(r Repository, q BrowseRequest) BrowsePage {
4584- return BrowsePage{Owner: r.Owner, Identifier: r.Identifier, Head: r.Head, Private: r.Private, Clone: r.Clone, Relays: r.Relays, Refs: r.Refs, Path: q.Path, View: q.View, NextOffset: -1}
4585-}
4586-
4587-// Browse only reads objects reachable from materialized, signed public refs.
4588-// It never checks out a working tree, invokes a shell, or follows a symlink.
4589-func (g *GitRelay) Browse(ctx context.Context, q BrowseRequest) (BrowsePage, error) {
4590- if !validBrowsePath(q.Path) || q.Offset < 0 {
4591- return BrowsePage{}, errors.New("invalid: repository path or offset")
4592- }
4593- if q.View == "" {
4594- q.View = "tree"
4595- }
4596- if q.Limit <= 0 || q.Limit > 200 {
4597- q.Limit = 50
4598- }
4599- r, err := g.BrowseRepository(q.Owner, q.Repo)
4600- if err != nil {
4601- return BrowsePage{}, err
4602- }
4603- page := newBrowsePage(r, q)
4604- r.Refs, err = g.browseRefs(ctx, r)
4605- if err != nil {
4606- return page, err
4607- }
4608- page.Refs = r.Refs
4609- if len(r.Refs) == 0 {
4610- return page, nil
4611- }
4612- page.Commit, err = g.browseCommit(ctx, r, q.Ref)
4613- if err != nil {
4614- return page, err
4615- }
4616- switch q.View {
4617- case "tree":
4618- err = g.browseTree(ctx, r, q, &page)
4619- case "file":
4620- err = g.browseFile(ctx, r, q, &page)
4621- case "history":
4622- err = g.browseHistory(ctx, r, q, &page)
4623- case "commit":
4624- err = g.browseDiff(ctx, r, &page)
4625- case "activity":
4626- default:
4627- err = errors.New("invalid: unknown repository view")
4628- }
4629- return page, err
4630-}
4631-
4632-func (g *GitRelay) browseCommand(ctx context.Context, r Repository, args ...string) *exec.Cmd {
4633- base := []string{"--no-pager", "--literal-pathspecs", "-c", "core.quotePath=false", "-c", "log.showSignature=false", "--git-dir", g.repoPath(r)}
4634- return exec.CommandContext(ctx, "git", append(base, args...)...)
4635-}
4636-
4637-func (g *GitRelay) browseRefs(ctx context.Context, r Repository) (map[string]string, error) {
4638- b, err := g.browseCommand(ctx, r, "for-each-ref", "--format=%(refname) %(objectname)", "refs/heads/", "refs/tags/", "refs/nostr/").Output()
4639- if err != nil {
4640- return nil, fmt.Errorf("read repository refs: %w", err)
4641- }
4642- refs := make(map[string]string)
4643- for _, line := range strings.Split(string(b), "\n") {
4644- fields := strings.Fields(line)
4645- if len(fields) == 2 && r.Refs[fields[0]] == fields[1] {
4646- refs[fields[0]] = fields[1]
4647- }
4648- }
4649- return refs, nil
4650-}
4651-
4652-func (g *GitRelay) browseCommit(ctx context.Context, r Repository, ref string) (string, error) {
4653- if ref == "" || ref == "HEAD" {
4654- ref = strings.TrimPrefix(r.Head, "ref: ")
4655- }
4656- for _, candidate := range []string{ref, "refs/heads/" + ref, "refs/tags/" + ref} {
4657- if oid, ok := r.Refs[candidate]; ok {
4658- return g.peelBrowseCommit(ctx, r, oid)
4659- }
4660- }
4661- if !hexObject(ref) {
4662- return "", errors.New("not found: published ref")
4663- }
4664- oid, err := g.peelBrowseCommit(ctx, r, ref)
4665- if err != nil {
4666- return "", err
4667- }
4668- cmd := g.browseCommand(ctx, r, "rev-list", "--max-count=1", "--stdin")
4669- var input strings.Builder
4670- input.WriteString(oid + "\n")
4671- for _, tip := range r.Refs {
4672- input.WriteString("^" + tip + "\n")
4673- }
4674- cmd.Stdin = strings.NewReader(input.String())
4675- b, err := cmd.Output()
4676- if err != nil || len(bytes.TrimSpace(b)) != 0 {
4677- return "", errors.New("not found: published commit")
4678- }
4679- return oid, nil
4680-}
4681-
4682-func hexObject(value string) bool {
4683- if len(value) != 40 && len(value) != 64 {
4684- return false
4685- }
4686- for _, ch := range value {
4687- if !(ch >= '0' && ch <= '9') && !(ch >= 'a' && ch <= 'f') {
4688- return false
4689- }
4690- }
4691- return true
4692-}
4693-
4694-func (g *GitRelay) peelBrowseCommit(ctx context.Context, r Repository, oid string) (string, error) {
4695- if !hexObject(oid) {
4696- return "", errors.New("invalid: object id")
4697- }
4698- b, err := g.browseCommand(ctx, r, "rev-parse", "--verify", "--end-of-options", oid+"^{commit}").Output()
4699- if err != nil {
4700- return "", errors.New("not found: commit")
4701- }
4702- return strings.TrimSpace(string(b)), nil
4703-}
4704-
4705-func readBrowseOutput(cmd *exec.Cmd, limit int64) ([]byte, bool, error) {
4706- out, err := cmd.StdoutPipe()
4707- if err != nil {
4708- return nil, false, err
4709- }
4710- if err = cmd.Start(); err != nil {
4711- return nil, false, err
4712- }
4713- b, readErr := io.ReadAll(io.LimitReader(out, limit+1))
4714- truncated := int64(len(b)) > limit
4715- if truncated || readErr != nil {
4716- _ = out.Close()
4717- _ = cmd.Process.Kill()
4718- }
4719- waitErr := cmd.Wait()
4720- if readErr != nil {
4721- return nil, false, readErr
4722- }
4723- if truncated {
4724- return b[:limit], true, nil
4725- }
4726- if waitErr != nil {
4727- return nil, false, fmt.Errorf("read Git object: %w", waitErr)
4728- }
4729- return b, false, nil
4730-}
4731-
4732-func (g *GitRelay) browseTree(ctx context.Context, r Repository, q BrowseRequest, page *BrowsePage) error {
4733- tree := page.Commit + ":" + q.Path
4734- cmd := g.browseCommand(ctx, r, "ls-tree", "-z", "-l", tree)
4735- out, err := cmd.StdoutPipe()
4736- if err != nil {
4737- return err
4738- }
4739- if err := cmd.Start(); err != nil {
4740- return err
4741- }
4742- reader := bufio.NewReader(out)
4743- readErr := readBrowseTree(reader, q, page)
4744- if readErr != nil || page.NextOffset >= 0 {
4745- // Closing this page cancels only its read-only Git child.
4746- _ = out.Close()
4747- _ = cmd.Process.Kill()
4748- }
4749- waitErr := cmd.Wait()
4750- if readErr != nil {
4751- return readErr
4752- }
4753- if page.NextOffset >= 0 {
4754- return nil
4755- }
4756- return waitErr
4757-}
4758-
4759-func readBrowseTree(reader *bufio.Reader, q BrowseRequest, page *BrowsePage) error {
4760- for index := 0; ; index++ {
4761- row, err := reader.ReadString(0)
4762- if errors.Is(err, io.EOF) && row == "" {
4763- return nil
4764- }
4765- if err != nil {
4766- return err
4767- }
4768- if index < q.Offset {
4769- continue
4770- }
4771- if len(page.Entries) >= q.Limit {
4772- page.NextOffset = index
4773- return nil
4774- }
4775- meta, name, ok := strings.Cut(strings.TrimSuffix(row, "\x00"), "\t")
4776- fields := strings.Fields(meta)
4777- if !ok || len(fields) != 4 {
4778- return errors.New("invalid Git tree entry")
4779- }
4780- var size int64
4781- if fields[3] != "-" {
4782- size, err = strconv.ParseInt(fields[3], 10, 64)
4783- if err != nil {
4784- return err
4785- }
4786- }
4787- page.Entries = append(page.Entries, BrowseEntry{Name: name, Path: path.Join(q.Path, name), Mode: fields[0], Type: fields[1], OID: fields[2], Size: size})
4788- }
4789-}
4790-
4791-func (g *GitRelay) fileObject(ctx context.Context, r Repository, commit, file string) (string, error) {
4792- if file == "" || !validBrowsePath(file) {
4793- return "", errors.New("invalid: file path")
4794- }
4795- b, err := g.browseCommand(ctx, r, "rev-parse", "--verify", "--end-of-options", commit+":"+file).Output()
4796- if err != nil {
4797- return "", os.ErrNotExist
4798- }
4799- oid := strings.TrimSpace(string(b))
4800- if !hexObject(oid) {
4801- return "", os.ErrNotExist
4802- }
4803- kind, err := g.browseCommand(ctx, r, "cat-file", "-t", oid).Output()
4804- if err != nil || strings.TrimSpace(string(kind)) != "blob" {
4805- return "", errors.New("not found: source file")
4806- }
4807- return oid, nil
4808-}
4809-
4810-func (g *GitRelay) browseFile(ctx context.Context, r Repository, q BrowseRequest, page *BrowsePage) error {
4811- oid, err := g.fileObject(ctx, r, page.Commit, q.Path)
4812- if err != nil {
4813- return err
4814- }
4815- b, truncated, err := readBrowseOutput(g.browseCommand(ctx, r, "cat-file", "blob", oid), previewBytes)
4816- if err != nil {
4817- return err
4818- }
4819- page.Binary = bytes.IndexByte(b, 0) >= 0 || (!utf8.Valid(b) && !truncated)
4820- page.Truncated = truncated
4821- if !page.Binary {
4822- page.Content = strings.ToValidUTF8(string(b), "�")
4823- }
4824- return nil
4825-}
4826-
4827-func (g *GitRelay) browseHistory(ctx context.Context, r Repository, q BrowseRequest, page *BrowsePage) error {
4828- args := []string{"log", "--format=%H%x00%P%x00%an%x00%at%x00%s%x00", "--max-count=" + strconv.Itoa(q.Limit+1), "--skip=" + strconv.Itoa(q.Offset), page.Commit, "--"}
4829- if q.Path != "" {
4830- args = append(args, q.Path)
4831- }
4832- b, truncated, err := readBrowseOutput(g.browseCommand(ctx, r, args...), previewBytes)
4833- if err != nil {
4834- return err
4835- }
4836- if truncated {
4837- return errors.New("commit metadata exceeds preview capacity")
4838- }
4839- fields := strings.Split(string(b), "\x00")
4840- for i := 0; i+4 < len(fields); i += 5 {
4841- if len(page.Commits) >= q.Limit {
4842- page.NextOffset = q.Offset + q.Limit
4843- break
4844- }
4845- stamp, err := strconv.ParseInt(fields[i+3], 10, 64)
4846- if err != nil {
4847- return err
4848- }
4849- page.Commits = append(page.Commits, BrowseCommit{OID: strings.TrimSpace(fields[i]), Parents: strings.Fields(fields[i+1]), Author: fields[i+2], Time: stamp, Subject: fields[i+4]})
4850- }
4851- return nil
4852-}
4853-
4854-func (g *GitRelay) browseDiff(ctx context.Context, r Repository, page *BrowsePage) error {
4855- if err := g.browseHistory(ctx, r, BrowseRequest{Limit: 1}, page); err != nil {
4856- return err
4857- }
4858- b, truncated, err := readBrowseOutput(g.browseCommand(ctx, r, "show", "--format=", "--no-ext-diff", "--no-textconv", "--no-renames", page.Commit, "--"), previewBytes)
4859- page.Diff, page.Truncated = strings.ToValidUTF8(string(b), "�"), truncated
4860- return err
4861-}
4862-
4863-// WriteSource streams a published source blob directly to the destination.
4864-func (g *GitRelay) WriteSource(ctx context.Context, q BrowseRequest, dst io.Writer) error {
4865- r, err := g.BrowseRepository(q.Owner, q.Repo)
4866- if err != nil {
4867- return err
4868- }
4869- r.Refs, err = g.browseRefs(ctx, r)
4870- if err != nil {
4871- return err
4872- }
4873- commit, err := g.browseCommit(ctx, r, q.Ref)
4874- if err != nil {
4875- return err
4876- }
4877- oid, err := g.fileObject(ctx, r, commit, q.Path)
4878- if err != nil {
4879- return err
4880- }
4881- cmd := g.browseCommand(ctx, r, "cat-file", "blob", oid)
4882- cmd.Stdout = dst
4883- if err := cmd.Run(); err != nil {
4884- return fmt.Errorf("stream source: %w", err)
4885- }
4886- return nil
4887-}
4888diff --git a/internal/gitrelay/browse_test.go b/internal/gitrelay/browse_test.go
4889deleted file mode 100644
4890index 7ca8753..0000000
4891--- a/internal/gitrelay/browse_test.go
4892+++ /dev/null
4893@@ -1,190 +0,0 @@
4894-package gitrelay
4895-
4896-import (
4897- "bytes"
4898- "context"
4899- "os"
4900- "os/exec"
4901- "path/filepath"
4902- "strings"
4903- "testing"
4904-)
4905-
4906-func browseFixture(t *testing.T) (*GitRelay, Repository, string) {
4907- t.Helper()
4908- root := t.TempDir()
4909- r := Repository{Owner: strings.Repeat("a", 64), Identifier: "browse", Head: "refs/heads/main"}
4910- g := &GitRelay{root: root, repos: map[string]Repository{}}
4911- dir := g.repoPath(r)
4912- if err := os.MkdirAll(dir, 0700); err != nil {
4913- t.Fatal(err)
4914- }
4915- run := func(args ...string) string {
4916- t.Helper()
4917- c := exec.Command("git", append([]string{"-C", dir}, args...)...)
4918- c.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Browser Test", "GIT_AUTHOR_EMAIL=test@example.test", "GIT_COMMITTER_NAME=Browser Test", "GIT_COMMITTER_EMAIL=test@example.test")
4919- b, err := c.CombinedOutput()
4920- if err != nil {
4921- t.Fatalf("git %v: %v %s", args, err, b)
4922- }
4923- return strings.TrimSpace(string(b))
4924- }
4925- run("init", "-b", "main")
4926- for name, body := range map[string]string{"source.go": "package main\n// <script>alert(1)</script>\n", "[literal]*.txt": "literal path", "large.txt": strings.Repeat("x", 300*1024), "binary.bin": "a\x00b"} {
4927- if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0600); err != nil {
4928- t.Fatal(err)
4929- }
4930- }
4931- run("add", ".")
4932- run("commit", "-m", "first source")
4933- first := run("rev-parse", "HEAD")
4934- if err := os.WriteFile(filepath.Join(dir, "source.go"), []byte("package main\n// second\n"), 0600); err != nil {
4935- t.Fatal(err)
4936- }
4937- run("commit", "-am", "second source")
4938- r.Refs = map[string]string{"refs/heads/main": run("rev-parse", "HEAD")}
4939- g.repos[key(r.Owner, r.Identifier)] = r
4940- // Browsing normally uses a bare repository; this fixture switches --git-dir to .git.
4941- g.root = filepath.Join(root, "bare")
4942- if err := os.MkdirAll(filepath.Dir(g.repoPath(r)), 0700); err != nil {
4943- t.Fatal(err)
4944- }
4945- cmd := exec.Command("git", "clone", "--bare", dir, g.repoPath(r))
4946- if b, err := cmd.CombinedOutput(); err != nil {
4947- t.Fatalf("bare clone: %v %s", err, b)
4948- }
4949- return g, r, first
4950-}
4951-
4952-func TestBrowseRepositorySourceAndHistory(t *testing.T) {
4953- g, r, first := browseFixture(t)
4954- ctx := context.Background()
4955- q := BrowseRequest{Owner: r.Owner, Repo: r.Identifier}
4956- page, err := g.Browse(ctx, q)
4957- if err != nil || len(page.Entries) != 4 {
4958- t.Fatalf("tree: %+v %v", page, err)
4959- }
4960- q.View, q.Path, q.Ref = "file", "[literal]*.txt", first
4961- page, err = g.Browse(ctx, q)
4962- if err != nil || page.Content != "literal path" {
4963- t.Fatalf("literal: %+v %v", page, err)
4964- }
4965- q.Path = "source.go"
4966- page, err = g.Browse(ctx, q)
4967- if err != nil || !strings.Contains(page.Content, "<script>") {
4968- t.Fatalf("historical source: %+v %v", page, err)
4969- }
4970- q.View, q.Ref, q.Path, q.Limit = "history", "main", "", 1
4971- page, err = g.Browse(ctx, q)
4972- if err != nil || len(page.Commits) != 1 || page.NextOffset != 1 {
4973- t.Fatalf("history: %+v %v", page, err)
4974- }
4975- q.View = "commit"
4976- page, err = g.Browse(ctx, q)
4977- if err != nil || !strings.Contains(page.Diff, "+// second") {
4978- t.Fatalf("diff: %+v %v", page, err)
4979- }
4980-}
4981-
4982-func TestFetchMissingUsesCompleteLocalObjects(t *testing.T) {
4983- g, repo, _ := browseFixture(t)
4984- expected := map[string]string{"refs/heads/repaired": repo.Refs["refs/heads/main"]}
4985- if err := g.FetchMissing(context.Background(), repo, nil, expected); err != nil {
4986- t.Fatalf("complete local inventory should not need a network source: %v", err)
4987- }
4988- native, err := g.nativeRefs(repo)
4989- if err != nil || native["refs/heads/repaired"] != expected["refs/heads/repaired"] {
4990- t.Fatalf("locally available objects were not projected to expected refs: %v %v", native, err)
4991- }
4992-}
4993-
4994-func TestBrowseRejectsUnsafeOrUnpublishedObjects(t *testing.T) {
4995- g, r, _ := browseFixture(t)
4996- cmd := exec.Command("git", "--git-dir", g.repoPath(r), "hash-object", "-w", "--stdin")
4997- cmd.Stdin = strings.NewReader("unpublished secret")
4998- id, err := cmd.Output()
4999- if err != nil {
5000- t.Fatal(err)
5001- }
5002- for _, q := range []BrowseRequest{
5003- {Owner: r.Owner, Repo: r.Identifier, Ref: "--output=/tmp/nope"},
5004- {Owner: r.Owner, Repo: r.Identifier, Ref: strings.TrimSpace(string(id))},
5005- {Owner: r.Owner, Repo: r.Identifier, Path: "../config"},
5006- {Owner: r.Owner, Repo: r.Identifier, Path: "/etc/passwd"},
5007- } {
5008- if _, err := g.Browse(context.Background(), q); err == nil {
5009- t.Fatalf("accepted unsafe request: %+v", q)
5010- }
5011- }
5012-}
5013-
5014-func TestBrowseFilePreviewIsBoundedAndBinaryAware(t *testing.T) {
5015- g, r, _ := browseFixture(t)
5016- for _, name := range []string{"large.txt", "binary.bin"} {
5017- page, err := g.Browse(context.Background(), BrowseRequest{Owner: r.Owner, Repo: r.Identifier, View: "file", Path: name})
5018- if err != nil {
5019- t.Fatal(err)
5020- }
5021- if name == "large.txt" && (!page.Truncated || len(page.Content) > 256*1024) {
5022- t.Fatal("unbounded preview")
5023- }
5024- if name == "binary.bin" && (!page.Binary || page.Content != "") {
5025- t.Fatal("binary shown as source")
5026- }
5027- }
5028-}
5029-
5030-func TestBrowseHiddenCommitAndFullBinaryDownload(t *testing.T) {
5031- g, r, _ := browseFixture(t)
5032- cmd := g.browseCommand(context.Background(), r, "rev-parse", "HEAD^{tree}")
5033- tree, err := cmd.Output()
5034- if err != nil {
5035- t.Fatal(err)
5036- }
5037- cmd = g.browseCommand(context.Background(), r, "commit-tree", strings.TrimSpace(string(tree)))
5038- cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.test", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.test")
5039- cmd.Stdin = strings.NewReader("unpublished commit")
5040- hidden, err := cmd.Output()
5041- if err != nil {
5042- t.Fatal(err)
5043- }
5044- oid := strings.TrimSpace(string(hidden))
5045- if err := g.browseCommand(context.Background(), r, "update-ref", "refs/tinyrelay/pending/secret", oid).Run(); err != nil {
5046- t.Fatal(err)
5047- }
5048- if _, err := g.Browse(context.Background(), BrowseRequest{Owner: r.Owner, Repo: r.Identifier, Ref: oid}); err == nil {
5049- t.Fatal("unpublished commit exposed")
5050- }
5051- var result bytes.Buffer
5052- if err := g.WriteSource(context.Background(), BrowseRequest{Owner: r.Owner, Repo: r.Identifier, Path: "large.txt"}, &result); err != nil {
5053- t.Fatal(err)
5054- }
5055- if result.Len() != 300*1024 {
5056- t.Fatalf("download truncated: %d", result.Len())
5057- }
5058-}
5059-
5060-func TestBrowseTreePaginationHasNoMissingEntries(t *testing.T) {
5061- g, r, _ := browseFixture(t)
5062- names := map[string]bool{}
5063- q := BrowseRequest{Owner: r.Owner, Repo: r.Identifier, Limit: 1}
5064- for {
5065- page, err := g.Browse(context.Background(), q)
5066- if err != nil {
5067- t.Fatal(err)
5068- }
5069- for _, entry := range page.Entries {
5070- if names[entry.Name] {
5071- t.Fatal("duplicate entry")
5072- }
5073- names[entry.Name] = true
5074- }
5075- if page.NextOffset < 0 {
5076- break
5077- }
5078- q.Offset = page.NextOffset
5079- }
5080- if len(names) != 4 {
5081- t.Fatalf("missing entries: %v", names)
5082- }
5083-}
5084diff --git a/internal/gitrelay/capabilities_test.go b/internal/gitrelay/capabilities_test.go
5085deleted file mode 100644
5086index e4a33b6..0000000
5087--- a/internal/gitrelay/capabilities_test.go
5088+++ /dev/null
5089@@ -1,34 +0,0 @@
5090-package gitrelay
5091-
5092-import (
5093- "context"
5094- "reflect"
5095- "testing"
5096-
5097- "github.com/FelineStateMachine/tinyrelay/internal/policy"
5098-)
5099-
5100-func TestCapabilitiesRequireEventSyncTransport(t *testing.T) {
5101- p := policy.Policy{Features: policy.Features{Grasp: true, Grasp02: true, Grasp03: true, Grasp06: true}}
5102- g := &GitRelay{policy: func() policy.Policy { return p }}
5103- if got := g.Capabilities(); !reflect.DeepEqual(got, []string{"GRASP-01", "GRASP-06"}) {
5104- t.Fatalf("without event transport: %v", got)
5105- }
5106- g.eventSync = func(context.Context, Repository) error { return nil }
5107- if got := g.Capabilities(); !reflect.DeepEqual(got, []string{"GRASP-01", "GRASP-02", "GRASP-03", "GRASP-06"}) {
5108- t.Fatalf("with event transport: %v", got)
5109- }
5110- p.Features.Grasp02 = false
5111- if got := g.Capabilities(); !reflect.DeepEqual(got, []string{"GRASP-01", "GRASP-06"}) {
5112- t.Fatalf("without base sync: %v", got)
5113- }
5114-}
5115-
5116-func TestCapabilitiesPreservePrivateProfiles(t *testing.T) {
5117- p := policy.Policy{Reads: "members", Features: policy.Features{Grasp: true, Grasp02: true, Grasp03: true, Grasp06: true, Grasp08: true}}
5118- g := &GitRelay{policy: func() policy.Policy { return p }, eventSync: func(context.Context, Repository) error { return nil }}
5119- want := []string{"GRASP-01", "GRASP-02", "GRASP-03", "GRASP-06", "GRASP-08"}
5120- if got := g.SupportedGRASPs(); !reflect.DeepEqual(got, want) {
5121- t.Fatalf("private profiles: %v", got)
5122- }
5123-}
5124diff --git a/internal/gitrelay/cgi_test.go b/internal/gitrelay/cgi_test.go
5125deleted file mode 100644
5126index 86e567c..0000000
5127--- a/internal/gitrelay/cgi_test.go
5128+++ /dev/null
5129@@ -1,116 +0,0 @@
5130-package gitrelay
5131-
5132-import (
5133- "bytes"
5134- "compress/gzip"
5135- "context"
5136- "errors"
5137- "io"
5138- "net/http"
5139- "net/http/httptest"
5140- "os"
5141- "path/filepath"
5142- "strconv"
5143- "strings"
5144- "testing"
5145- "time"
5146-)
5147-
5148-func TestCGIFailureAfterHeadersPreservesPartialPack(t *testing.T) {
5149- dir := t.TempDir()
5150- script := "#!/bin/sh\nprintf 'Content-Type: application/octet-stream\\r\\n\\r\\npartial-pack'\nprintf 'backend failed' >&2\nexit 1\n"
5151- if err := os.WriteFile(filepath.Join(dir, "git"), []byte(script), 0700); err != nil {
5152- t.Fatal(err)
5153- }
5154- t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
5155- g := &GitRelay{root: dir}
5156- w := httptest.NewRecorder()
5157- err := g.cgi(httptest.NewRequest(http.MethodGet, "/", nil), w, Repository{Owner: "owner", Identifier: "test"}, "/git-upload-pack")
5158- var streamed *cgiStreamError
5159- if !errors.As(err, &streamed) || !strings.Contains(err.Error(), "backend failed") {
5160- t.Fatalf("backend failure did not retain committed-response state: %v", err)
5161- }
5162- if got := w.Body.String(); got != "partial-pack" {
5163- t.Fatalf("error corrupted pack response: %q", got)
5164- }
5165-}
5166-
5167-func TestCGIGzipInputDecodesAndRemovesSpool(t *testing.T) {
5168- want := strings.Repeat("object negotiation\n", 10000)
5169- var compressed bytes.Buffer
5170- zw := gzip.NewWriter(&compressed)
5171- if _, err := io.WriteString(zw, want); err != nil {
5172- t.Fatal(err)
5173- }
5174- if err := zw.Close(); err != nil {
5175- t.Fatal(err)
5176- }
5177- req := httptest.NewRequest(http.MethodPost, "/", &compressed)
5178- req.Header.Set("Content-Encoding", "gzip")
5179- body, length, cleanup, err := cgiInput(req)
5180- if err != nil {
5181- t.Fatal(err)
5182- }
5183- defer cleanup()
5184- got, err := io.ReadAll(body)
5185- if err != nil || string(got) != want || length != strconv.Itoa(len(want)) {
5186- t.Fatalf("decoded request: length=%s bytes=%d err=%v", length, len(got), err)
5187- }
5188- file, ok := body.(*os.File)
5189- if !ok {
5190- t.Fatal("compressed request did not use a disk spool")
5191- }
5192- cleanup()
5193- if _, err := os.Stat(file.Name()); !errors.Is(err, os.ErrNotExist) {
5194- t.Fatalf("temporary request remains after cleanup: %v", err)
5195- }
5196-}
5197-
5198-// A backend that waits for more input after its first output must not require
5199-// the whole pack to fit in memory before the client can receive any of it.
5200-func TestCGIStreamsBeforeBackendExits(t *testing.T) {
5201- dir := t.TempDir()
5202- script := "#!/bin/sh\nprintf 'Content-Type: application/octet-stream\\r\\n\\r\\nfirst'\nread release\nprintf 'last'\n"
5203- if err := os.WriteFile(filepath.Join(dir, "git"), []byte(script), 0700); err != nil {
5204- t.Fatal(err)
5205- }
5206- t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
5207- input, release := io.Pipe()
5208- defer input.Close()
5209- defer release.Close()
5210- g := &GitRelay{root: dir}
5211- finished := make(chan error, 1)
5212- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
5213- r.Body = input
5214- finished <- g.cgi(r, w, Repository{Owner: "owner", Identifier: "test"}, "/git-upload-pack")
5215- }))
5216- defer server.Close()
5217- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
5218- defer cancel()
5219- req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
5220- if err != nil {
5221- t.Fatal(err)
5222- }
5223- response, err := server.Client().Do(req)
5224- if err != nil {
5225- _ = release.Close()
5226- t.Fatalf("backend output was not streamed: %v", err)
5227- }
5228- defer response.Body.Close()
5229- first := make([]byte, 5)
5230- if _, err := io.ReadFull(response.Body, first); err != nil || string(first) != "first" {
5231- _ = release.Close()
5232- t.Fatalf("first backend chunk = %q, %v", first, err)
5233- }
5234- if _, err := io.WriteString(release, "continue\n"); err != nil {
5235- t.Fatal(err)
5236- }
5237- _ = release.Close()
5238- rest, err := io.ReadAll(response.Body)
5239- if err != nil || string(rest) != "last" {
5240- t.Fatalf("remaining backend output = %q, %v", rest, err)
5241- }
5242- if err := <-finished; err != nil {
5243- t.Fatal(err)
5244- }
5245-}
5246diff --git a/internal/gitrelay/collaboration_diff.go b/internal/gitrelay/collaboration_diff.go
5247deleted file mode 100644
5248index 1fdf8df..0000000
5249--- a/internal/gitrelay/collaboration_diff.go
5250+++ /dev/null
5251@@ -1,118 +0,0 @@
5252-package gitrelay
5253-
5254-import (
5255- "context"
5256- "errors"
5257- "fmt"
5258- "strings"
5259- "time"
5260-)
5261-
5262-const collaborationDiffBytes = 512 * 1024
5263-const collaborationDiffTimeout = 5 * time.Second
5264-
5265-// CollaborationDiff is the result of rendering a pull request's local Git
5266-// objects. Status is "ok" when Diff is authoritative and
5267-// "diff_unavailable" when one or both commits are not present locally.
5268-type CollaborationDiff struct {
5269- Status string `json:"status"`
5270- Base string `json:"base,omitempty"`
5271- Tip string `json:"tip,omitempty"`
5272- MergeBase string `json:"merge_base,omitempty"`
5273- Diff string `json:"diff,omitempty"`
5274- Truncated bool `json:"truncated,omitempty"`
5275- Reason string `json:"reason,omitempty"`
5276-}
5277-
5278-// PRDiff renders a bounded diff from commits already present in the
5279-// repository. It never fetches or resolves a remote ref. A supplied base is
5280-// used only after it is verified as a local commit; when it is empty, Git's
5281-// merge-base with the repository target is used.
5282-func (g *GitRelay) PRDiff(ctx context.Context, r Repository, base, tip string) CollaborationDiff {
5283- ctx, cancel := context.WithTimeout(ctx, collaborationDiffTimeout)
5284- defer cancel()
5285- result := CollaborationDiff{Status: "diff_unavailable", Base: base, Tip: tip}
5286- if !isObjectID(base) && base != "" {
5287- result.Reason = "invalid base commit"
5288- return result
5289- }
5290- if !isObjectID(tip) {
5291- result.Reason = "invalid tip commit"
5292- return result
5293- }
5294- if err := g.localCommit(ctx, r, tip); err != nil {
5295- result.Reason = "tip commit is unavailable locally"
5296- return result
5297- }
5298- if base != "" {
5299- if err := g.localCommit(ctx, r, base); err != nil {
5300- result.Reason = "base commit is unavailable locally"
5301- return result
5302- }
5303- }
5304- target, err := g.targetCommit(ctx, r)
5305- if err != nil {
5306- result.Reason = "repository target commit is unavailable locally"
5307- return result
5308- }
5309- if base != "" {
5310- // A PR base is meaningful only when it is reachable from both sides of
5311- // the comparison. Checking the current target as well prevents an old
5312- // or malicious PR-only ancestor from being presented as the repository
5313- // diff base.
5314- if err := g.browseCommand(ctx, r, "merge-base", "--is-ancestor", base, tip).Run(); err != nil {
5315- result.Reason = "base is not an ancestor of tip"
5316- return result
5317- }
5318- if err := g.browseCommand(ctx, r, "merge-base", "--is-ancestor", base, target).Run(); err != nil {
5319- result.Reason = "base is not an ancestor of repository target"
5320- return result
5321- }
5322- result.MergeBase = base
5323- } else {
5324- b, err := g.browseCommand(ctx, r, "merge-base", "--", tip, target).Output()
5325- if err != nil || !isObjectID(strings.TrimSpace(string(b))) {
5326- result.Reason = "merge base is unavailable locally"
5327- return result
5328- }
5329- result.MergeBase = strings.TrimSpace(string(b))
5330- result.Base = result.MergeBase
5331- }
5332-
5333- b, truncated, err := readBrowseOutput(g.browseCommand(ctx, r, "diff", "--format=", "--no-ext-diff", "--no-textconv", "--no-renames", "--binary", result.MergeBase, tip, "--"), collaborationDiffBytes)
5334- if err != nil {
5335- result.Reason = "unable to render local diff"
5336- return result
5337- }
5338- result.Status = "ok"
5339- result.Diff = strings.ToValidUTF8(string(b), "�")
5340- result.Truncated = truncated
5341- return result
5342-}
5343-
5344-func (g *GitRelay) targetCommit(ctx context.Context, r Repository) (string, error) {
5345- target := strings.TrimPrefix(r.Head, "ref: ")
5346- if target == "" || !validBrowsePath(target) {
5347- return "", errors.New("missing repository target")
5348- }
5349- b, err := g.browseCommand(ctx, r, "rev-parse", "--verify", "--end-of-options", target+"^{commit}").Output()
5350- if err != nil {
5351- return "", err
5352- }
5353- target = strings.TrimSpace(string(b))
5354- if !isObjectID(target) || g.localCommit(ctx, r, target) != nil {
5355- return "", errors.New("target commit unavailable")
5356- }
5357- return target, nil
5358-}
5359-
5360-func (g *GitRelay) localCommit(ctx context.Context, r Repository, oid string) error {
5361- if !isObjectID(oid) {
5362- return errors.New("invalid object id")
5363- }
5364- b, err := g.browseCommand(ctx, r, "cat-file", "-t", oid).Output()
5365- if err != nil || strings.TrimSpace(string(b)) != "commit" {
5366- return fmt.Errorf("commit unavailable: %s", oid)
5367- }
5368- return nil
5369-}
5370diff --git a/internal/gitrelay/collaboration_diff_test.go b/internal/gitrelay/collaboration_diff_test.go
5371deleted file mode 100644
5372index b08a4cf..0000000
5373--- a/internal/gitrelay/collaboration_diff_test.go
5374+++ /dev/null
5375@@ -1,112 +0,0 @@
5376-package gitrelay
5377-
5378-import (
5379- "context"
5380- "os"
5381- "os/exec"
5382- "strings"
5383- "testing"
5384-)
5385-
5386-func TestPRDiffUsesLocalCommitsAndReportsMetadata(t *testing.T) {
5387- g, r, base := browseFixture(t)
5388- tip := r.Refs["refs/heads/main"]
5389- got := g.PRDiff(context.Background(), r, base, tip)
5390- if got.Status != "ok" || got.Base != base || got.Tip != tip || got.MergeBase != base {
5391- t.Fatalf("result = %+v", got)
5392- }
5393- if !strings.Contains(got.Diff, "+// second") || got.Truncated {
5394- t.Fatalf("diff = %+v", got)
5395- }
5396-}
5397-
5398-func TestPRDiffDoesNotTurnMissingOrUnrelatedObjectsIntoEmptyDiff(t *testing.T) {
5399- g, r, base := browseFixture(t)
5400- tip := r.Refs["refs/heads/main"]
5401- missing := strings.Repeat("f", 40)
5402- if got := g.PRDiff(context.Background(), r, base, missing); got.Status != "diff_unavailable" || got.Reason == "" {
5403- t.Fatalf("missing tip result = %+v", got)
5404- }
5405- if got := g.PRDiff(context.Background(), r, missing, r.Refs["refs/heads/main"]); got.Status != "diff_unavailable" || got.Reason == "" {
5406- t.Fatalf("missing base result = %+v", got)
5407- }
5408- // The first commit in the fixture is not related to an independently
5409- // created root. The backend must disclose that it cannot establish a PR
5410- // base instead of presenting a whole-tree comparison as a normal diff.
5411- tree, err := exec.Command("git", "--git-dir", g.repoPath(r), "mktree").Output()
5412- if err != nil {
5413- t.Fatal(err)
5414- }
5415- cmd := exec.Command("git", "--git-dir", g.repoPath(r), "commit-tree", strings.TrimSpace(string(tree)), "-m", "unrelated")
5416- cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.test", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.test")
5417- otherBytes, err := cmd.Output()
5418- if err != nil {
5419- t.Fatal(err)
5420- }
5421- other := strings.TrimSpace(string(otherBytes))
5422- if got := g.PRDiff(context.Background(), r, other, base); got.Status != "diff_unavailable" {
5423- t.Fatalf("unrelated result = %+v", got)
5424- }
5425- // This base is an ancestor of the PR tip but belongs only to that PR;
5426- // it is not an ancestor of the repository target.
5427- treeBytes, err := exec.Command("git", "--git-dir", g.repoPath(r), "rev-parse", base+"^{tree}").Output()
5428- if err != nil {
5429- t.Fatal(err)
5430- }
5431- commit := func(parents ...string) string {
5432- args := []string{"--git-dir", g.repoPath(r), "commit-tree", strings.TrimSpace(string(treeBytes))}
5433- for _, parent := range parents {
5434- args = append(args, "-p", parent)
5435- }
5436- args = append(args, "-m", "PR-only")
5437- cmd := exec.Command("git", args...)
5438- cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.test", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.test")
5439- out, err := cmd.Output()
5440- if err != nil {
5441- t.Fatal(err)
5442- }
5443- return strings.TrimSpace(string(out))
5444- }
5445- prBase := commit(base)
5446- prTip := commit(prBase)
5447- if got := g.PRDiff(context.Background(), r, prBase, prTip); got.Status != "diff_unavailable" {
5448- t.Fatalf("PR-only ancestor result = %+v (main tip %s)", got, tip)
5449- }
5450-}
5451-
5452-func TestPRDiffAcceptsBaseWhenTargetBranchDiverged(t *testing.T) {
5453- g, r, base := browseFixture(t)
5454- tip := r.Refs["refs/heads/main"]
5455- treeBytes, err := exec.Command("git", "--git-dir", g.repoPath(r), "rev-parse", base+"^{tree}").Output()
5456- if err != nil {
5457- t.Fatal(err)
5458- }
5459- commit := func(message string) string {
5460- cmd := exec.Command("git", "--git-dir", g.repoPath(r), "commit-tree", strings.TrimSpace(string(treeBytes)), "-p", base, "-m", message)
5461- cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.test", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.test")
5462- out, err := cmd.Output()
5463- if err != nil {
5464- t.Fatal(err)
5465- }
5466- return strings.TrimSpace(string(out))
5467- }
5468- target := commit("target branch")
5469- if err := exec.Command("git", "--git-dir", g.repoPath(r), "update-ref", "refs/heads/target", target).Run(); err != nil {
5470- t.Fatal(err)
5471- }
5472- r.Head = "refs/heads/target"
5473- got := g.PRDiff(context.Background(), r, base, tip)
5474- if got.Status != "ok" || got.MergeBase != base {
5475- t.Fatalf("diverged target result = %+v", got)
5476- }
5477-}
5478-
5479-func TestPRDiffFindsMergeBaseFromRepositoryHead(t *testing.T) {
5480- g, r, base := browseFixture(t)
5481- tip := r.Refs["refs/heads/main"]
5482- r.Head = "refs/heads/main"
5483- got := g.PRDiff(context.Background(), r, "", tip)
5484- if got.Status != "ok" || got.MergeBase != tip || got.Base != tip {
5485- t.Fatalf("result = %+v (base fixture commit %s)", got, base)
5486- }
5487-}
5488diff --git a/internal/gitrelay/doc.go b/internal/gitrelay/doc.go
5489deleted file mode 100644
5490index f16166c..0000000
5491--- a/internal/gitrelay/doc.go
5492+++ /dev/null
5493@@ -1,24 +0,0 @@
5494-// Package gitrelay coordinates signed Nostr repository events with native
5495-// Git object storage and smart HTTP. Signed events establish repository refs,
5496-// metadata and maintainer authority.
5497-//
5498-// GitRelay stores bare repositories under Config.Root. Journal files under
5499-// .tinyrelay/journal make event-to-ref transitions recoverable, while pending
5500-// state files keep a signed ref announcement out of the visible repository
5501-// until every advertised tip object is present. New creates the root,
5502-// recovers journals and reloads repository metadata from the event store.
5503-// Synchronization runs through Service.Tick or transport callbacks invoked by
5504-// the caller.
5505-//
5506-// Validate checks event and maintainer admission. After the caller durably
5507-// stores the event, CommitAfterStore stages the corresponding Git transition.
5508-// CommitAfterStoreNoNotify serves callers already holding the host publication
5509-// fence. PromotePending completes a receive-pack repair and invokes OnPromote
5510-// after state becomes visible.
5511-//
5512-// ServeHTTP exposes Git smart HTTP for public and private repositories.
5513-// Private requests pass through AuthorizeHTTP. GRASP-08 private peer traffic
5514-// is limited to Config.PrivatePeers and is signed by HTTPAuth. GitSync and
5515-// EventSync supply synchronization transports; the daemon supplies the relay
5516-// client and WebSocket lifecycle.
5517-package gitrelay
5518diff --git a/internal/gitrelay/gitrelay.go b/internal/gitrelay/gitrelay.go
5519deleted file mode 100644
5520index e030113..0000000
5521--- a/internal/gitrelay/gitrelay.go
5522+++ /dev/null
5523@@ -1,162 +0,0 @@
5524-package gitrelay
5525-
5526-import (
5527- "context"
5528- "errors"
5529- "fmt"
5530- "net/http"
5531- "os"
5532- "strings"
5533- "sync"
5534-
5535- "github.com/FelineStateMachine/tinyrelay/internal/event"
5536- "github.com/FelineStateMachine/tinyrelay/internal/policy"
5537- "github.com/FelineStateMachine/tinyrelay/internal/storage"
5538-)
5539-
5540-// Repository is the resolved authority and Git state for one hosted project.
5541-type Repository struct {
5542- Owner string
5543- Identifier string
5544- EventID string
5545- Private bool
5546- Clone []string
5547- Relays []string
5548- Maintainers []string
5549- Refs map[string]string
5550- Head string
5551- Alternative bool
5552-}
5553-
5554-// Maintainer is one key that may sign repository state, push refs and change
5555-// status. Role is "owner", "maintainer" for a key in the announcement's
5556-// maintainers tag, or "agent" for a key the host vouches for under an agent
5557-// grant; Name carries the grant's label when it has one.
5558-type Maintainer struct {
5559- PubKey string `json:"pubkey"`
5560- Role string `json:"role"`
5561- Name string `json:"name,omitempty"`
5562-}
5563-
5564-// MaintainerSource answers whether a key maintains a repository beyond the
5565-// owner and the announcement's maintainers tag. The host relay supplies it so
5566-// authority it records elsewhere, such as an agent grant, counts in every
5567-// place this package checks maintainers.
5568-type MaintainerSource interface {
5569- IsMaintainer(ctx context.Context, r Repository, pubkey string) bool
5570-}
5571-
5572-// Config supplies the event store, Git root, authorization and transport hooks.
5573-type Config struct {
5574- Store *storage.Store
5575- Root string
5576- Policy func() policy.Policy
5577- Authorize func(context.Context, event.Event, Repository) error
5578- // Maintainers extends maintainer checks with host-side authority. It is
5579- // consulted only after the owner and the maintainers tag.
5580- Maintainers MaintainerSource
5581- // AuthorizeHTTP i
Diff preview truncated (256 KiB or 10,000 lines).

This diff is truncated. Clone the repository to review the complete change.

Conversation

No replies yet.

Sign in to reply.

About

ownerc01503e8035f
visibilitypublic

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs