clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

[verified] Extract standalone tinygit and tinyclient components

Hermes committed 9d68e87
commit
9d68e8778c3448fdc08c95e7b44bb30732692f83
parent
5625d0b5f0230cb9d592d0e13baa9494a168f954
author
Hermes <ba1b5beed5b5b9691bc44d5eaeb7fedc1cf8427b06d6f26a0813e4724a589166@nostr.invalid>
245 files changed, +2737 -204
MAGENTS.md2+-
MDockerfile2+-
MMakefile13+-
MREADME.md14+
MTHIRD_PARTY_NOTICES6+-
Acmd/tinyclient/main.go75+++++++
Acmd/tinyclient/main_test.go48+++++
Acmd/tinygit/main.go88++++++++
Acmd/tinygit/main_integration_test.go191++++++++++++++++++
Mdocs/architecture.md8+-
Adocs/extensions/README.md9+
Adocs/extensions/tinyclient-read-adapter.md83++++++++
Mdocs/fixi.md2+-
Adocs/module-contracts.md81++++++++
Mdocs/testing.md16+-
Minternal/daemon/client_browse.go2+-
Minternal/daemon/client_browse_http.go2+-
Minternal/daemon/client_files_test.go2+-
Minternal/daemon/collaboration_approvals.go2+-
Minternal/daemon/collaboration_browse.go2+-
Minternal/daemon/custom_views.go2+-
Minternal/daemon/custom_views_transform.go2+-
Minternal/daemon/event_commit.go2+-
Minternal/daemon/git_agent_maintainer_test.go2+-
Minternal/daemon/git_live.go2+-
Minternal/daemon/git_live_test.go2+-
Minternal/daemon/git_outbox_sync.go2+-
Minternal/daemon/git_payload_test.go2+-
Minternal/daemon/git_services.go2+-
Minternal/daemon/git_services_test.go2+-
Minternal/daemon/git_sync_acceptance_test.go2+-
Minternal/daemon/git_sync_cursor_test.go2+-
Minternal/daemon/git_sync_integration_test.go2+-
Minternal/daemon/mcp_tools.go2+-
Minternal/daemon/private_peer_acceptance_test.go2+-
Minternal/daemon/replication_services.go2+-
Minternal/daemon/repository_maintainers.go2+-
Minternal/daemon/rooms_browse.go2+-
Minternal/daemon/services.go4+-
Minternal/daemon/tenant.go4+-
Ainternal/daemon/tinyclient_integration_test.go109++++++++++
Minternal/daemon/webui_rooms.go2+-
Mpackage.json2+
Mscripts/agent-grant.test.mjs4+-
Mscripts/approvals.test.mjs4+-
Mscripts/blossom-encryption.test.mjs4+-
Mscripts/blossom-manifests-review.test.mjs4+-
Mscripts/blossom-manifests.test.mjs4+-
Mscripts/blossom-upload.test.mjs4+-
Mscripts/build-nostr-name.mjs2+-
Mscripts/build-signer.mjs2+-
Mscripts/chat-activity.test.mjs2+-
Mscripts/chat-files.test.mjs2+-
Mscripts/chat-presence.test.mjs2+-
Mscripts/chat.test.mjs2+-
Mscripts/collaboration-compose.test.mjs2+-
Mscripts/direct-messages.test.mjs4+-
Mscripts/file-catalog.test.mjs2+-
Mscripts/file-media-preview.test.mjs4+-
Mscripts/file-messages.test.mjs4+-
Mscripts/file-workspace.test.mjs19+-
Mscripts/grant-request.test.mjs2+-
Mscripts/nostr-avatar.test.mjs2+-
Mscripts/nostr-name-entry.mjs2+-
Mscripts/page-link.test.mjs2+-
Mscripts/private-services.test.mjs4+-
Mscripts/profile-form.test.mjs2+-
Mscripts/qa/README.md2+-
Mscripts/rooms.test.mjs4+-
Mscripts/shell-navigation.test.mjs2+-
Mscripts/signer-recovery.test.mjs4+-
Mscripts/signer.test.mjs4+-
Mscripts/social-compose.test.mjs2+-
Mscripts/test-signing.mjs2+-
Mscripts/tiny-namespace.test.mjs2+-
Mscripts/verify-bundles.mjs4+-
Mscripts/view-artifact.test.mjs2+-
Mscripts/webmcp.test.mjs2+-
Mscripts/wiki-compose.test.mjs4+-
Atinyclient/README.md116+++++++++++
Rinternal/webui/agents_test.go → tinyclient/agents_test.go2+-
Rinternal/webui/apple-touch-icon.png → tinyclient/apple-touch-icon.png0
Rinternal/webui/approvals.go → tinyclient/approvals.go2+-
Rinternal/webui/approvals_test.go → tinyclient/approvals_test.go2+-
Rinternal/webui/avatars.go → tinyclient/avatars.go2+-
Rinternal/webui/avatars_test.go → tinyclient/avatars_test.go2+-
Rinternal/webui/badge-96.png → tinyclient/badge-96.png0
Rinternal/webui/blossom-encryption.js → tinyclient/blossom-encryption.js0
Rinternal/webui/blossom-manifests.js → tinyclient/blossom-manifests.js0
Rinternal/webui/blossom-upload.js → tinyclient/blossom-upload.js0
Rinternal/webui/bridge.js → tinyclient/bridge.js0
Rinternal/webui/browse.html → tinyclient/browse.html0
Rinternal/webui/browse_test.go → tinyclient/browse_test.go4+-
Rinternal/webui/chat-activity.go → tinyclient/chat-activity.go2+-
Rinternal/webui/chat-activity.html → tinyclient/chat-activity.html0
Rinternal/webui/chat-activity.js → tinyclient/chat-activity.js0
Rinternal/webui/chat-files.js → tinyclient/chat-files.js0
Rinternal/webui/chat-presence.js → tinyclient/chat-presence.js0
Rinternal/webui/chat.html → tinyclient/chat.html0
Rinternal/webui/chat.js → tinyclient/chat.js0
Rinternal/webui/chat_activity_http_test.go → tinyclient/chat_activity_http_test.go2+-
Rinternal/webui/chat_activity_test.go → tinyclient/chat_activity_test.go2+-
Rinternal/webui/chat_pages.go → tinyclient/chat_pages.go2+-
Rinternal/webui/chat_pages_test.go → tinyclient/chat_pages_test.go2+-
Rinternal/webui/collaboration.go → tinyclient/collaboration.go2+-
Rinternal/webui/collaboration.html → tinyclient/collaboration.html0
Rinternal/webui/collaboration_test.go → tinyclient/collaboration_test.go2+-
Rinternal/webui/components.js → tinyclient/components.js0
Rinternal/webui/connections.go → tinyclient/connections.go2+-
Rinternal/webui/connections_test.go → tinyclient/connections_test.go2+-
Atinyclient/contracts.go26++
Rinternal/webui/custom_views.go → tinyclient/custom_views.go2+-
Rinternal/webui/custom_views_test.go → tinyclient/custom_views_test.go2+-
Rinternal/webui/direct-messages.js → tinyclient/direct-messages.js0
Rinternal/webui/doc.go → tinyclient/doc.go7+-
Rinternal/webui/file-catalog.js → tinyclient/file-catalog.js0
Rinternal/webui/file-messages.js → tinyclient/file-messages.js0
Rinternal/webui/file-workspace.js → tinyclient/file-workspace.js0
Rinternal/webui/files.go → tinyclient/files.go2+-
Rinternal/webui/files_test.go → tinyclient/files_test.go2+-
Rinternal/webui/fixi.js → tinyclient/fixi.js0
Rinternal/webui/grant-request.js → tinyclient/grant-request.js0
Rinternal/webui/group_avatars_test.go → tinyclient/group_avatars_test.go2+-
Rinternal/webui/icon-192.png → tinyclient/icon-192.png0
Rinternal/webui/icon-512.png → tinyclient/icon-512.png0
Rinternal/webui/icon-maskable-512.png → tinyclient/icon-maskable-512.png0
Rinternal/webui/icon-maskable.svg → tinyclient/icon-maskable.svg0
Rinternal/webui/icon-mono.svg → tinyclient/icon-mono.svg0
Rinternal/webui/icon.svg → tinyclient/icon.svg0
Rinternal/webui/job_views.go → tinyclient/job_views.go2+-
Rinternal/webui/manage.html → tinyclient/manage.html0
Rinternal/webui/manifest.webmanifest → tinyclient/manifest.webmanifest0
Rinternal/webui/markdown.go → tinyclient/markdown.go2+-
Rinternal/webui/markdown_test.go → tinyclient/markdown_test.go2+-
Rinternal/webui/mobile_header.go → tinyclient/mobile_header.go2+-
Rinternal/webui/mobile_header_test.go → tinyclient/mobile_header_test.go2+-
Rinternal/webui/nostr-name.js → tinyclient/nostr-name.js0
Rinternal/webui/nostr-name.js.license → tinyclient/nostr-name.js.license2+-
Rinternal/webui/open.go → tinyclient/open.go2+-
Rinternal/webui/open_test.go → tinyclient/open_test.go2+-
Rinternal/webui/page.html → tinyclient/page.html0
Rinternal/webui/people_test.go → tinyclient/people_test.go2+-
Rinternal/webui/prefix.go → tinyclient/prefix.go2+-
Rinternal/webui/prefix_test.go → tinyclient/prefix_test.go2+-
Rinternal/webui/private-services.js → tinyclient/private-services.js0
Rinternal/webui/private.go → tinyclient/private.go2+-
Rinternal/webui/profile_test.go → tinyclient/profile_test.go2+-
Rinternal/webui/public.html → tinyclient/public.html0
Rinternal/webui/public_pages.go → tinyclient/public_pages.go9+-
Rinternal/webui/public_pages_test.go → tinyclient/public_pages_test.go2+-
Atinyclient/public_test.go36+++
Rinternal/webui/read_contracts.go → tinyclient/read_contracts.go2+-
Rinternal/webui/read_contracts_test.go → tinyclient/read_contracts_test.go2+-
Atinyclient/remote.go317++++++++++++++++++++++++++++++
Atinyclient/remote_actor_test.go104++++++++++
Atinyclient/remote_auth_test.go72+++++++
Atinyclient/remote_browser_test.go88++++++++
Atinyclient/remote_feed_test.go65++++++
Atinyclient/remote_proxy_test.go60++++++
Atinyclient/remote_rooms.go78+++++++
Atinyclient/remote_rooms_test.go40++++
Atinyclient/remote_scope_test.go86++++++++
Atinyclient/remote_test.go88++++++++
Rinternal/webui/repo.html → tinyclient/repo.html0
Rinternal/webui/repo_navigation.go → tinyclient/repo_navigation.go2+-
Rinternal/webui/repo_navigation_test.go → tinyclient/repo_navigation_test.go2+-
Rinternal/webui/review.go → tinyclient/review.go2+-
Rinternal/webui/review_test.go → tinyclient/review_test.go2+-
Rinternal/webui/room_attachments.go → tinyclient/room_attachments.go2+-
Rinternal/webui/room_attachments_integration_test.go → tinyclient/room_attachments_integration_test.go2+-
Rinternal/webui/room_attachments_test.go → tinyclient/room_attachments_test.go2+-
Rinternal/webui/room_parity.json → tinyclient/room_parity.json0
Rinternal/webui/room_parity_test.go → tinyclient/room_parity_test.go2+-
Rinternal/webui/rooms.go → tinyclient/rooms.go2+-
Rinternal/webui/rooms.html → tinyclient/rooms.html0
Rinternal/webui/rooms.js → tinyclient/rooms.js0
Rinternal/webui/rooms_test.go → tinyclient/rooms_test.go2+-
Rinternal/webui/route_groups.go → tinyclient/route_groups.go2+-
Rinternal/webui/screenshot-narrow.png → tinyclient/screenshot-narrow.png0
Rinternal/webui/screenshot-wide.png → tinyclient/screenshot-wide.png0
Rinternal/webui/shell_test.go → tinyclient/shell_test.go2+-
Rinternal/webui/signer.js → tinyclient/signer.js0
Rinternal/webui/signer.js.license → tinyclient/signer.js.license0
Rinternal/webui/signin.go → tinyclient/signin.go2+-
Rinternal/webui/signin_test.go → tinyclient/signin_test.go2+-
Atinyclient/site_metadata.go53+++++
Atinyclient/site_metadata_test.go37++++
Rinternal/webui/social.html → tinyclient/social.html0
Rinternal/webui/social_content.go → tinyclient/social_content.go2+-
Rinternal/webui/social_content_test.go → tinyclient/social_content_test.go2+-
Rinternal/webui/social_mentions.go → tinyclient/social_mentions.go2+-
Rinternal/webui/social_mentions_test.go → tinyclient/social_mentions_test.go2+-
Rinternal/webui/social_pages.go → tinyclient/social_pages.go2+-
Rinternal/webui/social_pages_test.go → tinyclient/social_pages_test.go2+-
Rinternal/webui/social_reference_routes.go → tinyclient/social_reference_routes.go2+-
Rinternal/webui/source_benchmark_test.go → tinyclient/source_benchmark_test.go2+-
Rinternal/webui/source_render.go → tinyclient/source_render.go2+-
Rinternal/webui/source_render_test.go → tinyclient/source_render_test.go2+-
Rinternal/webui/style.css → tinyclient/style.css0
Rinternal/webui/sw.js → tinyclient/sw.js0
Rinternal/webui/templates.go → tinyclient/templates.go2+-
Rinternal/webui/tiny.js → tinyclient/tiny.js0
Rinternal/webui/webmcp.go → tinyclient/webmcp.go2+-
Rinternal/webui/webmcp.js → tinyclient/webmcp.js0
Rinternal/webui/webmcp_test.go → tinyclient/webmcp_test.go2+-
Rinternal/webui/webui.go → tinyclient/webui.go12+-
Rinternal/webui/webui_test.go → tinyclient/webui_test.go2+-
Rinternal/webui/wiki.go → tinyclient/wiki.go2+-
Rinternal/webui/wiki.html → tinyclient/wiki.html0
Rinternal/webui/wiki_test.go → tinyclient/wiki_test.go2+-
Atinygit/README.md66++++++
Rinternal/gitrelay/admission.go → tinygit/admission.go2+-
Atinygit/api.go36+++
Rinternal/gitrelay/archive.go → tinygit/archive.go2+-
Rinternal/gitrelay/archive_test.go → tinygit/archive_test.go2+-
Rinternal/gitrelay/backup_import.go → tinygit/backup_import.go2+-
Rinternal/gitrelay/browse.go → tinygit/browse.go2+-
Rinternal/gitrelay/browse_test.go → tinygit/browse_test.go2+-
Rinternal/gitrelay/capabilities_test.go → tinygit/capabilities_test.go2+-
Rinternal/gitrelay/cgi_test.go → tinygit/cgi_test.go2+-
Rinternal/gitrelay/collaboration_diff.go → tinygit/collaboration_diff.go2+-
Rinternal/gitrelay/collaboration_diff_test.go → tinygit/collaboration_diff_test.go2+-
Rinternal/gitrelay/doc.go → tinygit/doc.go21++-
Atinygit/external_test.go63++++++
Rinternal/gitrelay/gitrelay.go → tinygit/gitrelay.go8+-
Rinternal/gitrelay/gitrelay_test.go → tinygit/gitrelay_test.go2+-
Rinternal/gitrelay/http.go → tinygit/http.go2+-
Rinternal/gitrelay/journal.go → tinygit/journal.go2+-
Rinternal/gitrelay/maintainers_test.go → tinygit/maintainers_test.go2+-
Rinternal/gitrelay/pr_budget_proxy.go → tinygit/pr_budget_proxy.go2+-
Rinternal/gitrelay/pr_budget_proxy_test.go → tinygit/pr_budget_proxy_test.go2+-
Rinternal/gitrelay/pr_repair.go → tinygit/pr_repair.go2+-
Rinternal/gitrelay/pr_repair_test.go → tinygit/pr_repair_test.go2+-
Rinternal/gitrelay/private_repair_acceptance_test.go → tinygit/private_repair_acceptance_test.go2+-
Rinternal/gitrelay/private_transport.go → tinygit/private_transport.go2+-
Rinternal/gitrelay/private_transport_test.go → tinygit/private_transport_test.go2+-
Atinygit/public_test.go43++++
Rinternal/gitrelay/recovery_refs.go → tinygit/recovery_refs.go2+-
Rinternal/gitrelay/repair.go → tinygit/repair.go2+-
Rinternal/gitrelay/repository.go → tinygit/repository.go2+-
Rinternal/gitrelay/scheduler.go → tinygit/scheduler.go2+-
Atinygit/server.go148++++++++++++++
Atinygit/server_limits_test.go61++++++
Atinygit/server_test.go91+++++++++
Rinternal/gitrelay/storage_inventory.go → tinygit/storage_inventory.go2+-
M AGENTS.md +1 -1
M Dockerfile +1 -1
M Makefile +12 -1
M README.md +14 -0
M THIRD_PARTY_NOTICES +3 -3
A cmd/tinyclient/main.go +75 -0
@@ -0,0 +1,75 @@
1+// Command tinyclient runs the standalone, server-rendered relay website.2+package main3+4+import (5+ "context"6+ "errors"7+ "flag"8+ "fmt"9+ "io"10+ "net"11+ "net/http"12+ "os"13+ "os/signal"14+ "syscall"15+ "time"16+17+ "github.com/FelineStateMachine/tinyrelay/tinyclient"18+)19+20+func configure(args []string, output io.Writer) (*http.Server, error) {21+ flags := flag.NewFlagSet("tinyclient", flag.ContinueOnError)22+ flags.SetOutput(output)23+ listen := flags.String("listen", "127.0.0.1:8081", "HTTP listen address")24+ backend := flags.String("backend", "", "Upstream tinyrelay backend HTTP URL, including /r/<tenant> when used")25+ public := flags.String("public-url", "", "Browser-facing HTTP(S) URL; preserve the relay's public origin and tenant path")26+ if err := flags.Parse(args); err != nil {27+ return nil, err28+ }29+ if flags.NArg() != 0 {30+ return nil, errors.New("tinyclient accepts flags only")31+ }32+ handler, err := tinyclient.NewRemote(tinyclient.RemoteOptions{BackendURL: *backend, PublicURL: *public})33+ if err != nil {34+ return nil, err35+ }36+ return &http.Server{Addr: *listen, Handler: handler, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 90 * time.Second}, nil37+}38+39+func run(ctx context.Context, args []string, output io.Writer) error {40+ server, err := configure(args, output)41+ if err != nil {42+ return err43+ }44+ listener, err := net.Listen("tcp", server.Addr)45+ if err != nil {46+ return err47+ }48+ defer listener.Close()49+ fmt.Fprintf(output, "tinyclient listening on %s\n", listener.Addr())50+ done := make(chan struct{})51+ defer close(done)52+ go func() {53+ select {54+ case <-ctx.Done():55+ shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)56+ defer cancel()57+ _ = server.Shutdown(shutdown)58+ case <-done:59+ }60+ }()61+ err = server.Serve(listener)62+ if errors.Is(err, http.ErrServerClosed) {63+ return nil64+ }65+ return err66+}67+68+func main() {69+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)70+ defer cancel()71+ if err := run(ctx, os.Args[1:], os.Stderr); err != nil && !errors.Is(err, flag.ErrHelp) {72+ fmt.Fprintln(os.Stderr, "tinyclient:", err)73+ os.Exit(1)74+ }75+}
A cmd/tinyclient/main_test.go +48 -0
@@ -0,0 +1,48 @@
1+package main2+3+import (4+ "bytes"5+ "net/http"6+ "net/http/httptest"7+ "strings"8+ "testing"9+10+ "github.com/FelineStateMachine/tinyrelay/tinyclient"11+)12+13+func TestCommandRequiresBackendAndPublicOrigin(t *testing.T) {14+ 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"}} {15+ if _, err := configure(args, &bytes.Buffer{}); err == nil {16+ t.Fatalf("accepted incomplete or invalid configuration: %v", args)17+ }18+ }19+}20+21+func TestCommandServesEmbeddedClient(t *testing.T) {22+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {23+ if r.URL.Path != tinyclient.BackendPath {24+ t.Errorf("unexpected upstream page %s", r.URL.Path)25+ http.NotFound(w, r)26+ return27+ }28+ w.Header().Set("Content-Type", "application/json")29+ if r.URL.Query().Get("method") != "" {30+ _, _ = w.Write([]byte("null"))31+ return32+ }33+ _, _ = w.Write([]byte(`{"policy":{"name":"Standalone frontend","features":{"pages":true}},"slug":"test","url":"http://client.example"}`))34+ }))35+ defer upstream.Close()36+ server, err := configure([]string{"-listen", "127.0.0.1:0", "-backend", upstream.URL, "-public-url", "http://client.example"}, &bytes.Buffer{})37+ if err != nil {38+ t.Fatal(err)39+ }40+ response := httptest.NewRecorder()41+ server.Handler.ServeHTTP(response, httptest.NewRequest("GET", "http://client.example/", nil))42+ if response.Code != 200 || !strings.Contains(response.Body.String(), "Standalone frontend") {43+ t.Fatalf("standalone command: %d %s", response.Code, response.Body.String())44+ }45+ if server.Addr != "127.0.0.1:0" {46+ t.Fatalf("listen=%s", server.Addr)47+ }48+}
A cmd/tinygit/main.go +88 -0
@@ -0,0 +1,88 @@
1+// Command tinygit serves signed repository metadata and native Git smart HTTP.2+package main3+4+import (5+ "context"6+ "errors"7+ "flag"8+ "fmt"9+ "io"10+ "net"11+ "net/http"12+ "os"13+ "os/exec"14+ "os/signal"15+ "syscall"16+ "time"17+18+ "github.com/FelineStateMachine/tinyrelay/tinygit"19+)20+21+func main() {22+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)23+ defer stop()24+ if err := run(ctx, os.Args[1:], os.Stdout, os.Stderr); err != nil {25+ fmt.Fprintln(os.Stderr, "tinygit:", err)26+ os.Exit(1)27+ }28+}29+30+func run(ctx context.Context, args []string, stdout, stderr io.Writer) error {31+ flags := flag.NewFlagSet("tinygit", flag.ContinueOnError)32+ flags.SetOutput(stderr)33+ listen := flags.String("listen", "127.0.0.1:8082", "HTTP listen address (put TLS and request limits in front for public hosting)")34+ data := flags.String("data", "./tinygit-data", "dedicated data directory for events.db and bare Git repositories")35+ owner := flags.String("owner", "", "required repository owner's lowercase hex Nostr public key")36+ publicURL := flags.String("public-url", "", "canonical public HTTP(S) URL")37+ if err := flags.Parse(args); err != nil {38+ if errors.Is(err, flag.ErrHelp) {39+ return nil40+ }41+ return err42+ }43+ if flags.NArg() != 0 {44+ return errors.New("unexpected positional arguments")45+ }46+ if _, err := exec.LookPath("git"); err != nil {47+ return fmt.Errorf("native Git is required: %w", err)48+ }49+ handler, err := tinygit.OpenServer(ctx, tinygit.ServerConfig{DataDir: *data, Owner: *owner, PublicURL: *publicURL})50+ if err != nil {51+ return err52+ }53+ defer handler.Close()54+ listener, err := net.Listen("tcp", *listen)55+ if err != nil {56+ return err57+ }58+ defer listener.Close()59+ server := &http.Server{60+ Handler: handler,61+ ReadHeaderTimeout: 10 * time.Second,62+ IdleTimeout: 60 * time.Second,63+ MaxHeaderBytes: 1 << 20,64+ BaseContext: func(net.Listener) context.Context { return ctx },65+ }66+ done := make(chan error, 1)67+ go func() { done <- server.Serve(listener) }()68+ fmt.Fprintln(stdout, "tinygit listening on http://"+listener.Addr().String())69+ select {70+ case err := <-done:71+ if errors.Is(err, http.ErrServerClosed) {72+ return nil73+ }74+ return err75+ case <-ctx.Done():76+ shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)77+ defer cancel()78+ err := server.Shutdown(shutdown)79+ if err != nil {80+ _ = server.Close()81+ }82+ serveErr := <-done83+ if errors.Is(serveErr, http.ErrServerClosed) {84+ serveErr = nil85+ }86+ return errors.Join(err, serveErr)87+ }88+}
A cmd/tinygit/main_integration_test.go +191 -0
@@ -0,0 +1,191 @@
1+package main2+3+import (4+ "bufio"5+ "bytes"6+ "context"7+ "encoding/json"8+ "fmt"9+ "io"10+ "net/http"11+ "os"12+ "os/exec"13+ "path/filepath"14+ "strings"15+ "testing"16+ "time"17+18+ "github.com/FelineStateMachine/tinyrelay/internal/event"19+)20+21+// This is deliberately a subprocess acceptance test: it proves cmd/tinygit is22+// a real standalone server, not only an embeddable HTTP handler.23+func TestStandaloneBinarySignedPushCloneAndRestart(t *testing.T) {24+ if testing.Short() {25+ t.Skip("builds and runs the standalone binary and native Git")26+ }27+ root := t.TempDir()28+ binary := filepath.Join(root, "tinygit")29+ build := exec.Command("go", "build", "-o", binary, ".")30+ if out, err := build.CombinedOutput(); err != nil {31+ t.Fatalf("build standalone binary: %v\n%s", err, out)32+ }33+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)34+ defer cancel()35+ secret := strings.Repeat("0", 63) + "1"36+ owner, err := event.PublicKey(secret)37+ if err != nil {38+ t.Fatal(err)39+ }40+ data := filepath.Join(root, "data")41+ start := func() (string, func()) {42+ t.Helper()43+ cmd := exec.CommandContext(ctx, binary, "-listen", "127.0.0.1:0", "-data", data, "-owner", owner)44+ stdout, err := cmd.StdoutPipe()45+ if err != nil {46+ t.Fatal(err)47+ }48+ cmd.Stderr = os.Stderr49+ if err := cmd.Start(); err != nil {50+ t.Fatal(err)51+ }52+ done := make(chan error, 1)53+ go func() { done <- cmd.Wait() }()54+ stopped := false55+ stop := func() {56+ t.Helper()57+ if stopped {58+ return59+ }60+ stopped = true61+ _ = cmd.Process.Signal(os.Interrupt)62+ select {63+ case err := <-done:64+ if err != nil {65+ t.Errorf("server exit: %v", err)66+ }67+ case <-time.After(10 * time.Second):68+ _ = cmd.Process.Kill()69+ <-done70+ t.Error("server did not shut down")71+ }72+ }73+ t.Cleanup(stop)74+ line := make(chan string, 1)75+ go func() {76+ scanner := bufio.NewScanner(stdout)77+ if scanner.Scan() {78+ line <- scanner.Text()79+ } else {80+ line <- ""81+ }82+ }()83+ var address string84+ select {85+ case ready := <-line:86+ const prefix = "tinygit listening on "87+ if !strings.HasPrefix(ready, prefix) {88+ t.Fatalf("startup: %q", ready)89+ }90+ address = strings.TrimPrefix(ready, prefix)91+ case <-ctx.Done():92+ t.Fatal(ctx.Err())93+ }94+ res, err := http.Get(address + "/healthz")95+ if err != nil {96+ t.Fatal(err)97+ }98+ res.Body.Close()99+ if res.StatusCode != http.StatusOK {100+ t.Fatalf("health = %d", res.StatusCode)101+ }102+ return address, stop103+ }104+ address, stop := start()105+ post := func(e event.Event) bool {106+ t.Helper()107+ if err := event.Sign(&e, secret); err != nil {108+ t.Fatal(err)109+ }110+ body, err := json.Marshal(e)111+ if err != nil {112+ t.Fatal(err)113+ }114+ res, err := http.Post(address+"/events", "application/json", bytes.NewReader(body))115+ if err != nil {116+ t.Fatal(err)117+ }118+ defer res.Body.Close()119+ var result struct {120+ ID string `json:"id"`121+ Pending bool `json:"pending"`122+ }123+ if res.StatusCode != http.StatusOK {124+ body, _ := io.ReadAll(res.Body)125+ t.Fatalf("publish = %d: %s", res.StatusCode, body)126+ }127+ if err := json.NewDecoder(res.Body).Decode(&result); err != nil {128+ t.Fatal(err)129+ }130+ if result.ID != e.ID {131+ t.Fatalf("event ID = %q, want %q", result.ID, e.ID)132+ }133+ return result.Pending134+ }135+ git := func(dir string, wantSuccess bool, args ...string) string {136+ t.Helper()137+ cmd := exec.CommandContext(ctx, "git", args...)138+ cmd.Dir = dir139+ 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")140+ out, err := cmd.CombinedOutput()141+ if (err == nil) != wantSuccess {142+ t.Fatalf("git %v: %v\n%s", args, err, out)143+ }144+ return strings.TrimSpace(string(out))145+ }146+ work := filepath.Join(root, "work")147+ if err := os.Mkdir(work, 0700); err != nil {148+ t.Fatal(err)149+ }150+ git(work, true, "init", "-b", "main")151+ if err := os.WriteFile(filepath.Join(work, "hello.txt"), []byte("standalone Git\n"), 0600); err != nil {152+ t.Fatal(err)153+ }154+ git(work, true, "add", "hello.txt")155+ git(work, true, "commit", "-m", "first")156+ oid := git(work, true, "rev-parse", "HEAD")157+ post(event.Event{Kind: 30617, CreatedAt: 100, Tags: [][]string{{"d", "demo"}}})158+ cloneURL := func() string {159+ return address + "/npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d/demo.git"160+ }161+ git(work, false, "push", cloneURL(), "main")162+ state := event.Event{Kind: 30618, CreatedAt: 101, Tags: [][]string{{"d", "demo"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", oid}}}163+ if !post(state) {164+ t.Fatal("missing objects were not staged as pending")165+ }166+ t.Log(git(work, true, "push", cloneURL(), "main"))167+ if post(state) {168+ t.Fatal("receive-pack did not promote pending state")169+ }170+ git(work, true, "commit", "--allow-empty", "-m", "unsigned change")171+ git(work, false, "push", cloneURL(), "main")172+ git(work, false, "push", cloneURL(), "HEAD:refs/heads/unauthorized")173+ verifyClone := func(name string) {174+ t.Helper()175+ dest := filepath.Join(root, name)176+ t.Log(git(root, true, "clone", cloneURL(), dest))177+ if got := git(dest, true, "rev-parse", "HEAD"); got != oid {178+ t.Fatalf("cloned HEAD = %s, want %s", got, oid)179+ }180+ body, err := os.ReadFile(filepath.Join(dest, "hello.txt"))181+ if err != nil || string(body) != "standalone Git\n" {182+ t.Fatalf("clone content = %q (%v)", body, err)183+ }184+ }185+ verifyClone("clone")186+ stop()187+ address, stop = start()188+ defer stop()189+ verifyClone("restarted-clone")190+ t.Log(fmt.Sprintf("verified signed push, rejected unsigned pushes, clone and durable restart at %s", oid))191+}
M docs/architecture.md +5 -3
@@ -1,5 +1,7 @@
11 # Architecture22 3+`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.4+35 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.46 57 ## Find your starting point
@@ -16,9 +18,9 @@ Each tenant has a SQLite store, a policy snapshot and a set of services. The dae
1618 | [`community`](../internal/community/doc.go), [`communityread`](../internal/communityread/doc.go) | Membership, moderation, rooms, agents and the read models consumed by records. |1719 | [`records`](../internal/records/doc.go) | Relay identity, signed protocol records, projections and notifications. |1820 | [`blob`](../internal/blob/doc.go), [`sites`](../internal/sites/doc.go) | Content-addressed files, upload rules and sites backed by signed manifests. |19-| [`gitrelay`](../internal/gitrelay/doc.go) | Repository admission, object storage, synchronization, repair and Git HTTP endpoints. |21+| [`tinygit`](../tinygit/doc.go) | Repository admission, object storage, synchronization, repair and Git HTTP endpoints. |2022 | [`replication`](../internal/replication/doc.go), [`syncprotocol`](../internal/syncprotocol/doc.go) | Relay synchronization plans and transports, count sketches and reconciliation sessions. |21-| [`relay`](../internal/relay/doc.go), [`mcp`](../internal/mcp/doc.go), [`webui`](../internal/webui/doc.go) | WebSocket sessions, MCP requests, HTML pages and browser interactions. |23+| [`relay`](../internal/relay/doc.go), [`mcp`](../internal/mcp/doc.go), [`tinyclient`](../tinyclient/doc.go) | WebSocket sessions, MCP requests, HTML pages and browser interactions. |2224 | [`agentrunner`](../internal/agentrunner) | The optional agent process: room mentions, durable queue recovery, ACP or command execution, and permission prompts. |2325 | [`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. |2426 | [`seedmark`](../internal/seedmark) | Deterministic decorative SVG avatars for people and rooms without a profile picture. |
@@ -32,7 +34,7 @@ Each tenant has a SQLite store, a policy snapshot and a set of services. The dae
3234 3335 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.3436 35-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`.37+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`.3638 3739 ## Event acceptance3840
A docs/extensions/README.md +9 -0
A docs/extensions/tinyclient-read-adapter.md +83 -0
@@ -0,0 +1,83 @@
1+# Tinyclient read adapter2+3+| Field | Value |4+| --- | --- |5+| Identifier | `tinyclient-read-adapter` |6+| Status | Experimental |7+| Contract revision | 1, tied to this repository revision; no version negotiation |8+| Owner | `tinyclient` defines the presentation contract; the tinyrelay backend owns data and authorization. |9+| Transport | HTTP GET, JSON success bodies, plain-text error bodies. |10+| Endpoint | `/api/tinyclient`, under the selected tenant prefix when present. |11+12+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.13+14+## Authentication and scope15+16+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.17+18+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.19+20+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.21+22+## Request forms23+24+All forms use GET. If `op` is nonempty it selects a typed read before `method` is considered.25+26+### Presentation snapshot27+28+With no `op` or `method`, the response is a JSON object with:29+30+- `policy`: Presentation policy, using the public `Policy` structure.31+- `url`, `slug`, `identity`: Relay presentation identity.32+- `actor`: Public key resolved by the backend, or an empty string for an anonymous caller.33+- `version`, `revision`: Backend build metadata.34+- `rooms`, `activity`: Whether the backend provides the corresponding optional typed interfaces.35+- `share_presence`: The authenticated caller's presence preference.36+- `views`: Optional custom-view presentation definitions.37+- `read_error`: Optional private-access failure used to render a restricted page.38+39+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.40+41+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.42+43+### Method reads44+45+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.46+47+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.48+49+### Typed reads50+51+| `op` | Parameters | Backend contract |52+| --- | --- | --- |53+| `rooms` | `cursor`, `limit` | `RoomsReader.ListRooms` |54+| `room` | `room`, `cursor`, `limit` | `RoomsReader.ReadRoom` |55+| `thread` | `room`, `root`, `cursor`, `limit` | `RoomsReader.ReadThread` |56+| `activity` | `room`, `root` | `ChatActivityReader.ReadChatActivity` |57+58+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.59+60+## Errors61+62+| Status | Meaning |63+| --- | --- |64+| 200 | A successful JSON read, including a deliberately masked private snapshot. |65+| 400 | A method outside the allowlist or malformed JSON method parameters. |66+| 401 | The actor resolver rejected authentication. |67+| 403 | Private access or a backend operation was refused; also used for an unsupported typed `op`. |68+| 405 | Non-GET method; response includes `Allow: GET`. |69+| 501 | Requested typed interface is unavailable. |70+71+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.72+73+## Discovery and compatibility74+75+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.76+77+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.78+79+## Verification80+81+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).82+83+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).
M docs/fixi.md +1 -1
A docs/module-contracts.md +81 -0
@@ -0,0 +1,81 @@
1+# Module boundaries and protocol contracts2+3+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.4+5+The Git and frontend extraction establishes the first boundaries. The additional boundaries below are proposed follow-up work, not claims that those packages have already been extracted.6+7+## Keep protocol rules separate from access policy8+9+Apply checks in this order:10+11+1. Parse the wire representation and verify its signature or request proof.12+2. Validate the feature's protocol structure and semantics.13+3. Apply host policy: membership, grants, visibility, quotas and enabled features.14+4. Commit the event, feature projection and required work together.15+5. Expose the result through the relevant transport and read model.16+17+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.18+19+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.20+21+## Recommended seams22+23+| Boundary | Owns | Must not own |24+| --- | --- | --- |25+| Shared Nostr primitives, a proposed public `protocol/nostr` package | Event and filter types, canonical serialization, event IDs, signatures and common encoding rules. | Tenant configuration, membership, SQL or feature-specific tags. |26+| Request-proof adapters | Verification for HTTP, WebSocket and service-specific proof profiles; authenticated actor and proof scope. | Deciding what that actor may do. |27+| Host access policy | Membership, agent grants, visibility, quotas and admission decisions. | Reinterpreting signed fields or weakening feature protocol validation. |28+| `tinygit` | Git repository metadata, signed refs, object storage, smart HTTP, repair and Git-specific protocol rules. | Rooms, browser sessions or general relay management. |29+| `tinyclient` | Server-rendered pages, browser interactions, draft construction and signer-result verification. | Authoritative permissions, repository mutation or event persistence. |30+| 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. |31+| Site service | Signed site manifests, routing and site lifecycle over a blob-store contract. | Owning another copy of blob storage or upload authorization. |32+| Room service | Room state and message projections, with the room protocols it implements. | General agent grants or all community administration. |33+| Host assembly | Tenant lifecycle, transaction coordination, worker ownership and adapter wiring. | A second implementation of feature rules. |34+35+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.36+37+`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.38+39+## Define the contract before moving dependencies40+41+Each public feature boundary should document:42+43+- Inputs, outputs and stable error categories.44+- The authenticated actor and authorization context it accepts.45+- Which protocol checks it performs and which policy decisions the host supplies.46+- Whether it owns a transaction or joins one supplied by the host.47+- Idempotency, ordering and retry behavior.48+- Cancellation, resource ownership and shutdown.49+- Side effects and the point at which they become visible.50+- Capability differences between standalone and integrated operation.51+52+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.53+54+During the extraction, `tinygit` still shares internal event, policy and SQLite implementations with the host. Public aliases can preserve source compatibility, but they are transitional plumbing, not a substitute for a small public protocol and persistence contract. Independent builds are a first milestone; a dependency boundary check is the next one.55+56+## Describe standards and project extensions separately57+58+Protocol documentation should classify each behavior as one of:59+60+- **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.61+- **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.62+- **Project extension:** Additional wire behavior defined by this project. Describe how an ordinary client behaves without it and how peers discover support.63+- **Local implementation detail:** Storage paths, queues, indexes or internal APIs that are not an interoperability contract.64+65+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.66+67+The extension catalog should index those specifications rather than repeat their rules. Start by auditing agent grants and grant requests, callbacks, custom-view transforms and custom HTTP/MCP bindings. Existing behavior is not automatically standardized, and an implementation detail should not be promoted into a wire extension merely because it is unusual.68+69+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.70+71+## Compatibility and terminology72+73+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.74+75+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.76+77+## Verification78+79+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.80+81+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.
M docs/testing.md +15 -1
@@ -11,6 +11,20 @@ make test-race
1111 1212 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.1313 14+## Component checks15+16+Build all executables with `make build`. Run the focused component suites with:17+18+```sh19+go test -race ./tinygit ./cmd/tinygit ./tinyclient ./cmd/tinyclient20+go test ./internal/daemon -run TestStandaloneTinyclient21+npm run test:tinyclient22+```23+24+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.25+26+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).27+1428 ## Remote Linux host1529 1630 `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.
@@ -88,6 +102,6 @@ The raw JSON records failures as failures; interrupted or absent phases are not
88102 89103 ## Git collaboration checks90104 91-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.105+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.92106 93107 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.
M internal/daemon/client_browse.go +1 -1
M internal/daemon/client_browse_http.go +1 -1
M internal/daemon/client_files_test.go +1 -1
M internal/daemon/collaboration_approvals.go +1 -1
M internal/daemon/collaboration_browse.go +1 -1
M internal/daemon/custom_views.go +1 -1
M internal/daemon/custom_views_transform.go +1 -1
M internal/daemon/event_commit.go +1 -1
M internal/daemon/git_agent_maintainer_test.go +1 -1
M internal/daemon/git_live.go +1 -1
M internal/daemon/git_live_test.go +1 -1
M internal/daemon/git_outbox_sync.go +1 -1
M internal/daemon/git_payload_test.go +1 -1
M internal/daemon/git_services.go +1 -1
M internal/daemon/git_services_test.go +1 -1
M internal/daemon/git_sync_acceptance_test.go +1 -1
M internal/daemon/git_sync_cursor_test.go +1 -1
M internal/daemon/git_sync_integration_test.go +1 -1
M internal/daemon/mcp_tools.go +1 -1
M internal/daemon/private_peer_acceptance_test.go +1 -1
M internal/daemon/replication_services.go +1 -1
M internal/daemon/repository_maintainers.go +1 -1
M internal/daemon/rooms_browse.go +1 -1
M internal/daemon/services.go +2 -2
M internal/daemon/tenant.go +2 -2
A internal/daemon/tinyclient_integration_test.go +109 -0
@@ -0,0 +1,109 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "io"7+ "net/http"8+ "net/http/httptest"9+ "net/url"10+ "strings"11+ "testing"12+13+ "github.com/FelineStateMachine/tinyrelay/tinyclient"14+)15+16+func TestStandaloneTinyclientAuthenticatesAgainstRealRelay(t *testing.T) {17+ _, tenant := testTenant(t)18+ upstream := httptest.NewServer(tenant)19+ defer upstream.Close()20+ handler, err := tinyclient.NewRemote(tinyclient.RemoteOptions{BackendURL: upstream.URL, PublicURL: "http://relay.test"})21+ if err != nil {22+ t.Fatal(err)23+ }24+ frontend := httptest.NewServer(handler)25+ defer frontend.Close()26+ frontendURL, _ := url.Parse(frontend.URL)27+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}28+ issue := func(method, path, body string, cookie *http.Cookie, signed bool) (*http.Response, string) {29+ t.Helper()30+ request := httptest.NewRequest(method, "http://relay.test"+path, strings.NewReader(body))31+ request.Header.Set("Content-Type", "application/json")32+ request.Header.Set("Origin", "http://relay.test")33+ if cookie != nil {34+ request.AddCookie(cookie)35+ }36+ if signed {37+ signRequest(t, request, body)38+ }39+ request.URL.Scheme, request.URL.Host = frontendURL.Scheme, frontendURL.Host40+ request.RequestURI = ""41+ response, err := client.Do(request)42+ if err != nil {43+ t.Fatal(err)44+ }45+ raw, err := io.ReadAll(response.Body)46+ response.Body.Close()47+ if err != nil {48+ t.Fatal(err)49+ }50+ return response, string(raw)51+ }52+ login, body := issue("POST", "/session", "", nil, true)53+ if login.StatusCode != 200 || len(login.Cookies()) != 1 {54+ t.Fatalf("signed login: %d %s", login.StatusCode, body)55+ }56+ cookie := login.Cookies()[0]57+ if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode {58+ t.Fatalf("unsafe cookie: %+v", cookie)59+ }60+ for _, path := range []string{"/", "/account", "/manage/rules", "/chat", "/scripts/bridge.js", "/manifest.webmanifest"} {61+ response, body := issue("GET", path, "", cookie, false)62+ integrated := httptest.NewRecorder()63+ request := httptest.NewRequest("GET", "http://relay.test"+path, nil)64+ request.AddCookie(cookie)65+ tenant.ServeHTTP(integrated, request)66+ if response.StatusCode != integrated.Code || body != integrated.Body.String() {67+ t.Fatalf("standalone parity %s: status=%d want=%d body lengths=%d/%d", path, response.StatusCode, integrated.Code, len(body), integrated.Body.Len())68+ }69+ }70+ denied, _ := issue("POST", "/manage/rpc", `{"method":"stats"}`, cookie, false)71+ if denied.StatusCode != 401 {72+ t.Fatalf("cookie-only write accepted: %d", denied.StatusCode)73+ }74+ signed, body := issue("POST", "/manage/rpc", `{"method":"stats"}`, cookie, true)75+ if signed.StatusCode != 200 {76+ t.Fatalf("signed management request lost URL binding: %d %s", signed.StatusCode, body)77+ }78+ // The new read bridge ignores claimed pubkeys and still requires a real session.79+ forged, body := issue("GET", tinyclient.BackendPath+"?actor="+tenant.Policy().Owner, "", nil, false)80+ var snapshot struct {81+ Actor string `json:"actor"`82+ }83+ if forged.StatusCode != 200 || json.Unmarshal([]byte(body), &snapshot) != nil || snapshot.Actor != "" {84+ t.Fatalf("forged actor accepted: %d %s", forged.StatusCode, body)85+ }86+ logout, body := issue("POST", "/session/logout", "", cookie, false)87+ if logout.StatusCode != 200 {88+ t.Fatalf("logout: %d %s", logout.StatusCode, body)89+ }90+ response, body := issue("GET", "/account", "", cookie, false)91+ if response.StatusCode != 200 || !strings.Contains(body, "Sign in to open your account.") {92+ t.Fatalf("revoked session survived: %d", response.StatusCode)93+ }94+ p := tenant.Policy()95+ p.Features.Grasp, p.Features.Grasp08, p.Reads, p.Name = true, true, "members", "private secret project"96+ if err := tenant.applyPolicy(context.Background(), p); err != nil {97+ t.Fatal(err)98+ }99+ for _, path := range []string{"/", tinyclient.BackendPath} {100+ response, body := issue("GET", path, "", cookie, false)101+ if response.StatusCode != 200 || strings.Contains(body, p.Name) || strings.Contains(body, p.Owner) {102+ t.Fatalf("private metadata leaked through %s: %d", path, response.StatusCode)103+ }104+ }105+ response, _ = issue("GET", "/social", "", cookie, false)106+ if response.StatusCode != 401 {107+ t.Fatalf("private read without membership: %d", response.StatusCode)108+ }109+}
M internal/daemon/webui_rooms.go +1 -1
M package.json +2 -0
M scripts/agent-grant.test.mjs +2 -2
M scripts/approvals.test.mjs +2 -2
M scripts/blossom-encryption.test.mjs +2 -2
M scripts/blossom-manifests-review.test.mjs +2 -2
M scripts/blossom-manifests.test.mjs +2 -2
M scripts/blossom-upload.test.mjs +2 -2
M scripts/build-nostr-name.mjs +1 -1
M scripts/build-signer.mjs +1 -1
M scripts/chat-activity.test.mjs +1 -1
M scripts/chat-files.test.mjs +1 -1
M scripts/chat-presence.test.mjs +1 -1
M scripts/chat.test.mjs +1 -1
M scripts/collaboration-compose.test.mjs +1 -1
M scripts/direct-messages.test.mjs +2 -2
M scripts/file-catalog.test.mjs +1 -1
M scripts/file-media-preview.test.mjs +2 -2
M scripts/file-messages.test.mjs +2 -2
M scripts/file-workspace.test.mjs +14 -5
@@ -57,9 +57,9 @@ const load = (workspace = false) => {
5757 globalThis.tiny = window.tiny;5858 Object.defineProperty(globalThis, "navigator", {configurable: true, value: {clipboard: {writeText: async () => { throw Error("clipboard unavailable"); }}}});5959 globalThis.btoa = value => Buffer.from(value, "binary").toString("base64"); globalThis.atob = value => Buffer.from(value, "base64").toString("binary");60- vm.runInThisContext(fs.readFileSync("internal/webui/tiny.js", "utf8"));61- if (workspace) { vm.runInThisContext(fs.readFileSync("internal/webui/blossom-manifests.js", "utf8")); vm.runInThisContext(fs.readFileSync("internal/webui/blossom-encryption.js", "utf8")); vm.runInThisContext(fs.readFileSync("internal/webui/blossom-upload.js", "utf8")); vm.runInThisContext(fs.readFileSync("internal/webui/file-workspace.js", "utf8")); } else { delete globalThis.tiny.blossom.upload; }62- vm.runInThisContext(fs.readFileSync("internal/webui/components.js", "utf8"));60+ vm.runInThisContext(fs.readFileSync("tinyclient/tiny.js", "utf8"));61+ if (workspace) { vm.runInThisContext(fs.readFileSync("tinyclient/blossom-manifests.js", "utf8")); vm.runInThisContext(fs.readFileSync("tinyclient/blossom-encryption.js", "utf8")); vm.runInThisContext(fs.readFileSync("tinyclient/blossom-upload.js", "utf8")); vm.runInThisContext(fs.readFileSync("tinyclient/file-workspace.js", "utf8")); } else { delete globalThis.tiny.blossom.upload; }62+ vm.runInThisContext(fs.readFileSync("tinyclient/components.js", "utf8"));6363 return {document, window, FileTools: customElements.registry["file-tools"], FileMirror: customElements.registry["file-mirror"], FileUpload: customElements.registry["file-upload"]};6464 };6565
@@ -430,6 +430,8 @@ test("large uploads hand signed requests to Background Fetch and read the parked
430430 const hash = await window.tiny.sha256hex(bytes);431431 const descriptor = {sha256: hash, size: bytes.byteLength};432432 let started;433+ let signalStart;434+ const didStart = new Promise(resolve => { signalStart = resolve; });433435 const task = {result: "", uploaded: 0, uploadTotal: bytes.byteLength, listeners: [], addEventListener(name, fn) { this.listeners.push(fn); }, removeEventListener() {}};434436 const parked = new Map();435437 globalThis.caches = {open: async () => ({match: async key => parked.get(key), delete: async key => parked.delete(key), put: async (key, value) => parked.set(key, value)})};
@@ -438,7 +440,7 @@ test("large uploads hand signed requests to Background Fetch and read the parked
438440 globalThis.localStorage = {getItem: key => store.get(key) ?? null, setItem: (key, value) => store.set(key, value), removeItem: key => store.delete(key), get length() { return store.size; }};439441 Object.defineProperty(globalThis, "localStorage", {configurable: true, value: globalThis.localStorage});440442 Object.keys = ((original => target => target === globalThis.localStorage ? [...store.keys()] : original(target)))(Object.keys);441- Object.defineProperty(globalThis, "navigator", {configurable: true, value: {clipboard: {writeText: async () => { throw Error("unavailable"); }}, serviceWorker: {controller: {}, ready: Promise.resolve({backgroundFetch: {fetch: async (id, requests, options) => { started = {id, requests, options}; return task; }, get: async () => null}})}}});443+ Object.defineProperty(globalThis, "navigator", {configurable: true, value: {clipboard: {writeText: async () => { throw Error("unavailable"); }}, serviceWorker: {controller: {}, ready: Promise.resolve({backgroundFetch: {fetch: async (id, requests, options) => { started = {id, requests, options}; signalStart(); return task; }, get: async () => null}})}}});442444 const originalFetch = globalThis.fetch;443445 globalThis.fetch = async (_url, init = {}) => new Response(null, {status: 204, headers: {Allow: "PATCH, PUT"}});444446 try {
@@ -447,7 +449,14 @@ test("large uploads hand signed requests to Background Fetch and read the parked
447449 form.elements.namedItem("file").files = [new File([bytes], "big.bin", {type: "application/octet-stream"})];448450 form.elements.namedItem("background").checked = true;449451 const run = upload.run(form);450- await new Promise(resolve => setTimeout(resolve, 50));452+ let timeout;453+ try {454+ await Promise.race([didStart, new Promise((_, reject) => {455+ timeout = setTimeout(() => reject(new Error("background fetch was not started")), 5000);456+ })]);457+ } finally {458+ clearTimeout(timeout);459+ }451460 assert.ok(started, "background fetch was not started");452461 assert.equal(started.requests.length, 2);453462 assert.equal(started.requests[0].method, "PATCH");
M scripts/grant-request.test.mjs +1 -1
M scripts/nostr-avatar.test.mjs +1 -1
M scripts/nostr-name-entry.mjs +1 -1
M scripts/page-link.test.mjs +1 -1
M scripts/private-services.test.mjs +2 -2
M scripts/profile-form.test.mjs +1 -1
M scripts/qa/README.md +1 -1
M scripts/rooms.test.mjs +2 -2
M scripts/shell-navigation.test.mjs +1 -1
M scripts/signer-recovery.test.mjs +2 -2
M scripts/signer.test.mjs +2 -2
M scripts/social-compose.test.mjs +1 -1
M scripts/test-signing.mjs +1 -1
M scripts/tiny-namespace.test.mjs +1 -1
M scripts/verify-bundles.mjs +2 -2
M scripts/view-artifact.test.mjs +1 -1
M scripts/webmcp.test.mjs +1 -1
M scripts/wiki-compose.test.mjs +2 -2
A tinyclient/README.md +116 -0
@@ -0,0 +1,116 @@
1+# tinyclient2+3+`tinyclient` is the relay website. It includes the HTML templates, styles,4+JavaScript modules and embedded browser bundles used by the integrated `tiny`5+backend. Pages render on the server and remain readable without JavaScript.6+Signing, encrypted messages, uploads and live updates use browser enhancements.7+8+## Build and run9+10+Run these commands from the repository root:11+12+```sh13+go build -o tinyclient-server ./cmd/tinyclient14+./tinyclient-server \15+ -listen 127.0.0.1:8081 \16+ -backend http://127.0.0.1:7447 \17+ -public-url https://relay.example18+```19+20+The backend must be a compatible tinyrelay backend (`tiny serve`) from this21+repository. Set its `--public-url` to the same `https://relay.example` address.22+Route that public23+origin to tinyclient through your HTTPS reverse proxy, preserving the Host24+header. Keep the backend listener on the private network. The frontend renders25+pages and assets locally and forwards relay APIs, WebSocket connections,26+streams, signed requests and mutations to the backend.27+28+For a path-based tenant, include the same path in both URLs:29+30+```sh31+./tinyclient-server \32+ -backend http://127.0.0.1:7447/r/team \33+ -public-url https://relay.example/r/team34+```35+36+Each process serves one tenant. The root path and `/r/<name>` are supported;37+arbitrary mount paths and different frontend and backend tenant paths are not.38+A root-mounted frontend rejects `/r/<name>` requests before rendering or39+forwarding them. A prefixed frontend accepts only its configured tenant path.40+The default listener is `127.0.0.1:8081`. Run `tinyclient-server -help` for flags.41+42+The binary embeds its assets and needs no working directory, SQLite database,43+Git installation or relay signing key at runtime. It requires a running backend44+for relay data and session validation. Failure to load the relay snapshot45+returns HTTP 502 rather than serving cached private data. It is not an offline client or a frontend for46+an arbitrary Nostr relay.47+48+## Authentication49+50+Keep the relay's public origin and tenant path unchanged when splitting the51+processes. NIP-98 signatures bind the exact URL, method and body. Tinyclient does52+not re-sign requests, accept a public key as authentication or translate proofs53+between origins. Incoming requests for a different public Host are rejected.54+Configure the relay's HTTPS public URL even when the private hop uses HTTP so55+it verifies the original scheme and issues secure cookies.56+57+Browser sessions remain owned, checked and revoked by the relay. The frontend58+loads the presentation snapshot through the read-only contract at59+`/api/tinyclient` using the browser's session cookie. Anonymous method and typed60+reads omit that cookie, including public article feeds requested by signed-in61+users. Authenticated reads require the requested actor to match the snapshot's62+authenticated actor. The relay derives the actor from the session, checks access63+on data reads and redacts private relay metadata. Owner-only settings are not64+included in anonymous or nonowner snapshots. Signed operations and relay65+protocol traffic retain their authorization headers, paths, query strings and66+bodies as they pass upstream. Signed page requests are handled by the integrated67+backend renderer rather than replaying a signature across several reads.68+69+Private pages and authenticated no-JavaScript navigation are supported with a70+valid browser session. Creating that session still requires a browser signer.71+Pointing the frontend at a different public origin is not a supported migration72+path for existing sessions or signed URLs.73+74+## Embed75+76+Import `github.com/FelineStateMachine/tinyrelay/tinyclient` and call `New` with a77+`Backend` and `Options`. Public `Policy`, `Features`, `CustomView` and room types78+let another package implement the interfaces without importing relay-internal79+types. `DefaultPolicy` supplies the normal feature settings.80+81+`Backend.Query` owns authorization for method calls. `Options.Actor` resolves82+authenticated requests; it must verify a session or proof, never a caller's83+claimed public key. Optional `RoomsReader`, `PrivateReader`,84+`AccountPreferencesReader`, `CustomViewSource` and `ChatActivityReader`85+interfaces provide typed room reads, membership checks and richer page data.86+The remote adapter preserves typed room summaries, folded replies, reaction87+counts, activity cards and account preferences.88+89+`App.BackendHandler()` exposes the read contract for an embedded data service.90+`App.ServeHTTP` mounts it automatically. `NewRemote` returns the standalone HTTP91+handler for use in another executable.92+93+The `/api/tinyclient` contract is a project-specific, read-only HTTP extension,94+not a Nostr NIP. See the [read-adapter specification](../docs/extensions/tinyclient-read-adapter.md)95+for request forms, authorization, errors and compatibility. GET without a method returns the authorized presentation96+snapshot. `method` and JSON-array `params` select an allowlisted backend read;97+`op=rooms|room|thread|activity` selects a typed read with its room, root, cursor98+and limit parameters. Every call resolves authentication at the tinyrelay99+backend. This adapter introduces no event kinds, signing formats or storage100+identifiers. Frontend validation improves feedback; the backend remains the101+authority for protocol validation, membership and mutation permissions.102+103+## Test and rebuild browser bundles104+105+```sh106+go test ./tinyclient ./cmd/tinyclient107+go test ./internal/daemon -run TestStandaloneTinyclient108+npm ci109+npm run test:tinyclient110+npm run build:tinyclient111+```112+113+Go builds use the checked-in browser bundles, so Node.js is not required to build114+or run the binary. Rebuild the bundles when their JavaScript sources or pinned115+dependencies change. Bundle verification compares regenerated bytes with the116+checked-in assets.
R internal/webui/agents_test.go → tinyclient/agents_test.go +1 -1
R internal/webui/apple-touch-icon.png → tinyclient/apple-touch-icon.png +0 -0

No content changes.

R internal/webui/approvals.go → tinyclient/approvals.go +1 -1
R internal/webui/approvals_test.go → tinyclient/approvals_test.go +1 -1
R internal/webui/avatars.go → tinyclient/avatars.go +1 -1
R internal/webui/avatars_test.go → tinyclient/avatars_test.go +1 -1
R internal/webui/badge-96.png → tinyclient/badge-96.png +0 -0

No content changes.

R internal/webui/blossom-encryption.js → tinyclient/blossom-encryption.js +0 -0

No content changes.

R internal/webui/blossom-manifests.js → tinyclient/blossom-manifests.js +0 -0

No content changes.

R internal/webui/blossom-upload.js → tinyclient/blossom-upload.js +0 -0

No content changes.

R internal/webui/bridge.js → tinyclient/bridge.js +0 -0

No content changes.

R internal/webui/browse.html → tinyclient/browse.html +0 -0

No content changes.

R internal/webui/browse_test.go → tinyclient/browse_test.go +2 -2
R internal/webui/chat-activity.go → tinyclient/chat-activity.go +1 -1
R internal/webui/chat-activity.html → tinyclient/chat-activity.html +0 -0

No content changes.

R internal/webui/chat-activity.js → tinyclient/chat-activity.js +0 -0

No content changes.

R internal/webui/chat-files.js → tinyclient/chat-files.js +0 -0

No content changes.

R internal/webui/chat-presence.js → tinyclient/chat-presence.js +0 -0

No content changes.

R internal/webui/chat.html → tinyclient/chat.html +0 -0

No content changes.

R internal/webui/chat.js → tinyclient/chat.js +0 -0

No content changes.

R internal/webui/chat_activity_http_test.go → tinyclient/chat_activity_http_test.go +1 -1
R internal/webui/chat_activity_test.go → tinyclient/chat_activity_test.go +1 -1
R internal/webui/chat_pages.go → tinyclient/chat_pages.go +1 -1
R internal/webui/chat_pages_test.go → tinyclient/chat_pages_test.go +1 -1
R internal/webui/collaboration.go → tinyclient/collaboration.go +1 -1
R internal/webui/collaboration.html → tinyclient/collaboration.html +0 -0

No content changes.

R internal/webui/collaboration_test.go → tinyclient/collaboration_test.go +1 -1
R internal/webui/components.js → tinyclient/components.js +0 -0

No content changes.

R internal/webui/connections.go → tinyclient/connections.go +1 -1
R internal/webui/connections_test.go → tinyclient/connections_test.go +1 -1
A tinyclient/contracts.go +26 -0
R internal/webui/custom_views.go → tinyclient/custom_views.go +1 -1
R internal/webui/custom_views_test.go → tinyclient/custom_views_test.go +1 -1
R internal/webui/direct-messages.js → tinyclient/direct-messages.js +0 -0

No content changes.

R internal/webui/doc.go → tinyclient/doc.go +5 -2
R internal/webui/file-catalog.js → tinyclient/file-catalog.js +0 -0

No content changes.

R internal/webui/file-messages.js → tinyclient/file-messages.js +0 -0

No content changes.

R internal/webui/file-workspace.js → tinyclient/file-workspace.js +0 -0

No content changes.

R internal/webui/files.go → tinyclient/files.go +1 -1
R internal/webui/files_test.go → tinyclient/files_test.go +1 -1
R internal/webui/fixi.js → tinyclient/fixi.js +0 -0

No content changes.

R internal/webui/grant-request.js → tinyclient/grant-request.js +0 -0

No content changes.

R internal/webui/group_avatars_test.go → tinyclient/group_avatars_test.go +1 -1
R internal/webui/icon-192.png → tinyclient/icon-192.png +0 -0

No content changes.

R internal/webui/icon-512.png → tinyclient/icon-512.png +0 -0

No content changes.

R internal/webui/icon-maskable-512.png → tinyclient/icon-maskable-512.png +0 -0

No content changes.

R internal/webui/icon-maskable.svg → tinyclient/icon-maskable.svg +0 -0

No content changes.

R internal/webui/icon-mono.svg → tinyclient/icon-mono.svg +0 -0

No content changes.

R internal/webui/icon.svg → tinyclient/icon.svg +0 -0

No content changes.

R internal/webui/job_views.go → tinyclient/job_views.go +1 -1
R internal/webui/manage.html → tinyclient/manage.html +0 -0

No content changes.

R internal/webui/manifest.webmanifest → tinyclient/manifest.webmanifest +0 -0

No content changes.

R internal/webui/markdown.go → tinyclient/markdown.go +1 -1
R internal/webui/markdown_test.go → tinyclient/markdown_test.go +1 -1
R internal/webui/mobile_header.go → tinyclient/mobile_header.go +1 -1
R internal/webui/mobile_header_test.go → tinyclient/mobile_header_test.go +1 -1
R internal/webui/nostr-name.js → tinyclient/nostr-name.js +0 -0

No content changes.

R internal/webui/nostr-name.js.license → tinyclient/nostr-name.js.license +1 -1
R internal/webui/open.go → tinyclient/open.go +1 -1
R internal/webui/open_test.go → tinyclient/open_test.go +1 -1
R internal/webui/page.html → tinyclient/page.html +0 -0

No content changes.

R internal/webui/people_test.go → tinyclient/people_test.go +1 -1
R internal/webui/prefix.go → tinyclient/prefix.go +1 -1
R internal/webui/prefix_test.go → tinyclient/prefix_test.go +1 -1
R internal/webui/private-services.js → tinyclient/private-services.js +0 -0

No content changes.

R internal/webui/private.go → tinyclient/private.go +1 -1
R internal/webui/profile_test.go → tinyclient/profile_test.go +1 -1
R internal/webui/public.html → tinyclient/public.html +0 -0

No content changes.

R internal/webui/public_pages.go → tinyclient/public_pages.go +4 -5
@@ -1,4 +1,4 @@
1-package webui1+package tinyclient22 33 import (44 "context"
@@ -12,7 +12,6 @@ import (
1212 "time"1313 1414 "github.com/FelineStateMachine/tinyrelay/internal/event"15- "github.com/FelineStateMachine/tinyrelay/internal/sites"1615 )1716 1817 func (a *App) publicFeed(ctx context.Context, tab, actor string, values url.Values) ([]any, error) {
@@ -34,7 +33,7 @@ func (a *App) publicFeed(ctx context.Context, tab, actor string, values url.Valu
3433 case "articles":3534 filter = map[string]any{"kinds": []int{30023}, "limit": 50}3635 case "sites":37- filter = map[string]any{"kinds": []int{sites.KindSite, sites.KindNamedSite}, "limit": 100}36+ filter = map[string]any{"kinds": []int{kindSite, kindNamedSite}, "limit": 100}3837 default:3938 return nil, nil4039 }
@@ -99,7 +98,7 @@ func (a *App) siteRows(feed []any) []any {
9998 if json.Unmarshal(encoded, &e) != nil {10099 continue101100 }102- label := sites.SiteLabel(e)101+ label := siteLabel(e)103102 if label == "" {104103 continue105104 }
@@ -109,7 +108,7 @@ func (a *App) siteRows(feed []any) []any {
109108 } else if scheme == "wss" {110109 scheme = "https"111110 }112- rows = append(rows, map[string]any{"id": e.ID, "kind": e.Kind, "author": e.PubKey, "label": label, "paths": len(sites.SitePaths(e)), "created_at": e.CreatedAt, "url": scheme + "://" + label + "." + base.Host})111+ rows = append(rows, map[string]any{"id": e.ID, "kind": e.Kind, "author": e.PubKey, "label": label, "paths": sitePathCount(e), "created_at": e.CreatedAt, "url": scheme + "://" + label + "." + base.Host})113112 }114113 return rows115114 }
R internal/webui/public_pages_test.go → tinyclient/public_pages_test.go +1 -1
A tinyclient/public_test.go +36 -0
R internal/webui/read_contracts.go → tinyclient/read_contracts.go +1 -1
R internal/webui/read_contracts_test.go → tinyclient/read_contracts_test.go +1 -1
A tinyclient/remote.go +317 -0
@@ -0,0 +1,317 @@
1+package tinyclient2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "io"9+ "net/http"10+ "net/http/httputil"11+ "net/url"12+ "strings"13+ "time"14+)15+16+// BackendPath is the read-only adapter endpoint mounted by App. It uses the17+// relay's normal actor resolver; a client-supplied public key is never trusted.18+const BackendPath = "/api/tinyclient"19+20+type remoteSnapshot struct {21+ Policy Policy `json:"policy"`22+ URL string `json:"url"`23+ Slug string `json:"slug"`24+ Identity string `json:"identity"`25+ Actor string `json:"actor"`26+ Version string `json:"version"`27+ Revision string `json:"revision"`28+ Views []CustomView `json:"views,omitempty"`29+ SharePresence bool `json:"share_presence"`30+ ReadError string `json:"read_error,omitempty"`31+ Rooms bool `json:"rooms"`32+ Activity bool `json:"activity"`33+}34+35+// BackendHandler exposes the read contract without rendering HTML. Mount it36+// at BackendPath when embedding the client separately from its data service.37+// Query authorization remains the responsibility of Backend.Query.38+func (a *App) BackendHandler() http.Handler {39+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {40+ w.Header().Set("Cache-Control", "private, no-store")41+ if r.Method != http.MethodGet {42+ w.Header().Set("Allow", "GET")43+ http.Error(w, "method not allowed", 405)44+ return45+ }46+ actor, err := a.resolveActor(r)47+ if err != nil {48+ http.Error(w, "authentication required", 401)49+ return50+ }51+ if r.URL.Query().Get("op") != "" {52+ a.typedBackendRead(w, r, actor)53+ return54+ }55+ if r.URL.Query().Get("method") == "" {56+ snapshot := remoteSnapshot{Policy: a.backend.Policy(), URL: a.backend.URL(), Slug: a.backend.Slug(), Identity: a.backend.Identity(), Actor: actor, Version: a.version, Revision: a.revision}57+ _, snapshot.Rooms = a.backend.(RoomsReader)58+ _, snapshot.Activity = a.backend.(ChatActivityReader)59+ if snapshot.Policy.Features.Grasp08 {60+ if err := a.privateReadAllowed(r, actor); err != nil {61+ snapshot.Policy = Policy{Features: Features{Grasp08: true}}62+ snapshot.URL, snapshot.Slug, snapshot.Identity = "", "private relay", ""63+ snapshot.ReadError = err.Error()64+ writeJSON(w, 200, snapshot)65+ return66+ }67+ }68+ if actor == "" || actor != snapshot.Policy.Owner {69+ p := snapshot.Policy70+ snapshot.Policy = Policy{Owner: p.Owner, Name: p.Name, Description: p.Description, Icon: p.Icon, Banner: p.Banner, Contact: p.Contact, PostingPolicy: p.PostingPolicy, PrivacyPolicy: p.PrivacyPolicy, Tags: p.Tags, LanguageTags: p.LanguageTags, RelayCountries: p.RelayCountries, Reads: p.Reads, Writes: p.Writes, JoinTerms: p.JoinTerms, DirectoryPublic: p.DirectoryPublic, Features: p.Features, Rooms: p.Rooms, Callbacks: p.Callbacks, MemberInvites: p.MemberInvites, FileLimits: p.FileLimits}71+ }72+ if views, ok := a.backend.(CustomViewSource); ok {73+ snapshot.Views = views.CustomViews()74+ }75+ if actor != "" {76+ if preferences, ok := a.backend.(AccountPreferencesReader); ok {77+ snapshot.SharePresence = preferences.SharePresence(r.Context(), actor)78+ }79+ }80+ writeJSON(w, 200, snapshot)81+ return82+ }83+ method := r.URL.Query().Get("method")84+ if !remoteReadMethods[method] {85+ http.Error(w, "unsupported client read", 400)86+ return87+ }88+ if a.backend.Policy().Features.Grasp08 {89+ if err := a.privateReadAllowed(r, actor); err != nil {90+ http.Error(w, err.Error(), 403)91+ return92+ }93+ }94+ var params []json.RawMessage95+ if raw := r.URL.Query().Get("params"); raw != "" {96+ if err := json.Unmarshal([]byte(raw), &params); err != nil {97+ http.Error(w, "invalid client query", 400)98+ return99+ }100+ }101+ result, err := a.backend.Query(r.Context(), method, params, actor)102+ if err != nil {103+ http.Error(w, err.Error(), 403)104+ return105+ }106+ writeJSON(w, 200, result)107+ })108+}109+110+var remoteReadMethods = map[string]bool{111+ "connections": true, "queryevents": true, "eventdetail": true, "listview": true,112+ "listpeople": true, "listjoinrequests": true, "listagents": true, "listcallbacks": true,113+ "listconnections": true, "listconnectiontemplates": true, "listcustomviews": true, "listjobs": true,114+ "browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true,115+ "browseapprovals": true, "browseapproval": true, "browseprofile": true, "browsewiki": true,116+ "browsewikipage": true, "browsewikimerge": true, "browsestatus": true,117+ "browserooms": true, "browseroom": true, "browsethread": true,118+ "browseissues": true, "browseissue": true, "browsepulls": true, "browsepull": true,119+ "browseagent": true, "browsejobs": true, "browsejob": true,120+ "browsesocial": true, "browsesocialthread": true, "browsedirectmessages": true,121+}122+123+// RemoteOptions connects a standalone renderer to one relay tenant. PublicURL124+// is the browser-facing URL; BackendURL is its upstream HTTP address. Use the125+// same tenant path at both addresses so cookies and signed URLs retain scope.126+type RemoteOptions struct {127+ BackendURL string128+ PublicURL string129+ // Transport optionally supplies TLS roots or other HTTP transport settings.130+ Transport http.RoundTripper131+}132+133+type remoteClient struct {134+ target, public *url.URL135+ client *http.Client136+ proxy *httputil.ReverseProxy137+}138+139+type requestBackend struct {140+ remote *remoteClient141+ snapshot remoteSnapshot142+ cookie string143+}144+145+func (b *requestBackend) Policy() Policy { return b.snapshot.Policy }146+func (b *requestBackend) URL() string { return b.snapshot.URL }147+func (b *requestBackend) Slug() string { return b.snapshot.Slug }148+func (b *requestBackend) Identity() string { return b.snapshot.Identity }149+func (b *requestBackend) CustomViews() []CustomView { return b.snapshot.Views }150+func (b *requestBackend) SharePresence(context.Context, string) bool { return b.snapshot.SharePresence }151+func (b *requestBackend) ReadAllowed(context.Context, string) error {152+ if b.snapshot.ReadError != "" {153+ return errors.New(b.snapshot.ReadError)154+ }155+ return nil156+}157+func (b *requestBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {158+ raw, err := json.Marshal(params)159+ if err != nil {160+ return nil, err161+ }162+ var result any163+ err = b.readAsActor(ctx, actor, url.Values{"method": {method}, "params": {string(raw)}}, &result)164+ return result, err165+}166+167+func (b *requestBackend) readAsActor(ctx context.Context, actor string, query url.Values, out any) error {168+ if actor != "" && actor != b.snapshot.Actor {169+ return errors.New("client read actor does not match authenticated session")170+ }171+ cookie := b.cookie172+ // Public reads must remain anonymous even on an authenticated page request.173+ if actor == "" {174+ cookie = ""175+ }176+ return b.remote.read(ctx, cookie, query, out)177+}178+179+// NewRemote serves embedded templates and assets locally and forwards relay180+// protocols, signed requests and mutations upstream. It does not open a181+// database, own relay keys or start relay workers.182+func NewRemote(options RemoteOptions) (http.Handler, error) {183+ parse := func(raw string) (*url.URL, error) {184+ u, err := url.Parse(raw)185+ if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" {186+ return nil, fmt.Errorf("tinyclient requires an absolute HTTP(S) URL without credentials, query or fragment")187+ }188+ u.Path = strings.TrimRight(u.Path, "/")189+ return u, nil190+ }191+ target, err := parse(options.BackendURL)192+ if err != nil {193+ return nil, err194+ }195+ public, err := parse(options.PublicURL)196+ if err != nil {197+ return nil, err198+ }199+ if target.Path != public.Path {200+ return nil, errors.New("tinyclient backend and public URL must use the same tenant path")201+ }202+ if public.Path != "" && pathPrefix(public.Path) != public.Path {203+ return nil, errors.New("tinyclient tenant path must be /r/<name>")204+ }205+ transport := options.Transport206+ if transport == nil {207+ transport = http.DefaultTransport208+ }209+ remote := &remoteClient{target: target, public: public, client: &http.Client{Transport: transport, Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}210+ remote.proxy = &httputil.ReverseProxy{Transport: transport, Rewrite: func(p *httputil.ProxyRequest) {211+ p.Out.URL.Scheme, p.Out.URL.Host = target.Scheme, target.Host212+ p.Out.Host = public.Host213+ }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {214+ http.Error(w, "relay backend unavailable", http.StatusBadGateway)215+ }}216+ return remote, nil217+}218+219+func (remote *remoteClient) read(ctx context.Context, cookie string, query url.Values, out any) error {220+ target := *remote.target221+ target.Path += BackendPath222+ target.RawQuery = query.Encode()223+ req, err := http.NewRequestWithContext(ctx, "GET", target.String(), nil)224+ if err != nil {225+ return err226+ }227+ req.Host = remote.public.Host228+ req.Header.Set("Cookie", cookie)229+ response, err := remote.client.Do(req)230+ if err != nil {231+ return err232+ }233+ defer response.Body.Close()234+ if response.StatusCode != 200 {235+ raw, _ := io.ReadAll(io.LimitReader(response.Body, 4096))236+ return fmt.Errorf("%s", strings.TrimSpace(string(raw)))237+ }238+ return json.NewDecoder(io.LimitReader(response.Body, 32<<20)).Decode(out)239+}240+241+func (remote *remoteClient) ServeHTTP(w http.ResponseWriter, r *http.Request) {242+ if !strings.EqualFold(r.Host, remote.public.Host) {243+ http.Error(w, "unexpected public host", http.StatusMisdirectedRequest)244+ return245+ }246+ path := r.URL.Path247+ prefix := remote.public.Path248+ if prefix == "" && strings.HasPrefix(path, "/r/") {249+ http.NotFound(w, r)250+ return251+ }252+ if prefix != "" {253+ if !strings.HasPrefix(path, prefix+"/") && path != prefix {254+ http.NotFound(w, r)255+ return256+ }257+ path = strings.TrimPrefix(path, prefix)258+ if path == "" {259+ http.Redirect(w, r, prefix+"/", http.StatusMovedPermanently)260+ return261+ }262+ }263+ if r.Method != "GET" && r.Method != "HEAD" || r.Header.Get("Authorization") != "" || strings.EqualFold(r.Header.Get("Upgrade"), "websocket") || strings.Contains(r.Header.Get("Accept"), "application/nostr+json") || !clientPath(path) {264+ remote.proxy.ServeHTTP(w, r)265+ return266+ }267+ b := &requestBackend{remote: remote, cookie: r.Header.Get("Cookie")}268+ if err := remote.read(r.Context(), b.cookie, nil, &b.snapshot); err != nil {269+ http.Error(w, "relay backend unavailable", http.StatusBadGateway)270+ return271+ }272+ b.snapshot.URL = remote.public.String()273+ var backend Backend = b274+ if b.snapshot.Rooms {275+ backend = &remoteRooms{b}276+ }277+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return b.snapshot.Actor, nil }, Version: b.snapshot.Version, Revision: b.snapshot.Revision})278+ if err != nil {279+ http.Error(w, "client renderer unavailable", 500)280+ return281+ }282+ local := r.Clone(r.Context())283+ local.URL.Path = path284+ // Preserve RequestURI so prefix-aware templates and browser requests agree.285+ if local.RequestURI == "" {286+ local.RequestURI = r.URL.RequestURI()287+ }288+ if r.Method == "HEAD" {289+ local.Method = "GET"290+ app.ServeHTTP(headWriter{w}, local)291+ return292+ }293+ app.ServeHTTP(w, local)294+}295+296+type headWriter struct{ http.ResponseWriter }297+298+func (w headWriter) Write(p []byte) (int, error) { return len(p), nil }299+300+func clientPath(path string) bool {301+ if path == "/repo/raw" || path == "/files/raw" || path == "/chat/preferences" || path == "/manage/jobs/status" || path == BackendPath {302+ return false303+ }304+ if tabForPath(path) != "home" || isBrowsePath(path) {305+ return true306+ }307+ switch path {308+ case "/", "/card.json", "/people", "/connect.json", "/signer.js", "/fixi.js", "/sw.js", "/icon.svg", "/icon-mono.svg", "/manifest.webmanifest", "/qr.svg", "/card.svg", "/card.nostr", "/webmcp.js", "/webmcp/query", "/api/join-policy", "/share", "/tools", "/manage/status", "/articles.json", "/feed", "/feed.xml", "/social", "/social.json", "/social.xml", "/icon-192.png", "/icon-512.png", "/icon-maskable-512.png", "/apple-touch-icon.png", "/badge-96.png", "/screenshot-narrow.png", "/screenshot-wide.png":309+ return true310+ }311+ for _, prefix := range []string{"/avatars/", "/scripts/", "/e/", "/a/", "/view/", "/invite/", "/social/", "/chat/"} {312+ if strings.HasPrefix(path, prefix) {313+ return true314+ }315+ }316+ return false317+}
A tinyclient/remote_actor_test.go +104 -0
@@ -0,0 +1,104 @@
1+package tinyclient2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "net/http/httptest"8+ "strings"9+ "sync/atomic"10+ "testing"11+)12+13+type actorReadBackend struct{ actorFeedBackend }14+15+func (b *actorReadBackend) ListRooms(_ context.Context, actor, _ string, _ int) (RoomList, error) {16+ return RoomList{Rooms: []RoomSummary{{ID: "public-article", Name: actor}}}, nil17+}18+func (b *actorReadBackend) ReadRoom(_ context.Context, actor, _, _ string, _ int) (RoomPage, error) {19+ return RoomPage{Room: RoomSummary{ID: "public-article", Name: actor}}, nil20+}21+func (b *actorReadBackend) ReadThread(_ context.Context, actor, _, _, _ string, _ int) (RoomPage, error) {22+ return RoomPage{Room: RoomSummary{ID: "public-article", Name: actor}}, nil23+}24+func (b *actorReadBackend) ReadChatActivity(_ context.Context, actor, _, _ string) (any, error) {25+ return map[string]string{"id": "public-article", "actor": actor}, nil26+}27+28+func TestRemoteReadsPreserveRequestedActor(t *testing.T) {29+ owner, other := strings.Repeat("a", 64), strings.Repeat("b", 64)30+ backend := &actorReadBackend{actorFeedBackend{fakeBackend{policy: DefaultPolicy(owner)}}}31+ app, err := New(backend, Options{Actor: func(r *http.Request) (string, error) {32+ if cookie, err := r.Cookie("tiny_session"); err == nil && cookie.Value == "valid-session" {33+ return owner, nil34+ }35+ return "", nil36+ }})37+ if err != nil {38+ t.Fatal(err)39+ }40+ var reads atomic.Int3241+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {42+ reads.Add(1)43+ app.BackendHandler().ServeHTTP(w, r)44+ }))45+ defer upstream.Close()46+ handler, err := NewRemote(RemoteOptions{BackendURL: upstream.URL, PublicURL: backend.URL()})47+ if err != nil {48+ t.Fatal(err)49+ }50+ for _, tc := range []struct {51+ name, cookie, actor string52+ denied bool53+ }{54+ {name: "authenticated", cookie: "tiny_session=valid-session", actor: owner},55+ {name: "anonymous read with session", cookie: "tiny_session=valid-session"},56+ {name: "mismatched actor", cookie: "tiny_session=valid-session", actor: other, denied: true},57+ {name: "anonymous"},58+ {name: "forged actor without session", actor: owner, denied: true},59+ {name: "forged actor with invalid session", cookie: "tiny_session=invalid", actor: owner, denied: true},60+ } {61+ t.Run(tc.name, func(t *testing.T) {62+ b := &requestBackend{remote: handler.(*remoteClient), cookie: tc.cookie}63+ if err := b.remote.read(context.Background(), b.cookie, nil, &b.snapshot); err != nil {64+ t.Fatal(err)65+ }66+ for name, read := range map[string]func() (any, error){67+ "query": func() (any, error) { return b.Query(context.Background(), "queryevents", nil, tc.actor) },68+ "rooms": func() (any, error) { return (&remoteRooms{b}).ListRooms(context.Background(), tc.actor, "", 10) },69+ "room": func() (any, error) {70+ return (&remoteRooms{b}).ReadRoom(context.Background(), tc.actor, "build", "", 10)71+ },72+ "thread": func() (any, error) {73+ return (&remoteRooms{b}).ReadThread(context.Background(), tc.actor, "build", "root", "", 10)74+ },75+ "activity": func() (any, error) { return b.ReadChatActivity(context.Background(), tc.actor, "build", "root") },76+ } {77+ t.Run(name, func(t *testing.T) {78+ before := reads.Load()79+ result, err := read()80+ if tc.denied {81+ if err == nil || reads.Load() != before {82+ t.Fatalf("mismatched actor must fail before forwarding: err=%v upstream reads=%d", err, reads.Load()-before)83+ }84+ return85+ }86+ if err != nil {87+ t.Fatal(err)88+ }89+ raw, err := json.Marshal(result)90+ if err != nil {91+ t.Fatal(err)92+ }93+ private := owner94+ if name == "query" {95+ private = "session-only-article"96+ }97+ if !strings.Contains(string(raw), "public-article") || strings.Contains(string(raw), private) != (tc.actor == owner) {98+ t.Fatalf("read used wrong actor: %s", raw)99+ }100+ })101+ }102+ })103+ }104+}
A tinyclient/remote_auth_test.go +72 -0
@@ -0,0 +1,72 @@
1+package tinyclient2+3+import (4+ "net/http"5+ "net/http/httptest"6+ "strings"7+ "testing"8+)9+10+func TestRemoteRejectsWrongPublicHostBeforeForwarding(t *testing.T) {11+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("forwarded request for a foreign public host") }))12+ defer upstream.Close()13+ remote, err := NewRemote(RemoteOptions{BackendURL: upstream.URL, PublicURL: "http://relay.example"})14+ if err != nil {15+ t.Fatal(err)16+ }17+ request := httptest.NewRequest("POST", "http://attacker.example/session", nil)18+ request.Header.Set("Authorization", "Nostr exact-proof")19+ response := httptest.NewRecorder()20+ remote.ServeHTTP(response, request)21+ if response.Code != http.StatusMisdirectedRequest {22+ t.Fatalf("foreign host accepted: %d", response.Code)23+ }24+}25+26+func TestBackendContractNeverTrustsCallerPubkeysOrWrites(t *testing.T) {27+ owner := strings.Repeat("a", 64)28+ backend := &fakeBackend{policy: DefaultPolicy(owner)}29+ app, err := New(backend, Options{Actor: func(r *http.Request) (string, error) {30+ cookie, err := r.Cookie("tiny_session")31+ if err == nil && cookie.Value == "valid-session" {32+ return owner, nil33+ }34+ return "", nil35+ }})36+ if err != nil {37+ t.Fatal(err)38+ }39+ for _, authenticated := range []bool{false, true} {40+ req := httptest.NewRequest("GET", BackendPath+"?method=browserepos&actor="+owner, nil)41+ req.Header.Set("X-Actor", owner)42+ if authenticated {43+ req.AddCookie(&http.Cookie{Name: "tiny_session", Value: "valid-session"})44+ }45+ response := httptest.NewRecorder()46+ app.BackendHandler().ServeHTTP(response, req)47+ want := "browserepos:"48+ if authenticated {49+ want += owner50+ }51+ if response.Code != 200 || backend.call != want {52+ t.Fatalf("actor trusted incorrectly: %d call=%s", response.Code, backend.call)53+ }54+ }55+ for _, tc := range []struct {56+ method, path string57+ status int58+ }{59+ {"GET", BackendPath + "?method=setpolicy", 400},60+ {"POST", BackendPath + "?method=browserepos", 405},61+ {"GET", BackendPath + "?method=browserepos&params=invalid", 400},62+ } {63+ backend.call = ""64+ req := httptest.NewRequest(tc.method, tc.path, nil)65+ req.AddCookie(&http.Cookie{Name: "tiny_session", Value: "valid-session"})66+ response := httptest.NewRecorder()67+ app.BackendHandler().ServeHTTP(response, req)68+ if response.Code != tc.status || backend.call != "" {69+ t.Fatalf("unsafe query: %d call=%s", response.Code, backend.call)70+ }71+ }72+}
A tinyclient/remote_browser_test.go +88 -0
@@ -0,0 +1,88 @@
1+package tinyclient2+3+import (4+ "context"5+ "net/http"6+ "net/http/httptest"7+ "strings"8+ "testing"9+ "time"10+11+ "github.com/coder/websocket"12+)13+14+func TestRemoteKeepsTenantPrefixInPagesAssetsAndRedirects(t *testing.T) {15+ backend := &fakeBackend{policy: DefaultPolicy(strings.Repeat("a", 64))}16+ app, err := New(backend, Options{})17+ if err != nil {18+ t.Fatal(err)19+ }20+ upstream := httptest.NewServer(http.StripPrefix("/r/team", app))21+ defer upstream.Close()22+ remote, err := NewRemote(RemoteOptions{BackendURL: upstream.URL + "/r/team", PublicURL: "http://relay.example/r/team"})23+ if err != nil {24+ t.Fatal(err)25+ }26+ for _, path := range []string{"/r/team/", "/r/team/account", "/r/team/manifest.webmanifest", "/r/team/scripts/bridge.js", "/r/team/rooms"} {27+ got := httptest.NewRecorder()28+ remote.ServeHTTP(got, httptest.NewRequest("GET", "http://relay.example"+path, nil))29+ if path == "/r/team/rooms" {30+ if got.Code != 301 || got.Header().Get("Location") != "/r/team/chat" {31+ t.Fatalf("redirect: %d %v", got.Code, got.Header())32+ }33+ continue34+ }35+ if got.Code != 200 {36+ t.Fatalf("prefix %s: %d", path, got.Code)37+ }38+ if path == "/r/team/" && !strings.Contains(got.Body.String(), `src="/r/team/scripts/bridge.js?`) {39+ t.Fatal("page dropped tenant asset prefix")40+ }41+ if path == "/r/team/manifest.webmanifest" && !strings.Contains(got.Body.String(), `"start_url": "/r/team/"`) {42+ t.Fatal("manifest dropped tenant prefix")43+ }44+ }45+ response := httptest.NewRecorder()46+ remote.ServeHTTP(response, httptest.NewRequest("GET", "http://relay.example/r/other/", nil))47+ if response.Code != 404 {48+ t.Fatalf("escaped tenant prefix: %d", response.Code)49+ }50+}51+52+func TestRemoteProxiesWebSocketUpgrades(t *testing.T) {53+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {54+ conn, err := websocket.Accept(w, r, nil)55+ if err != nil {56+ return57+ }58+ defer conn.CloseNow()59+ kind, body, err := conn.Read(r.Context())60+ if err == nil {61+ _ = conn.Write(r.Context(), kind, body)62+ }63+ }))64+ defer upstream.Close()65+ frontend := httptest.NewUnstartedServer(nil)66+ public := "http://" + frontend.Listener.Addr().String()67+ handler, err := NewRemote(RemoteOptions{BackendURL: upstream.URL, PublicURL: public})68+ if err != nil {69+ t.Fatal(err)70+ }71+ frontend.Config.Handler = handler72+ frontend.Start()73+ defer frontend.Close()74+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)75+ defer cancel()76+ conn, _, err := websocket.Dial(ctx, frontend.URL, &websocket.DialOptions{HTTPHeader: http.Header{"Origin": {public}}})77+ if err != nil {78+ t.Fatal(err)79+ }80+ defer conn.CloseNow()81+ if err := conn.Write(ctx, websocket.MessageText, []byte(`["REQ","test",{}]`)); err != nil {82+ t.Fatal(err)83+ }84+ _, body, err := conn.Read(ctx)85+ if err != nil || string(body) != `["REQ","test",{}]` {86+ t.Fatalf("websocket relay: %s %v", body, err)87+ }88+}
A tinyclient/remote_feed_test.go +65 -0
@@ -0,0 +1,65 @@
1+package tinyclient2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "net/http/httptest"8+ "strings"9+ "testing"10+)11+12+type actorFeedBackend struct{ fakeBackend }13+14+func (b *actorFeedBackend) Query(_ context.Context, method string, _ []json.RawMessage, actor string) (any, error) {15+ if method != "queryevents" {16+ return nil, nil17+ }18+ items := []any{map[string]any{"id": "public-article", "content": "Public article", "created_at": 1}}19+ if actor != "" {20+ items = append(items, map[string]any{"id": "session-only-article", "content": "Session-only article", "created_at": 1})21+ }22+ return items, nil23+}24+25+func TestRemotePublicFeedsRemainAnonymousWithSession(t *testing.T) {26+ backend := &actorFeedBackend{fakeBackend{policy: DefaultPolicy(strings.Repeat("a", 64))}}27+ app, err := New(backend, Options{Actor: func(r *http.Request) (string, error) {28+ if cookie, err := r.Cookie("tiny_session"); err == nil && cookie.Value == "valid-session" {29+ return backend.policy.Owner, nil30+ }31+ return "", nil32+ }})33+ if err != nil {34+ t.Fatal(err)35+ }36+ upstream := httptest.NewServer(app)37+ defer upstream.Close()38+ remote, err := NewRemote(RemoteOptions{BackendURL: upstream.URL, PublicURL: backend.URL()})39+ if err != nil {40+ t.Fatal(err)41+ }42+ for _, path := range []string{"/articles.json", "/feed.xml", "/feed"} {43+ t.Run(path, func(t *testing.T) {44+ anonymous := httptest.NewRecorder()45+ app.ServeHTTP(anonymous, httptest.NewRequest("GET", backend.URL()+path, nil))46+ if anonymous.Code != 200 || !strings.Contains(anonymous.Body.String(), "public-article") || strings.Contains(anonymous.Body.String(), "session-only-article") {47+ t.Fatalf("invalid anonymous feed: %d %s", anonymous.Code, anonymous.Body.String())48+ }49+ for _, authenticated := range []bool{false, true} {50+ request := httptest.NewRequest("GET", backend.URL()+path, nil)51+ if authenticated {52+ request.AddCookie(&http.Cookie{Name: "tiny_session", Value: "valid-session"})53+ }54+ integrated, standalone := httptest.NewRecorder(), httptest.NewRecorder()55+ app.ServeHTTP(integrated, request.Clone(request.Context()))56+ remote.ServeHTTP(standalone, request)57+ for name, got := range map[string]*httptest.ResponseRecorder{"integrated": integrated, "standalone": standalone} {58+ if got.Code != anonymous.Code || got.Body.String() != anonymous.Body.String() {59+ t.Errorf("%s authenticated=%v feed differs from anonymous: %d %s", name, authenticated, got.Code, got.Body.String())60+ }61+ }62+ }63+ })64+ }65+}
A tinyclient/remote_proxy_test.go +60 -0
@@ -0,0 +1,60 @@
1+package tinyclient2+3+import (4+ "io"5+ "net/http"6+ "net/http/httptest"7+ "strings"8+ "testing"9+)10+11+func TestRemoteProxiesRelayProtocolsWithoutChangingSignedRequest(t *testing.T) {12+ const public = "http://client.example"13+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {14+ if r.URL.Path == BackendPath {15+ http.Error(w, "unexpected renderer request", 500)16+ return17+ }18+ body, _ := io.ReadAll(r.Body)19+ if r.Host != "client.example" || r.Header.Get("Origin") != public || r.Header.Get("Cookie") != "tiny_session=token" {20+ t.Errorf("request identity changed: host=%s headers=%v", r.Host, r.Header)21+ }22+ w.Header().Set("Set-Cookie", "tiny_session=updated; Path=/; HttpOnly; SameSite=Strict")23+ w.Header().Set("Content-Type", "text/event-stream")24+ w.WriteHeader(202)25+ _, _ = io.WriteString(w, r.Method+" "+r.URL.RequestURI()+" "+r.Header.Get("Authorization")+" "+string(body))26+ }))27+ defer upstream.Close()28+ remote, err := NewRemote(RemoteOptions{BackendURL: upstream.URL, PublicURL: public})29+ if err != nil {30+ t.Fatal(err)31+ }32+ for _, tc := range []struct{ method, path, auth, body string }{33+ {"POST", "/session", "Nostr exact-proof", "signed body"},34+ {"POST", "/manage/rpc", "Nostr exact-proof", "signed body"},35+ {"POST", "/events", "Nostr exact-proof", "signed body"},36+ {"POST", "/query", "Nostr exact-proof", "signed body"},37+ {"POST", "/mcp", "Nostr exact-proof", "signed body"},38+ {"GET", "/rooms/build/stream?cursor=a%2Fb", "", ""},39+ {"GET", "/views/plot/hash.svg", "", ""},40+ {"GET", "/repo/raw?ref=main&path=a%2Fb", "", ""},41+ {"GET", "/files/raw?hash=123", "", ""},42+ {"GET", "/npub1example/repo.git/info/refs?service=git-upload-pack", "", ""},43+ {"GET", "/healthz", "", ""},44+ {"GET", "/.well-known/nostr.json", "", ""},45+ {"GET", "/" + strings.Repeat("a", 64) + ".png", "", ""},46+ } {47+ t.Run(tc.method+tc.path, func(t *testing.T) {48+ request := httptest.NewRequest(tc.method, public+tc.path, strings.NewReader(tc.body))49+ request.Header.Set("Authorization", tc.auth)50+ request.Header.Set("Origin", public)51+ request.Header.Set("Cookie", "tiny_session=token")52+ response := httptest.NewRecorder()53+ remote.ServeHTTP(response, request)54+ want := tc.method + " " + tc.path + " " + tc.auth + " " + tc.body55+ if response.Code != 202 || response.Body.String() != want || !strings.Contains(response.Header().Get("Set-Cookie"), "tiny_session=updated") {56+ t.Fatalf("proxy changed request: status=%d body=%s", response.Code, response.Body.String())57+ }58+ })59+ }60+}
A tinyclient/remote_rooms.go +78 -0
@@ -0,0 +1,78 @@
1+package tinyclient2+3+import (4+ "context"5+ "errors"6+ "net/http"7+ "net/url"8+ "strconv"9+)10+11+func (a *App) typedBackendRead(w http.ResponseWriter, r *http.Request, actor string) {12+ if a.backend.Policy().Features.Grasp08 {13+ if err := a.privateReadAllowed(r, actor); err != nil {14+ http.Error(w, err.Error(), 403)15+ return16+ }17+ }18+ q := r.URL.Query()19+ var result any20+ var err error21+ switch q.Get("op") {22+ case "rooms", "room", "thread":23+ reader, ok := a.backend.(RoomsReader)24+ if !ok {25+ http.Error(w, "typed room reads unavailable", 501)26+ return27+ }28+ limit, _ := strconv.Atoi(q.Get("limit"))29+ switch q.Get("op") {30+ case "rooms":31+ result, err = reader.ListRooms(r.Context(), actor, q.Get("cursor"), limit)32+ case "room":33+ result, err = reader.ReadRoom(r.Context(), actor, q.Get("room"), q.Get("cursor"), limit)34+ case "thread":35+ result, err = reader.ReadThread(r.Context(), actor, q.Get("room"), q.Get("root"), q.Get("cursor"), limit)36+ }37+ case "activity":38+ reader, ok := a.backend.(ChatActivityReader)39+ if !ok {40+ http.Error(w, "chat activity unavailable", 501)41+ return42+ }43+ result, err = reader.ReadChatActivity(r.Context(), actor, q.Get("room"), q.Get("root"))44+ default:45+ err = errors.New("unsupported client read")46+ }47+ if err != nil {48+ http.Error(w, err.Error(), 403)49+ return50+ }51+ writeJSON(w, 200, result)52+}53+54+type remoteRooms struct{ *requestBackend }55+56+func (b *remoteRooms) ListRooms(ctx context.Context, actor, cursor string, limit int) (RoomList, error) {57+ var result RoomList58+ err := b.readAsActor(ctx, actor, url.Values{"op": {"rooms"}, "cursor": {cursor}, "limit": {strconv.Itoa(limit)}}, &result)59+ return result, err60+}61+func (b *remoteRooms) ReadRoom(ctx context.Context, actor, room, cursor string, limit int) (RoomPage, error) {62+ var result RoomPage63+ err := b.readAsActor(ctx, actor, url.Values{"op": {"room"}, "room": {room}, "cursor": {cursor}, "limit": {strconv.Itoa(limit)}}, &result)64+ return result, err65+}66+func (b *remoteRooms) ReadThread(ctx context.Context, actor, room, root, cursor string, limit int) (RoomPage, error) {67+ var result RoomPage68+ err := b.readAsActor(ctx, actor, url.Values{"op": {"thread"}, "room": {room}, "root": {root}, "cursor": {cursor}, "limit": {strconv.Itoa(limit)}}, &result)69+ return result, err70+}71+func (b *requestBackend) ReadChatActivity(ctx context.Context, actor, room, root string) (any, error) {72+ if !b.snapshot.Activity {73+ return nil, nil74+ }75+ var result any76+ err := b.readAsActor(ctx, actor, url.Values{"op": {"activity"}, "room": {room}, "root": {root}}, &result)77+ return result, err78+}
A tinyclient/remote_rooms_test.go +40 -0
@@ -0,0 +1,40 @@
1+package tinyclient2+3+import (4+ "context"5+ "encoding/json"6+ "net/http/httptest"7+ "strings"8+ "testing"9+)10+11+type remoteTypedBackend struct{ typedRoomsBackend }12+13+func (remoteTypedBackend) Query(context.Context, string, []json.RawMessage, string) (any, error) {14+ return nil, nil15+}16+func (remoteTypedBackend) ReadChatActivity(context.Context, string, string, string) (any, error) {17+ return map[string]any{"jobs": []any{map[string]any{"id": strings.Repeat("b", 64), "content": "A visible agent task", "kind": 5000, "created_at": 1}}}, nil18+}19+20+func TestRemotePreservesTypedRoomAndActivityRendering(t *testing.T) {21+ backend := remoteTypedBackend{typedRoomsBackend{fakeBackend: fakeBackend{policy: DefaultPolicy(strings.Repeat("a", 64))}}}22+ app, err := New(&backend, Options{})23+ if err != nil {24+ t.Fatal(err)25+ }26+ upstream := httptest.NewServer(app)27+ defer upstream.Close()28+ remote, err := NewRemote(RemoteOptions{BackendURL: upstream.URL, PublicURL: backend.URL()})29+ if err != nil {30+ t.Fatal(err)31+ }32+ for _, path := range []string{"/chat", "/rooms/build", "/rooms/build/thread/" + strings.Repeat("b", 64), "/chat/activity?room=build"} {33+ want, got := httptest.NewRecorder(), httptest.NewRecorder()34+ app.ServeHTTP(want, httptest.NewRequest("GET", path, nil))35+ remote.ServeHTTP(got, httptest.NewRequest("GET", backend.URL()+path, nil))36+ if want.Code != got.Code || want.Body.String() != got.Body.String() {37+ t.Fatalf("typed parity %s: got status %d want %d\n%s", path, got.Code, want.Code, got.Body.String())38+ }39+ }40+}
A tinyclient/remote_scope_test.go +86 -0
@@ -0,0 +1,86 @@
1+package tinyclient2+3+import (4+ "net/http"5+ "net/http/httptest"6+ "strings"7+ "sync/atomic"8+ "testing"9+)10+11+func TestRemoteRejectsCrossTenantRoutesBeforeRenderingOrProxying(t *testing.T) {12+ app, err := New(&fakeBackend{policy: DefaultPolicy(strings.Repeat("a", 64))}, Options{})13+ if err != nil {14+ t.Fatal(err)15+ }16+ var calls atomic.Int3217+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {18+ calls.Add(1)19+ if strings.HasSuffix(r.URL.Path, BackendPath) {20+ app.BackendHandler().ServeHTTP(w, r)21+ return22+ }23+ w.WriteHeader(http.StatusAccepted)24+ }))25+ defer upstream.Close()26+ for _, mount := range []string{"", "/r/team"} {27+ t.Run("mount="+mount, func(t *testing.T) {28+ remote, err := NewRemote(RemoteOptions{BackendURL: upstream.URL + mount, PublicURL: "http://relay.example" + mount})29+ if err != nil {30+ t.Fatal(err)31+ }32+ for _, tc := range []struct {33+ name, method, path, header, value string34+ }{35+ {"page", "GET", "/r/other/account", "", ""},36+ {"head", "HEAD", "/r/other/account", "", ""},37+ {"feed", "GET", "/r/other/articles.json", "", ""},38+ {"asset", "GET", "/r/other/scripts/bridge.js", "", ""},39+ {"tenant root", "GET", "/r/other", "", ""},40+ {"tenant home", "GET", "/r/other/", "", ""},41+ {"events", "POST", "/r/other/events", "", ""},42+ {"session", "POST", "/r/other/session", "", ""},43+ {"delete", "DELETE", "/r/other/events", "", ""},44+ {"options", "OPTIONS", "/r/other/events", "", ""},45+ {"signed page", "GET", "/r/other/account", "Authorization", "Nostr exact-proof"},46+ {"websocket", "GET", "/r/other/", "Upgrade", "websocket"},47+ {"nostr info", "GET", "/r/other/", "Accept", "application/nostr+json"},48+ {"stream", "GET", "/r/other/rooms/build/stream", "", ""},49+ {"adapter", "GET", "/r/other" + BackendPath, "", ""},50+ {"encoded tenant prefix", "GET", "/%72/other/account", "", ""},51+ } {52+ t.Run(tc.name, func(t *testing.T) {53+ req := httptest.NewRequest(tc.method, "http://relay.example"+tc.path, nil)54+ req.AddCookie(&http.Cookie{Name: "tiny_session", Value: "root-session"})55+ if tc.header != "" {56+ req.Header.Set(tc.header, tc.value)57+ }58+ if tc.header == "Upgrade" {59+ req.Header.Set("Connection", "Upgrade")60+ }61+ before := calls.Load()62+ got := httptest.NewRecorder()63+ remote.ServeHTTP(got, req)64+ if got.Code != http.StatusNotFound || calls.Load() != before {65+ t.Fatalf("cross-tenant request reached renderer or proxy: status=%d upstream calls=%d", got.Code, calls.Load()-before)66+ }67+ })68+ }69+ // The scope guard must not disable the configured tenant's routes.70+ for _, tc := range []struct {71+ method, path string72+ status int73+ }{74+ {"GET", "/account", 200},75+ {"POST", "/events", 202},76+ {"GET", "/rooms/build/stream", 202},77+ } {78+ got := httptest.NewRecorder()79+ remote.ServeHTTP(got, httptest.NewRequest(tc.method, "http://relay.example"+mount+tc.path, nil))80+ if got.Code != tc.status {81+ t.Errorf("valid tenant %s %s: status=%d want=%d", tc.method, tc.path, got.Code, tc.status)82+ }83+ }84+ })85+ }86+}
A tinyclient/remote_test.go +88 -0
@@ -0,0 +1,88 @@
1+package tinyclient2+3+import (4+ "net/http"5+ "net/http/httptest"6+ "strings"7+ "testing"8+)9+10+func TestBackendSnapshotHidesOwnerSettings(t *testing.T) {11+ p := DefaultPolicy(strings.Repeat("a", 64))12+ p.Name = "Visible relay"13+ p.PrivatePeers = []string{"https://secret-peer.example"}14+ p.PushCallbacks = []string{"https://secret-hook.example/token"}15+ p.BlockedWords = []string{"secret-moderation-rule"}16+ app, err := New(&fakeBackend{policy: p}, Options{Actor: func(r *http.Request) (string, error) {17+ c, err := r.Cookie("tiny_session")18+ if err == nil && c.Value == "valid-owner" {19+ return p.Owner, nil20+ }21+ return "", nil22+ }})23+ if err != nil {24+ t.Fatal(err)25+ }26+ for _, owner := range []bool{false, true} {27+ req := httptest.NewRequest("GET", BackendPath, nil)28+ if owner {29+ req.AddCookie(&http.Cookie{Name: "tiny_session", Value: "valid-owner"})30+ }31+ response := httptest.NewRecorder()32+ app.BackendHandler().ServeHTTP(response, req)33+ body := response.Body.String()34+ if response.Code != 200 || !strings.Contains(body, "Visible relay") {35+ t.Fatalf("snapshot: %d %s", response.Code, body)36+ }37+ for _, secret := range []string{"secret-peer", "secret-hook", "secret-moderation-rule"} {38+ if strings.Contains(body, secret) != owner {39+ t.Fatalf("owner=%v snapshot leaks or loses %s: %s", owner, secret, body)40+ }41+ }42+ }43+}44+45+func TestIntegratedAppMountsBackendContract(t *testing.T) {46+ app, err := New(&fakeBackend{policy: DefaultPolicy(strings.Repeat("a", 64))}, Options{})47+ if err != nil {48+ t.Fatal(err)49+ }50+ upstream := httptest.NewServer(app)51+ defer upstream.Close()52+ remote, err := NewRemote(RemoteOptions{BackendURL: upstream.URL, PublicURL: "http://relay.example"})53+ if err != nil {54+ t.Fatal(err)55+ }56+ got := httptest.NewRecorder()57+ remote.ServeHTTP(got, httptest.NewRequest("GET", "http://relay.example/", nil))58+ if got.Code != 200 || !strings.Contains(got.Body.String(), "<html") {59+ t.Fatalf("integrated backend: %d %s", got.Code, got.Body.String())60+ }61+}62+63+func TestRemoteRendersHomeLocally(t *testing.T) {64+ backend := &fakeBackend{policy: DefaultPolicy(strings.Repeat("a", 64))}65+ integrated, err := New(backend, Options{})66+ if err != nil {67+ t.Fatal(err)68+ }69+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {70+ if r.URL.Path != BackendPath {71+ t.Errorf("remote asked upstream to render %s", r.URL.Path)72+ http.Error(w, "no renderer here", 418)73+ return74+ }75+ integrated.BackendHandler().ServeHTTP(w, r)76+ }))77+ defer upstream.Close()78+ remote, err := NewRemote(RemoteOptions{BackendURL: upstream.URL, PublicURL: backend.URL()})79+ if err != nil {80+ t.Fatal(err)81+ }82+ want, got := httptest.NewRecorder(), httptest.NewRecorder()83+ integrated.ServeHTTP(want, httptest.NewRequest("GET", "/", nil))84+ remote.ServeHTTP(got, httptest.NewRequest("GET", backend.URL()+"/", nil))85+ if got.Code != want.Code || got.Body.String() != want.Body.String() {86+ t.Fatalf("local rendering differs: status %d want %d\n%s", got.Code, want.Code, got.Body.String())87+ }88+}
R internal/webui/repo.html → tinyclient/repo.html +0 -0

No content changes.

R internal/webui/repo_navigation.go → tinyclient/repo_navigation.go +1 -1
R internal/webui/repo_navigation_test.go → tinyclient/repo_navigation_test.go +1 -1
R internal/webui/review.go → tinyclient/review.go +1 -1
R internal/webui/review_test.go → tinyclient/review_test.go +1 -1
R internal/webui/room_attachments.go → tinyclient/room_attachments.go +1 -1
R internal/webui/room_attachments_integration_test.go → tinyclient/room_attachments_integration_test.go +1 -1
R internal/webui/room_attachments_test.go → tinyclient/room_attachments_test.go +1 -1
R internal/webui/room_parity.json → tinyclient/room_parity.json +0 -0

No content changes.

R internal/webui/room_parity_test.go → tinyclient/room_parity_test.go +1 -1
R internal/webui/rooms.go → tinyclient/rooms.go +1 -1
R internal/webui/rooms.html → tinyclient/rooms.html +0 -0

No content changes.

R internal/webui/rooms.js → tinyclient/rooms.js +0 -0

No content changes.

R internal/webui/rooms_test.go → tinyclient/rooms_test.go +1 -1
R internal/webui/route_groups.go → tinyclient/route_groups.go +1 -1
R internal/webui/screenshot-narrow.png → tinyclient/screenshot-narrow.png +0 -0

No content changes.

R internal/webui/screenshot-wide.png → tinyclient/screenshot-wide.png +0 -0

No content changes.

R internal/webui/shell_test.go → tinyclient/shell_test.go +1 -1
R internal/webui/signer.js → tinyclient/signer.js +0 -0

No content changes.

R internal/webui/signer.js.license → tinyclient/signer.js.license +0 -0

No content changes.

R internal/webui/signin.go → tinyclient/signin.go +1 -1
R internal/webui/signin_test.go → tinyclient/signin_test.go +1 -1
A tinyclient/site_metadata.go +53 -0
A tinyclient/site_metadata_test.go +37 -0
@@ -0,0 +1,37 @@
1+package tinyclient2+3+import (4+ "os/exec"5+ "strings"6+ "testing"7+8+ "github.com/FelineStateMachine/tinyrelay/internal/event"9+ "github.com/FelineStateMachine/tinyrelay/internal/sites"10+)11+12+func TestSitePresentationMatchesRelayManifests(t *testing.T) {13+ for _, kind := range []int{15128, 35128, 5128, 1} {14+ for _, key := range []string{strings.Repeat("0", 63) + "1", strings.Repeat("f", 64), "invalid"} {15+ e := event.Event{Kind: kind, PubKey: key, ID: key, Tags: [][]string{{"d", "demo"}, {"path", "/index.html", "hash"}, {"path"}, {"other"}}}16+ if got, want := siteLabel(e), sites.SiteLabel(e); got != want {17+ t.Errorf("label kind=%d key=%s: %s want %s", kind, key, got, want)18+ }19+ if got, want := sitePathCount(e), len(sites.SitePaths(e)); got != want {20+ t.Errorf("path count=%d want %d", got, want)21+ }22+ }23+ }24+}25+26+func TestFrontendBuildDoesNotLinkRelayStorage(t *testing.T) {27+ command := exec.Command("go", "list", "-deps", "github.com/FelineStateMachine/tinyrelay/cmd/tinyclient")28+ out, err := command.CombinedOutput()29+ if err != nil {30+ t.Fatalf("list frontend dependencies: %v %s", err, out)31+ }32+ for _, dependency := range strings.Fields(string(out)) {33+ if strings.Contains(dependency, "/internal/storage") || strings.Contains(dependency, "/internal/daemon") || strings.Contains(dependency, "modernc.org/sqlite") || strings.HasSuffix(dependency, "/tinygit") {34+ t.Errorf("frontend links backend implementation %s", dependency)35+ }36+ }37+}
R internal/webui/social.html → tinyclient/social.html +0 -0

No content changes.

R internal/webui/social_content.go → tinyclient/social_content.go +1 -1
R internal/webui/social_content_test.go → tinyclient/social_content_test.go +1 -1
R internal/webui/social_mentions.go → tinyclient/social_mentions.go +1 -1
R internal/webui/social_mentions_test.go → tinyclient/social_mentions_test.go +1 -1
R internal/webui/social_pages.go → tinyclient/social_pages.go +1 -1
R internal/webui/social_pages_test.go → tinyclient/social_pages_test.go +1 -1
R internal/webui/social_reference_routes.go → tinyclient/social_reference_routes.go +1 -1
R internal/webui/source_benchmark_test.go → tinyclient/source_benchmark_test.go +1 -1
R internal/webui/source_render.go → tinyclient/source_render.go +1 -1
R internal/webui/source_render_test.go → tinyclient/source_render_test.go +1 -1
R internal/webui/style.css → tinyclient/style.css +0 -0

No content changes.

R internal/webui/sw.js → tinyclient/sw.js +0 -0

No content changes.

R internal/webui/templates.go → tinyclient/templates.go +1 -1
R internal/webui/tiny.js → tinyclient/tiny.js +0 -0

No content changes.

R internal/webui/webmcp.go → tinyclient/webmcp.go +1 -1
R internal/webui/webmcp.js → tinyclient/webmcp.js +0 -0

No content changes.

R internal/webui/webmcp_test.go → tinyclient/webmcp_test.go +1 -1
R internal/webui/webui.go → tinyclient/webui.go +8 -4
@@ -1,4 +1,4 @@
1-package webui1+package tinyclient22 33 import (44 "bytes"
@@ -37,7 +37,7 @@ var fixiJS []byte
3737 // and identity describe the relay rendered into each page.3838 type Backend interface {3939 Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error)40- Policy() policy.Policy40+ Policy() Policy4141 URL() string4242 Slug() string4343 Identity() string
@@ -52,7 +52,7 @@ type AccountPreferencesReader interface {
5252 // CustomViewSource is the optional backend summary of the custom views the5353 // renderers show in place of fenced code blocks: names and languages only.5454 type CustomViewSource interface {55- CustomViews() []views.View55+ CustomViews() []CustomView5656 }5757 5858 // ActorResolver returns the authenticated public key associated with request.
@@ -123,7 +123,7 @@ type PageData struct {
123123 124124 func New(backend Backend, options Options) (*App, error) {125125 if backend == nil {126- return nil, fmt.Errorf("webui backend is required")126+ return nil, fmt.Errorf("tinyclient backend is required")127127 }128128 qr := options.QR129129 if qr == nil {
@@ -181,6 +181,10 @@ func defaultQR(text string) ([]byte, error) {
181181 }182182 183183 func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {184+ if request.URL.Path == BackendPath {185+ a.BackendHandler().ServeHTTP(writer, request)186+ return187+ }184188 if strings.HasPrefix(request.URL.Path, "/avatars/") {185189 serveAvatar(writer, request)186190 return
R internal/webui/webui_test.go → tinyclient/webui_test.go +1 -1
R internal/webui/wiki.go → tinyclient/wiki.go +1 -1
R internal/webui/wiki.html → tinyclient/wiki.html +0 -0

No content changes.

R internal/webui/wiki_test.go → tinyclient/wiki_test.go +1 -1
A tinygit/README.md +66 -0
@@ -0,0 +1,66 @@
1+# tinygit2+3+`tinygit` owns signed repository metadata, Git object storage, smart HTTP, repair and synchronization contracts. The integrated `tiny` host uses this same engine. The standalone executable serves public repositories without starting the relay daemon, frontend or replication workers.4+5+## Build and run6+7+From the repository root:8+9+```sh10+make build-tinygit11+./bin/tinygit -listen 127.0.0.1:8082 \12+ -data ./tinygit-data \13+ -owner <64-character-lowercase-hex-public-key> \14+ -public-url https://git.example15+```16+17+Go and native Git are required to build and test the service. The running service needs native Git and a POSIX shell for smart HTTP and receive hooks. Use a dedicated data directory, not a live tinyrelay tenant's database or Git directory. Put TLS and appropriate request limits in front of the listener for public hosting. `GET /healthz` reports readiness.18+19+The data directory holds `events.db` and bare repositories under `git/`. Existing journal, hook and ref-file names retain their `tinyrelay` spelling for compatibility.20+21+## Publish and push22+23+1. Sign a kind 30617 repository announcement with the configured owner's key. Include its identifier in `d`, and advertise its clone URL as `https://git.example/<owner-npub>/<identifier>.git`. Use `maintainers` to name additional authorized repository signers.24+2. POST the signed event as JSON to `/events`.25+3. Sign a kind 30618 state event with the owner or an announced maintainer. Include the same `d`, `HEAD` with `ref: refs/heads/main` and `refs/heads/main` with the complete Git commit ID. Include every ref the state should retain.26+4. POST the signed state to `/events`. Missing objects leave the state pending rather than making unavailable refs visible.27+5. Push the announced objects using Git smart HTTP and a NIP-98 repository-root proof. The existing `tiny git-token` command can mint that proof:28+29+```sh30+git -c "$(tiny git-token --repo https://git.example/<owner-npub>/<identifier>.git \31+ --key-env TINY_AGENT_KEY --format git)" \32+ push https://git.example/<owner-npub>/<identifier>.git main33+```34+35+Set `TINY_AGENT_KEY` in the calling process to the authorized signer's hex key or nsec. Do not put secret keys in repository configuration. The proof covers the exact repository root and uses the literal GET method for Git transport authorization.36+37+A successful `/events` response contains the event `id` and a boolean `pending`. The endpoint limits bodies to 1 MiB and accepts only signed repository announcement and state events. Repeating an accepted event retries its Git transition safely. A push cannot update a ref unless the signed state authorizes its exact object ID. After the required objects arrive, pending refs are promoted and available to clone and fetch.38+39+This JSON endpoint is the standalone service's HTTP binding, not a Nostr WebSocket relay. No relay subscription service is started.40+41+## Standalone profile42+43+The standalone service admits public repositories owned by the configured key and state signed by their authorized maintainers. It rejects private repository announcements. It does not provide agent grants, collaboration pages, relay event delivery or automatic peer synchronization.44+45+Those features remain available through the integrated tinyrelay host and its existing adapters. A standalone build is not a claim of full integrated feature parity or support for every GRASP capability.46+47+## Embed48+49+Import `github.com/FelineStateMachine/tinyrelay/tinygit`.50+51+- `OpenServer` owns the standalone service's store and returns an HTTP handler. Stop serving requests before calling `Close`.52+- `New` constructs the lower-level engine using `Config`. The caller owns its store, policy, access decisions, synchronization transports and lifecycle.53+- `OpenStore`, `Event`, `Policy` and `Store` make the public API usable from another Go module.54+- `Validate`, `CommitAfterStore` and the promotion callback separate admission, durable event persistence and visible Git state. Integration code must preserve that ordering and its transaction guarantees.55+56+The engine still shares internal event, authentication, policy and SQLite implementations with tinyrelay. It is a public package in the root Go module, not an independent dependency-free Go module. See [Module boundaries and protocol contracts](../docs/module-contracts.md) for the next dependency seams.57+58+## Verify59+60+```sh61+go test -race ./tinygit ./cmd/tinygit62+go vet ./tinygit ./cmd/tinygit63+go test ./internal/daemon -run 'Git|Repository|PrivatePeer|PrivateGit|Collaboration'64+```65+66+The command acceptance test builds and starts the binary, publishes signed metadata, pushes and clones real objects, rejects unsigned ref changes and checks persistence after restart. An external-module test checks that consumers can use the public API.
R internal/gitrelay/admission.go → tinygit/admission.go +1 -1
A tinygit/api.go +36 -0
R internal/gitrelay/archive.go → tinygit/archive.go +1 -1
R internal/gitrelay/archive_test.go → tinygit/archive_test.go +1 -1
R internal/gitrelay/backup_import.go → tinygit/backup_import.go +1 -1
R internal/gitrelay/browse.go → tinygit/browse.go +1 -1
R internal/gitrelay/browse_test.go → tinygit/browse_test.go +1 -1
R internal/gitrelay/capabilities_test.go → tinygit/capabilities_test.go +1 -1
R internal/gitrelay/cgi_test.go → tinygit/cgi_test.go +1 -1
R internal/gitrelay/collaboration_diff.go → tinygit/collaboration_diff.go +1 -1
R internal/gitrelay/collaboration_diff_test.go → tinygit/collaboration_diff_test.go +1 -1
R internal/gitrelay/doc.go → tinygit/doc.go +19 -2
@@ -1,4 +1,4 @@
1-// Package gitrelay coordinates signed Nostr repository events with native1+// Package tinygit coordinates signed Nostr repository events with native22 // Git object storage and smart HTTP. Signed events establish repository refs,33 // metadata and maintainer authority.44 //
@@ -21,4 +21,21 @@
2121 // is limited to Config.PrivatePeers and is signed by HTTPAuth. GitSync and2222 // EventSync supply synchronization transports; the daemon supplies the relay2323 // client and WebSocket lifecycle.24-package gitrelay24+//25+// For a standalone public host, OpenServer owns event persistence and exposes26+// POST /events plus Git smart HTTP. It requires an owner public key, admits27+// only that owner's public repositories and their announced maintainers, and28+// rejects private announcements. Publish signed kind 30617 metadata first,29+// then signed kind 30618 refs before pushing their objects. Receive-pack30+// promotes pending refs; unsigned ref updates are rejected by the same native31+// hook used by the embedded engine. GET /healthz reports process readiness.32+//33+// New is the lower-level integration boundary. Supply an absolute Config.Root34+// and an OpenStore result, or share the host's existing Store. Event, Policy35+// and Store aliases make this API callable outside the tinyrelay module.36+// The package still depends on tinyrelay's internal event, policy, auth and37+// SQLite storage implementations and schema. It is a public package in the38+// root Go module, not a separate dependency-free module. Neither constructor39+// starts the daemon, relay protocol, UI or replication workers. Native Git40+// and a POSIX shell are required for smart HTTP and receive hooks.41+package tinygit
A tinygit/external_test.go +63 -0
@@ -0,0 +1,63 @@
1+package tinygit_test2+3+import (4+ "fmt"5+ "os"6+ "os/exec"7+ "path/filepath"8+ "testing"9+)10+11+func TestExternalModuleCanUsePublicAPI(t *testing.T) {12+ if testing.Short() {13+ t.Skip("compiles an independent consumer module")14+ }15+ root, err := filepath.Abs("..")16+ if err != nil {17+ t.Fatal(err)18+ }19+ consumer := t.TempDir()20+ mod := fmt.Sprintf("module example.invalid/tinygit-consumer\n\ngo 1.27.1\n\nrequire github.com/FelineStateMachine/tinyrelay v0.0.0\nreplace github.com/FelineStateMachine/tinyrelay => %s\n", filepath.ToSlash(root))21+ if err := os.WriteFile(filepath.Join(consumer, "go.mod"), []byte(mod), 0600); err != nil {22+ t.Fatal(err)23+ }24+ source := `package consumer25+import (26+ "context"27+ "net/http"28+ "path/filepath"29+ "strings"30+ "testing"31+ "github.com/FelineStateMachine/tinyrelay/tinygit"32+)33+func TestUse(t *testing.T) {34+ ctx := context.Background()35+ dir := t.TempDir()36+ var store *tinygit.Store37+ var err error38+ store, err = tinygit.OpenStore(ctx, filepath.Join(dir, "events.db"))39+ if err != nil { t.Fatal(err) }40+ defer store.Close()41+ p := tinygit.DefaultPolicy("")42+ g, err := tinygit.New(tinygit.Config{Store: store, Root: filepath.Join(dir, "git"), Policy: func() tinygit.Policy { return p }, Authorize: func(context.Context, tinygit.Event, tinygit.Repository) error { return nil }})43+ if err != nil { t.Fatal(err) }44+ var _ http.Handler = g45+ if err := g.Publish(ctx, tinygit.Event{}); err == nil { t.Fatal("invalid event accepted") }46+ s, err := tinygit.OpenServer(ctx, tinygit.ServerConfig{DataDir: t.TempDir(), Owner: strings.Repeat("a", 64)})47+ if err != nil { t.Fatal(err) }48+ defer s.Close()49+ var _ http.Handler = s50+}51+`52+ if err := os.WriteFile(filepath.Join(consumer, "consumer_test.go"), []byte(source), 0600); err != nil {53+ t.Fatal(err)54+ }55+ cmd := exec.Command("go", "test", "-mod=mod", ".")56+ cmd.Dir = consumer57+ // Dependencies are already in the build cache. Never use this boundary test58+ // as a reason to make a network request or change the root module files.59+ cmd.Env = append(os.Environ(), "GOWORK=off", "GOPROXY=off", "GOSUMDB=off")60+ if out, err := cmd.CombinedOutput(); err != nil {61+ t.Fatalf("external consumer: %v\n%s", err, out)62+ }63+}
R internal/gitrelay/gitrelay.go → tinygit/gitrelay.go +4 -4
R internal/gitrelay/gitrelay_test.go → tinygit/gitrelay_test.go +1 -1
R internal/gitrelay/http.go → tinygit/http.go +1 -1
R internal/gitrelay/journal.go → tinygit/journal.go +1 -1
R internal/gitrelay/maintainers_test.go → tinygit/maintainers_test.go +1 -1
R internal/gitrelay/pr_budget_proxy.go → tinygit/pr_budget_proxy.go +1 -1
R internal/gitrelay/pr_budget_proxy_test.go → tinygit/pr_budget_proxy_test.go +1 -1
R internal/gitrelay/pr_repair.go → tinygit/pr_repair.go +1 -1
R internal/gitrelay/pr_repair_test.go → tinygit/pr_repair_test.go +1 -1
R internal/gitrelay/private_repair_acceptance_test.go → tinygit/private_repair_acceptance_test.go +1 -1
R internal/gitrelay/private_transport.go → tinygit/private_transport.go +1 -1
R internal/gitrelay/private_transport_test.go → tinygit/private_transport_test.go +1 -1
A tinygit/public_test.go +43 -0
R internal/gitrelay/recovery_refs.go → tinygit/recovery_refs.go +1 -1
R internal/gitrelay/repair.go → tinygit/repair.go +1 -1
R internal/gitrelay/repository.go → tinygit/repository.go +1 -1
R internal/gitrelay/scheduler.go → tinygit/scheduler.go +1 -1
A tinygit/server.go +148 -0
@@ -0,0 +1,148 @@
1+package tinygit2+3+import (4+ "context"5+ "encoding/hex"6+ "encoding/json"7+ "errors"8+ "io"9+ "net/http"10+ "net/url"11+ "path/filepath"12+ "strings"13+ "sync"14+ "time"15+16+ "github.com/FelineStateMachine/tinyrelay/internal/event"17+ "github.com/FelineStateMachine/tinyrelay/internal/storage"18+)19+20+// ServerConfig configures the standalone public Git host. Use a dedicated21+// DataDir, not a running tinyrelay tenant's database or Git directory.22+type ServerConfig struct {23+ DataDir string24+ Owner string25+ PublicURL string26+}27+28+// Server owns a Git engine and durable SQLite metadata, without a relay daemon,29+// WebSocket service, UI or replication workers. POST /events accepts signed30+// repository metadata. Other paths are handled by Git smart HTTP.31+type Server struct {32+ git *GitRelay33+ store *Store34+ publishMu sync.Mutex35+}36+37+var errHostAdmission = errors.New("blocked: standalone host accepts only its owner's public repositories and their maintainers")38+39+// OpenServer opens the standalone host. Stop all HTTP requests before Close.40+func OpenServer(ctx context.Context, cfg ServerConfig) (*Server, error) {41+ owner, err := hex.DecodeString(cfg.Owner)42+ if err != nil || len(owner) != 32 || cfg.Owner != strings.ToLower(cfg.Owner) {43+ return nil, errors.New("tinygit: owner must be a 64-character lowercase hex public key")44+ }45+ if cfg.DataDir == "" {46+ return nil, errors.New("tinygit: data directory is required")47+ }48+ if cfg.PublicURL != "" {49+ u, err := url.Parse(cfg.PublicURL)50+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {51+ return nil, errors.New("tinygit: public URL must be an HTTP(S) URL without credentials, query or fragment")52+ }53+ }54+ cfg.DataDir, err = filepath.Abs(cfg.DataDir)55+ if err != nil {56+ return nil, err57+ }58+ store, err := OpenStore(ctx, filepath.Join(cfg.DataDir, "events.db"))59+ if err != nil {60+ return nil, err61+ }62+ g, err := New(Config{63+ Store: store, Root: filepath.Join(cfg.DataDir, "git"), PublicURL: cfg.PublicURL,64+ Authorize: func(_ context.Context, e Event, repo Repository) error {65+ if repo.Owner != cfg.Owner || repo.Private || (e.PubKey != repo.Owner && !contains(repo.Maintainers, e.PubKey)) {66+ return errHostAdmission67+ }68+ return nil69+ },70+ })71+ if err != nil {72+ return nil, errors.Join(err, store.Close())73+ }74+ return &Server{git: g, store: store}, nil75+}76+77+// Close releases metadata storage. The caller must first stop serving requests.78+func (s *Server) Close() error { return s.store.Close() }79+80+// Publish validates and persists a signed announcement (30617) or ref state81+// (30618). Repeated events retry the Git transition after a partial failure.82+func (s *Server) Publish(ctx context.Context, e Event) error {83+ s.publishMu.Lock()84+ defer s.publishMu.Unlock()85+ repo, err := s.git.Validate(ctx, e)86+ if err != nil {87+ return err88+ }89+ if _, err := s.store.Save(ctx, e, storage.SaveOptions{Now: time.Now().Unix()}); err != nil && !errors.Is(err, storage.ErrDuplicate) {90+ return err91+ }92+ return s.git.CommitAfterStore(ctx, e, repo)93+}94+95+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {96+ if r.URL.Path == "/healthz" {97+ if r.Method != http.MethodGet && r.Method != http.MethodHead {98+ w.Header().Set("Allow", "GET, HEAD")99+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)100+ return101+ }102+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")103+ w.WriteHeader(http.StatusOK)104+ if r.Method != http.MethodHead {105+ _, _ = io.WriteString(w, "ok\n")106+ }107+ return108+ }109+ if r.URL.Path != "/events" {110+ s.git.ServeHTTP(w, r)111+ return112+ }113+ if r.Method != http.MethodPost {114+ w.Header().Set("Allow", "POST")115+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)116+ return117+ }118+ r.Body = http.MaxBytesReader(w, r.Body, 1<<20)119+ defer r.Body.Close()120+ body, err := io.ReadAll(r.Body)121+ if err != nil {122+ var tooLarge *http.MaxBytesError123+ if errors.As(err, &tooLarge) {124+ http.Error(w, "event exceeds 1 MiB", http.StatusRequestEntityTooLarge)125+ } else {126+ http.Error(w, "cannot read event", http.StatusBadRequest)127+ }128+ return129+ }130+ e, err := event.Parse(body)131+ if err != nil {132+ http.Error(w, err.Error(), http.StatusBadRequest)133+ return134+ }135+ if err := s.Publish(r.Context(), e); err != nil {136+ status := http.StatusBadRequest137+ if errors.Is(err, errHostAdmission) {138+ status = http.StatusForbidden139+ }140+ http.Error(w, err.Error(), status)141+ return142+ }143+ w.Header().Set("Content-Type", "application/json")144+ _ = json.NewEncoder(w).Encode(struct {145+ ID string `json:"id"`146+ Pending bool `json:"pending"`147+ }{e.ID, s.git.IsPending(e.ID)})148+}
A tinygit/server_limits_test.go +61 -0
@@ -0,0 +1,61 @@
1+package tinygit_test2+3+import (4+ "context"5+ "net/http"6+ "net/http/httptest"7+ "path/filepath"8+ "strings"9+ "testing"10+11+ "github.com/FelineStateMachine/tinyrelay/tinygit"12+)13+14+func TestStandaloneServerRequiresExplicitValidConfiguration(t *testing.T) {15+ t.Chdir(t.TempDir())16+ owner := signedAnnouncement(t, strings.Repeat("0", 63)+"1").PubKey17+ for _, tc := range []struct {18+ name string19+ cfg tinygit.ServerConfig20+ }{21+ {"missing owner", tinygit.ServerConfig{DataDir: t.TempDir()}},22+ {"malformed owner", tinygit.ServerConfig{DataDir: t.TempDir(), Owner: "not-a-key"}},23+ {"uppercase owner", tinygit.ServerConfig{DataDir: t.TempDir(), Owner: strings.ToUpper(owner)}},24+ {"missing data", tinygit.ServerConfig{Owner: owner}},25+ {"invalid URL", tinygit.ServerConfig{DataDir: t.TempDir(), Owner: owner, PublicURL: "file:///tmp/git"}},26+ } {27+ t.Run(tc.name, func(t *testing.T) {28+ s, err := tinygit.OpenServer(context.Background(), tc.cfg)29+ if err == nil {30+ s.Close()31+ t.Fatal("invalid configuration accepted")32+ }33+ })34+ }35+}36+37+func TestStandaloneServerBoundsEventHTTP(t *testing.T) {38+ owner := signedAnnouncement(t, strings.Repeat("0", 63)+"1").PubKey39+ s, err := tinygit.OpenServer(context.Background(), tinygit.ServerConfig{DataDir: filepath.Join(t.TempDir(), "data"), Owner: owner})40+ if err != nil {41+ t.Fatal(err)42+ }43+ defer s.Close()44+ for _, tc := range []struct {45+ name, method, path, body string46+ status int47+ }{48+ {"health", http.MethodGet, "/healthz", "", http.StatusOK},49+ {"method", http.MethodGet, "/events", "", http.StatusMethodNotAllowed},50+ {"body limit", http.MethodPost, "/events", strings.Repeat(" ", (1<<20)+1), http.StatusRequestEntityTooLarge},51+ {"unsigned", http.MethodPost, "/events", `{}`, http.StatusBadRequest},52+ } {53+ t.Run(tc.name, func(t *testing.T) {54+ w := httptest.NewRecorder()55+ s.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)))56+ if w.Code != tc.status {57+ t.Fatalf("HTTP = %d, want %d: %s", w.Code, tc.status, w.Body.String())58+ }59+ })60+ }61+}
A tinygit/server_test.go +91 -0
@@ -0,0 +1,91 @@
1+package tinygit_test2+3+import (4+ "bytes"5+ "context"6+ "encoding/json"7+ "net/http"8+ "net/http/httptest"9+ "strings"10+ "testing"11+12+ "github.com/FelineStateMachine/tinyrelay/internal/event"13+ "github.com/FelineStateMachine/tinyrelay/tinygit"14+)15+16+func signedAnnouncement(t *testing.T, secret string, tags ...[]string) tinygit.Event {17+ t.Helper()18+ e := tinygit.Event{Kind: 30617, CreatedAt: 100, Tags: append([][]string{{"d", "demo"}}, tags...)}19+ if err := event.Sign(&e, secret); err != nil {20+ t.Fatal(err)21+ }22+ return e23+}24+25+func postEvent(t *testing.T, s http.Handler, e tinygit.Event) *httptest.ResponseRecorder {26+ t.Helper()27+ body, err := json.Marshal(e)28+ if err != nil {29+ t.Fatal(err)30+ }31+ w := httptest.NewRecorder()32+ s.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/events", bytes.NewReader(body)))33+ return w34+}35+36+func TestStandaloneServerPersistsSignedAnnouncement(t *testing.T) {37+ e := signedAnnouncement(t, strings.Repeat("0", 63)+"1")38+ cfg := tinygit.ServerConfig{DataDir: t.TempDir(), Owner: e.PubKey}39+ s, err := tinygit.OpenServer(context.Background(), cfg)40+ if err != nil {41+ t.Fatal(err)42+ }43+ w := postEvent(t, s, e)44+ if w.Code != http.StatusOK {45+ t.Fatalf("publish = %d %s", w.Code, w.Body.String())46+ }47+ // Retrying a signed event is safe even if the caller lost the first response.48+ if w := postEvent(t, s, e); w.Code != http.StatusOK {49+ t.Fatalf("retry = %d %s", w.Code, w.Body.String())50+ }51+ if err := s.Close(); err != nil {52+ t.Fatal(err)53+ }54+ s, err = tinygit.OpenServer(context.Background(), cfg)55+ if err != nil {56+ t.Fatal(err)57+ }58+ defer s.Close()59+ // Bech32 public key for the test's secret scalar 1.60+ npub := "npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"61+ w = httptest.NewRecorder()62+ s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/"+npub+"/demo.git/info/refs?service=git-upload-pack", nil))63+ if w.Code != http.StatusOK || !strings.Contains(w.Header().Get("Content-Type"), "git-upload-pack-advertisement") {64+ t.Fatalf("reopened Git discovery = %d %s", w.Code, w.Body.String())65+ }66+}67+68+func TestStandaloneServerRestrictsRepositoryAdmission(t *testing.T) {69+ ownerEvent := signedAnnouncement(t, strings.Repeat("0", 63)+"1")70+ s, err := tinygit.OpenServer(context.Background(), tinygit.ServerConfig{DataDir: t.TempDir(), Owner: ownerEvent.PubKey})71+ if err != nil {72+ t.Fatal(err)73+ }74+ defer s.Close()75+ for _, tc := range []struct {76+ name string77+ e tinygit.Event78+ }{79+ {"foreign owner", signedAnnouncement(t, strings.Repeat("0", 63)+"2")},80+ {"private repository", signedAnnouncement(t, strings.Repeat("0", 63)+"1", []string{"private", "true"})},81+ } {82+ t.Run(tc.name, func(t *testing.T) {83+ if w := postEvent(t, s, tc.e); w.Code != http.StatusForbidden {84+ t.Fatalf("admission = %d %s", w.Code, w.Body.String())85+ }86+ })87+ }88+ if w := postEvent(t, s, ownerEvent); w.Code != http.StatusOK {89+ t.Fatalf("owner's public announcement = %d %s", w.Code, w.Body.String())90+ }91+}
R internal/gitrelay/storage_inventory.go → tinygit/storage_inventory.go +1 -1