| 1 | diff --git a/AGENTS.md b/AGENTS.md |
| 2 | index 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. |
| 11 | diff --git a/Dockerfile b/Dockerfile |
| 12 | index 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"] |
| 24 | diff --git a/Makefile b/Makefile |
| 25 | index 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 | |
| 48 | diff --git a/README.md b/README.md |
| 49 | index 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: |
| 73 | diff --git a/THIRD_PARTY_NOTICES b/THIRD_PARTY_NOTICES |
| 74 | index 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 | ----------------- |
| 103 | diff --git a/cmd/tiny/gittoken.go b/cmd/tiny/gittoken.go |
| 104 | index 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 |
| 117 | diff --git a/cmd/tiny/gittoken_test.go b/cmd/tiny/gittoken_test.go |
| 118 | index 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) { |
| 130 | diff --git a/cmd/tinyclient/main.go b/cmd/tinyclient/main.go |
| 131 | new file mode 100644 |
| 132 | index 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 | +} |
| 211 | diff --git a/cmd/tinyclient/main_test.go b/cmd/tinyclient/main_test.go |
| 212 | new file mode 100644 |
| 213 | index 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 | +} |
| 265 | diff --git a/cmd/tinygit/main.go b/cmd/tinygit/main.go |
| 266 | new file mode 100644 |
| 267 | index 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 | +} |
| 359 | diff --git a/cmd/tinygit/main_integration_test.go b/cmd/tinygit/main_integration_test.go |
| 360 | new file mode 100644 |
| 361 | index 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 | +} |
| 556 | diff --git a/docs/agents.md b/docs/agents.md |
| 557 | index 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 | |
| 580 | diff --git a/docs/architecture.md b/docs/architecture.md |
| 581 | index 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). |
| 628 | diff --git a/docs/extensions/README.md b/docs/extensions/README.md |
| 629 | new file mode 100644 |
| 630 | index 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. |
| 668 | diff --git a/docs/extensions/agent-grant-requests.md b/docs/extensions/agent-grant-requests.md |
| 669 | new file mode 100644 |
| 670 | index 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. |
| 767 | diff --git a/docs/extensions/agent-grants.md b/docs/extensions/agent-grants.md |
| 768 | new file mode 100644 |
| 769 | index 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. |
| 857 | diff --git a/docs/extensions/callbacks.md b/docs/extensions/callbacks.md |
| 858 | new file mode 100644 |
| 859 | index 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. |
| 956 | diff --git a/docs/extensions/custom-view-transforms.md b/docs/extensions/custom-view-transforms.md |
| 957 | new file mode 100644 |
| 958 | index 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. |
| 1084 | diff --git a/docs/extensions/tinyclient-read-adapter.md b/docs/extensions/tinyclient-read-adapter.md |
| 1085 | new file mode 100644 |
| 1086 | index 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). |
| 1173 | diff --git a/docs/fixi.md b/docs/fixi.md |
| 1174 | index 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 | |
| 1185 | diff --git a/docs/module-contracts.md b/docs/module-contracts.md |
| 1186 | new file mode 100644 |
| 1187 | index 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. |
| 1274 | diff --git a/docs/testing.md b/docs/testing.md |
| 1275 | index 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. |
| 1307 | diff --git a/docs/views.md b/docs/views.md |
| 1308 | index 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 | |
| 1354 | diff --git a/internal/auth/auth.go b/internal/auth/auth.go |
| 1355 | index 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) } |
| 1739 | diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go |
| 1740 | deleted file mode 100644 |
| 1741 | index 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 | -} |
| 1993 | diff --git a/internal/auth/compatibility_test.go b/internal/auth/compatibility_test.go |
| 1994 | new file mode 100644 |
| 1995 | index 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 | +} |
| 2039 | diff --git a/internal/auth/doc.go b/internal/auth/doc.go |
| 2040 | index 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 |
| 2054 | diff --git a/internal/auth/grasp08.go b/internal/auth/grasp08.go |
| 2055 | deleted file mode 100644 |
| 2056 | index 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 | -} |
| 2128 | diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go |
| 2129 | index 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 | ) |
| 2141 | diff --git a/internal/daemon/client_browse_http.go b/internal/daemon/client_browse_http.go |
| 2142 | index 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 { |
| 2154 | diff --git a/internal/daemon/client_files_test.go b/internal/daemon/client_files_test.go |
| 2155 | index 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 | ) |
| 2167 | diff --git a/internal/daemon/collaboration_approvals.go b/internal/daemon/collaboration_approvals.go |
| 2168 | index 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 | |
| 2180 | diff --git a/internal/daemon/collaboration_browse.go b/internal/daemon/collaboration_browse.go |
| 2181 | index 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 | ) |
| 2193 | diff --git a/internal/daemon/custom_views.go b/internal/daemon/custom_views.go |
| 2194 | index 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" |
| 2206 | diff --git a/internal/daemon/custom_views_transform.go b/internal/daemon/custom_views_transform.go |
| 2207 | index 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" |
| 2219 | diff --git a/internal/daemon/event_commit.go b/internal/daemon/event_commit.go |
| 2220 | index 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" |
| 2232 | diff --git a/internal/daemon/git_agent_maintainer_test.go b/internal/daemon/git_agent_maintainer_test.go |
| 2233 | index 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" |
| 2245 | diff --git a/internal/daemon/git_live.go b/internal/daemon/git_live.go |
| 2246 | index 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 | ) |
| 2258 | diff --git a/internal/daemon/git_live_test.go b/internal/daemon/git_live_test.go |
| 2259 | index 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) { |
| 2271 | diff --git a/internal/daemon/git_outbox_sync.go b/internal/daemon/git_outbox_sync.go |
| 2272 | index 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 | ) |
| 2284 | diff --git a/internal/daemon/git_payload_test.go b/internal/daemon/git_payload_test.go |
| 2285 | index 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{} |
| 2297 | diff --git a/internal/daemon/git_services.go b/internal/daemon/git_services.go |
| 2298 | index 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" |
| 2310 | diff --git a/internal/daemon/git_services_test.go b/internal/daemon/git_services_test.go |
| 2311 | index 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) { |
| 2323 | diff --git a/internal/daemon/git_sync_acceptance_test.go b/internal/daemon/git_sync_acceptance_test.go |
| 2324 | index 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" |
| 2336 | diff --git a/internal/daemon/git_sync_cursor_test.go b/internal/daemon/git_sync_cursor_test.go |
| 2337 | index 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 | ) |
| 2349 | diff --git a/internal/daemon/git_sync_integration_test.go b/internal/daemon/git_sync_integration_test.go |
| 2350 | index 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 | ) |
| 2362 | diff --git a/internal/daemon/landing.go b/internal/daemon/landing.go |
| 2363 | index 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 |
| 2379 | diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go |
| 2380 | index 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 |
| 2392 | diff --git a/internal/daemon/private_peer.go b/internal/daemon/private_peer.go |
| 2393 | index 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) { |
| 2406 | diff --git a/internal/daemon/private_peer_acceptance_test.go b/internal/daemon/private_peer_acceptance_test.go |
| 2407 | index 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 | ) |
| 2419 | diff --git a/internal/daemon/replication_services.go b/internal/daemon/replication_services.go |
| 2420 | index 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" |
| 2432 | diff --git a/internal/daemon/repository_maintainers.go b/internal/daemon/repository_maintainers.go |
| 2433 | index 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 |
| 2445 | diff --git a/internal/daemon/room_attachments.go b/internal/daemon/room_attachments.go |
| 2446 | index 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 |
| 2460 | diff --git a/internal/daemon/rooms_browse.go b/internal/daemon/rooms_browse.go |
| 2461 | index 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 ( |
| 2473 | diff --git a/internal/daemon/services.go b/internal/daemon/services.go |
| 2474 | index 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 |
| 2502 | diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go |
| 2503 | index 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 { |
| 2532 | diff --git a/internal/daemon/tinyclient_integration_test.go b/internal/daemon/tinyclient_integration_test.go |
| 2533 | new file mode 100644 |
| 2534 | index 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 | +} |
| 2647 | diff --git a/internal/daemon/webui_rooms.go b/internal/daemon/webui_rooms.go |
| 2648 | index 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) |
| 2660 | diff --git a/internal/event/compatibility_test.go b/internal/event/compatibility_test.go |
| 2661 | new file mode 100644 |
| 2662 | index 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 | +} |
| 2698 | diff --git a/internal/event/doc.go b/internal/event/doc.go |
| 2699 | index 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 |
| 2713 | diff --git a/internal/event/event.go b/internal/event/event.go |
| 2714 | index 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 | -} |
| 3025 | diff --git a/internal/event/event_test.go b/internal/event/event_test.go |
| 3026 | deleted file mode 100644 |
| 3027 | index 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 | -} |
| 3158 | diff --git a/internal/event/filter.go b/internal/event/filter.go |
| 3159 | index 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 | } |
| 3365 | diff --git a/internal/event/sign_empty_tags_test.go b/internal/event/sign_empty_tags_test.go |
| 3366 | deleted file mode 100644 |
| 3367 | index 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 | -} |
| 3395 | diff --git a/internal/gitrelay/admission.go b/internal/gitrelay/admission.go |
| 3396 | deleted file mode 100644 |
| 3397 | index 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 = © |
| 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(¤t); 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 | -} |
| 4075 | diff --git a/internal/gitrelay/archive.go b/internal/gitrelay/archive.go |
| 4076 | deleted file mode 100644 |
| 4077 | index 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 | -} |
| 4160 | diff --git a/internal/gitrelay/archive_test.go b/internal/gitrelay/archive_test.go |
| 4161 | deleted file mode 100644 |
| 4162 | index 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 | -} |
| 4252 | diff --git a/internal/gitrelay/backup_import.go b/internal/gitrelay/backup_import.go |
| 4253 | deleted file mode 100644 |
| 4254 | index 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 | -} |
| 4467 | diff --git a/internal/gitrelay/browse.go b/internal/gitrelay/browse.go |
| 4468 | deleted file mode 100644 |
| 4469 | index 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 | -} |
| 4888 | diff --git a/internal/gitrelay/browse_test.go b/internal/gitrelay/browse_test.go |
| 4889 | deleted file mode 100644 |
| 4890 | index 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 | -} |
| 5084 | diff --git a/internal/gitrelay/capabilities_test.go b/internal/gitrelay/capabilities_test.go |
| 5085 | deleted file mode 100644 |
| 5086 | index 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 | -} |
| 5124 | diff --git a/internal/gitrelay/cgi_test.go b/internal/gitrelay/cgi_test.go |
| 5125 | deleted file mode 100644 |
| 5126 | index 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 | -} |
| 5246 | diff --git a/internal/gitrelay/collaboration_diff.go b/internal/gitrelay/collaboration_diff.go |
| 5247 | deleted file mode 100644 |
| 5248 | index 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 | -} |
| 5370 | diff --git a/internal/gitrelay/collaboration_diff_test.go b/internal/gitrelay/collaboration_diff_test.go |
| 5371 | deleted file mode 100644 |
| 5372 | index 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 | -} |
| 5488 | diff --git a/internal/gitrelay/doc.go b/internal/gitrelay/doc.go |
| 5489 | deleted file mode 100644 |
| 5490 | index 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 |
| 5518 | diff --git a/internal/gitrelay/gitrelay.go b/internal/gitrelay/gitrelay.go |
| 5519 | deleted file mode 100644 |
| 5520 | index 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 |