Add the thin tinyrelay executable and tiny relay delegation, build targets, lifecycle tests and an external consumer check. Document standalone capabilities and the intentional public, internal and command package boundaries.
- commit
- 38542ac80a3fd2937c52da111948ce2bbabbacbc
- parent
- 8b59378664d2ea4084c0d57e0f0de789b9b3854f
- author
- Dami <dami.etoile@gmail.com>
| M | Makefile | 7 | +- |
| M | README.md | 20 | +++- |
| M | cmd/ | 7 | +- |
| M | cmd/ | 5 | +- |
| M | cmd/ | 9 | +- |
| A | cmd/ | 26 | +++++ |
| M | docs/ | 13 | +- |
| M | docs/ | 2 | +- |
| M | docs/ | 11 | +- |
| M | docs/ | 3 | + |
| A | internal/ | 172 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 100 | +++++++++++++++++ |
| M | tinyclient/ | 2 | +- |
| M | tinygit/ | 2 | +- |
| A | tinyrelay/ | 42 | +++++++ |
| A | tinyrelay/ | 105 | ++++++++++++++++++ |
@@ -1,12 +1,15 @@
1-1+22 33 TEST_PACKAGES ?= ./...44 5-build: build-tiny build-tinygit build-tinyclient5+build: build-tiny build-tinyrelay build-tinygit build-tinyclient66 77 build-tiny:88 go build -mod=readonly -o bin/tiny ./cmd/tiny99 10+build-tinyrelay:11+ go build -mod=readonly -o bin/tinyrelay ./cmd/tinyrelay12+1013 build-tinygit:1114 go build -mod=readonly -o bin/tinygit ./cmd/tinygit1215 @@ -21,15 +21,16 @@ flowchart LR
2121 2222 ## Components2323 24-The repository contains three executables in one Go module:24+The repository contains four executables in one Go module:2525 2626 | Component | Build target | Role |2727 | --- | --- | --- |28-| `tinyrelay`, distributed as `tiny` | `make build-tiny` | Integrated relay host with Git hosting and the browser interface. |28+| [`tinyrelay`](tinyrelay/README.md) | `make build-tinyrelay` | Standalone Nostr relay for arbitrary clients. |29+| `tiny` | `make build-tiny` | Combined host with the relay, Git hosting and browser interface. |2930 | [`tinygit`](tinygit/README.md) | `make build-tinygit` | Standalone public Git host using signed repository metadata and refs. |30-| [`tinyclient`](tinyclient/README.md) | `make build-tinyclient` | Standalone frontend connected to a compatible tinyrelay backend. |31+| [`tinyclient`](tinyclient/README.md) | `make build-tinyclient` | Standalone frontend connected to the combined `tiny` backend. |3132 32-`make build` writes all three binaries to `bin/`. Go builds embed the checked-in browser assets and do not require Node.js. The integrated `tiny` command, wire formats and existing tenant data remain compatible. Standalone components have narrower capabilities: tinygit does not start a relay or private-repository service, and tinyclient needs a backend and preserves its public origin and tenant path.33+`make build` writes all four binaries to `bin/`. Go builds embed the checked-in browser assets and do not require Node.js. The integrated `tiny` command, wire formats and existing tenant data remain compatible. Standalone components have narrower capabilities: tinygit does not start a relay or private-repository service, tinyclient needs a backend and tinyrelay serves the Nostr protocol without the web UI or Git services.3334 3435 Reusable `protocol/nostr` and `protocol/auth` packages supply wire values and proof verification without importing host services. Feature semantics and access policy remain separate. See [Architecture](docs/architecture.md), [Module boundaries and protocol contracts](docs/module-contracts.md) and the [extension catalog](docs/extensions/README.md) for ownership, supported profiles and remaining seams.3536 @@ -42,6 +43,16 @@ go build -o tiny ./cmd/tiny
4243 ./tiny serve --data-dir ./data --listen :74474344 ```4445 46+Run only the standalone relay:47+48+```sh49+go build -o bin/tinyrelay ./cmd/tinyrelay50+./bin/tinyrelay --data-dir ./relay-data --listen :7447 \51+ --public-url wss://relay.example52+```53+54+The standalone relay accepts ordinary Nostr clients over WebSocket. `tiny relay` runs the same standalone server through the combined CLI.55+4556 For a public deployment, set the relay URL and provision the owner key:4657 4758 ```sh@@ -54,6 +65,7 @@ The command is `tiny`. Useful commands include:
5465 5566 ```text5667 tiny serve [--data-dir PATH] [--listen :7447]68+tiny relay [options]5769 tiny tenant create --name NAME --owner PUBKEY [--template default] [--source wss://...]5870 tiny tenant list|enable|disable|host [options]5971 tiny git-token --repo URL [--key-env TINY_AGENT_KEY] [--format header|value|git]@@ -3,7 +3,8 @@
33 // main creates a context cancelled by interrupt or SIGTERM and reports the44 // error returned by run. serve binds the HTTP listeners, starts daemon.App,55 // runs optional peer monitoring and joins server shutdown before closing the6-// application. Tenant subcommands open an App for catalog operations. The7-// git-token subcommand produces signed Git authorization material from an8-// operator-selected key source.6+// application. Tenant subcommands open an App for catalog operations. relay7+// delegates to the standalone tinyrelay runner. The git-token subcommand8+// produces signed Git authorization material from an operator-selected key9+// source.910 package main@@ -18,6 +18,7 @@ import (
1818 "time"1919 2020 "github.com/FelineStateMachine/tinyrelay/internal/daemon"21+ "github.com/FelineStateMachine/tinyrelay/internal/relaycmd"2122 "github.com/FelineStateMachine/tinyrelay/internal/templates"2223 )2324 @@ -37,7 +38,7 @@ func main() {
3738 // to out. Returning errors to main keeps process exit at the command boundary.3839 func run(ctx context.Context, args []string, out io.Writer) error {3940 if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {40- _, err := fmt.Fprintln(out, "tiny serve [--data-dir PATH] [--listen :7447]\ntiny agent --relay URL --room ROOM --command PROGRAM [--protocol acp|command]\ntiny tenant create --name NAME --owner PUBKEY [--template default]\ntiny tenant list|enable|disable|host [options]\ntiny git-token --repo URL [--key-env TINY_AGENT_KEY] [--format header|value|git]\ntiny templates\ntiny version")41+ _, err := fmt.Fprintln(out, "tiny serve [--data-dir PATH] [--listen :7447]\ntiny relay [options]\ntiny agent --relay URL --room ROOM --command PROGRAM [--protocol acp|command]\ntiny tenant create --name NAME --owner PUBKEY [--template default]\ntiny tenant list|enable|disable|host [options]\ntiny git-token --repo URL [--key-env TINY_AGENT_KEY] [--format header|value|git]\ntiny templates\ntiny version")4142 return err4243 }4344 switch args[0] {@@ -48,6 +49,8 @@ func run(ctx context.Context, args []string, out io.Writer) error {
4849 return json.NewEncoder(out).Encode(templates.Names())4950 case "serve":5051 return serve(ctx, args[1:], out)52+ case "relay":53+ return relaycmd.Run(ctx, args[1:], out)5154 case "agent":5255 return agent(ctx, args[1:], out)5356 case "tenant":@@ -14,7 +14,7 @@ func TestCommandNameAndPermanentTenant(t *testing.T) {
1414 if err := run(context.Background(), []string{"help"}, &out); err != nil {1515 t.Fatal(err)1616 }17- if !strings.Contains(out.String(), "tiny serve") || strings.Contains(out.String(), "tinyrelay serve") {17+ if !strings.Contains(out.String(), "tiny serve") || !strings.Contains(out.String(), "tiny relay") {1818 t.Fatal(out.String())1919 }2020 args := []string{"tenant", "create", "--data-dir", t.TempDir(), "--name", "main", "--owner", strings.Repeat("a", 64)}@@ -26,6 +26,13 @@ func TestCommandNameAndPermanentTenant(t *testing.T) {
2626 }2727 }2828 29+func TestRelayCommandDelegatesFlagValidation(t *testing.T) {30+ err := run(context.Background(), []string{"relay", "--public-url", "ftp://relay.example"}, &bytes.Buffer{})31+ if err == nil {32+ t.Fatal("accepted invalid standalone relay URL")33+ }34+}35+2936 func TestServerShutdownDoesNotHideAnotherFailure(t *testing.T) {3037 results := make(chan error, 1)3138 results <- http.ErrServerClosed@@ -0,0 +1,26 @@
1+// Command tinyrelay serves a standalone Nostr relay.2+package main3+4+import (5+ "context"6+ "fmt"7+ "io"8+ "os"9+ "os/signal"10+ "syscall"11+12+ "github.com/FelineStateMachine/tinyrelay/internal/relaycmd"13+)14+15+func main() {16+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)17+ defer stop()18+ if err := run(ctx, os.Args[1:], os.Stdout); err != nil {19+ fmt.Fprintln(os.Stderr, "tinyrelay:", err)20+ os.Exit(1)21+ }22+}23+24+func run(ctx context.Context, args []string, output io.Writer) error {25+ return relaycmd.Run(ctx, args, output)26+}@@ -1,18 +1,18 @@
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.3+`tiny` assembles the combined relay host. The public `tinyrelay` package provides the standalone and embeddable Nostr relay, `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.44 55 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.66 77 ## Find your starting point88 9-[`cmd/tiny`](../cmd/tiny/doc.go) owns command dispatch, listeners and process shutdown. [`daemon`](../internal/daemon/doc.go) describes tenant assembly and event flow. Package overviews and interface, constructor and transaction comments describe the contracts at their declarations. These comments are also available through `go doc` and editor symbol help.9+[`cmd/tiny`](../cmd/tiny/doc.go) owns combined command dispatch, while [`cmd/tinyrelay`](../cmd/tinyrelay/main.go) starts the standalone relay through the shared runner. [`daemon`](../internal/daemon/doc.go) describes tenant assembly and event flow. Package overviews and interface, constructor and transaction comments describe the contracts at their declarations. These comments are also available through `go doc` and editor symbol help.1010 1111 ## Package layout1212 13-Executable entry points live in `cmd/tiny`, `cmd/tinygit` and `cmd/tinyclient`. Host services and storage implementations live under `internal/`, where Go prevents outside projects from importing them.13+Executable entry points live in `cmd/tiny`, `cmd/tinyrelay`, `cmd/tinygit` and `cmd/tinyclient`. Host services and storage implementations live under `internal/`, where Go prevents outside projects from importing them.1414 15-`tinygit`, `tinyclient` and `protocol/` are public so other Go projects can embed them. Separate executables alone do not require public packages. These packages expose the contracts an embedder needs; host policy, database access and tenant assembly remain internal. All packages belong to the root Go module.15+`tinyrelay`, `tinygit`, `tinyclient` and `protocol/` are public so other Go projects can embed them. Separate executables alone do not require public packages. These packages expose the contracts an embedder needs; host policy, database access and tenant assembly remain internal. All packages belong to the root Go module.1616 1717 ## Responsibilities1818 @@ -20,6 +20,7 @@ Executable entry points live in `cmd/tiny`, `cmd/tinygit` and `cmd/tinyclient`.
2020 | --- | --- |2121 | [`protocol/nostr`](../protocol/nostr/doc.go) | Public event and filter values, canonical encoding, signatures and generic wire checks. No host access policy. |2222 | [`protocol/auth`](../protocol/auth/doc.go) | Public request-proof verification and replay state. No membership or permission decisions. |23+| [`tinyrelay`](../tinyrelay/relay.go) | Standalone Nostr transport, durable event storage and generic relay metadata and policy. No tenant, Git or browser features. |2324 | [`event`](../internal/event/doc.go), [`auth`](../internal/auth/doc.go) | Internal compatibility aliases and feature helpers; private-kind search filtering remains host behavior. |2425 | [`policy`](../internal/policy/doc.go), [`gates`](../internal/gates/doc.go) | Host policy, event admission and visibility checks. |2526 | [`storage`](../internal/storage/doc.go), [`work`](../internal/work/doc.go) | Event persistence, queries, transaction hooks and durable queue claims. |@@ -28,7 +29,7 @@ Executable entry points live in `cmd/tiny`, `cmd/tinygit` and `cmd/tinyclient`.
2829 | [`blob`](../internal/blob/doc.go), [`sites`](../internal/sites/doc.go) | Content-addressed files, upload rules and sites backed by signed manifests. |2930 | [`tinygit`](../tinygit/doc.go) | Repository admission, object storage, synchronization, repair and Git HTTP endpoints. |3031 | [`replication`](../internal/replication/doc.go), [`syncprotocol`](../internal/syncprotocol/doc.go) | Relay synchronization plans and transports, count sketches and reconciliation sessions. |31-| [`relay`](../internal/relay/doc.go), [`mcp`](../internal/mcp/doc.go), [`tinyclient`](../tinyclient/doc.go) | WebSocket sessions, MCP requests, HTML pages and browser interactions. |32+| [`relay`](../internal/relay/doc.go), [`relaycmd`](../internal/relaycmd), [`mcp`](../internal/mcp/doc.go), [`tinyclient`](../tinyclient/doc.go) | WebSocket sessions, standalone command lifecycle, MCP requests, HTML pages and browser interactions. |3233 | [`agentrunner`](../internal/agentrunner) | The optional agent process: room mentions, durable queue recovery, ACP or command execution, and permission prompts. |3334 | [`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. |3435 | [`seedmark`](../internal/seedmark) | Deterministic decorative SVG avatars for people and rooms without a profile picture. |@@ -42,7 +43,7 @@ Executable entry points live in `cmd/tiny`, `cmd/tinygit` and `cmd/tinyclient`.
4243 4344 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.4445 45-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`.46+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. `tinyrelay.Backend` is the embedding contract for a host that owns persistence, while `tinyrelay.OpenServer` supplies a durable standalone implementation. `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`.4647 4748 ## Event acceptance4849 @@ -5,7 +5,7 @@
55 | Identifier | `tinyclient-read-adapter` |66 | Status | Experimental |77 | 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. |8+| Owner | `tinyclient` defines the presentation contract; the combined `tiny` backend owns data and authorization. |99 | Transport | HTTP GET, JSON success bodies, plain-text error bodies. |1010 | Endpoint | `/api/tinyclient`, under the selected tenant prefix when present. |1111 @@ -1,8 +1,8 @@
11 # Module boundaries and protocol contracts22 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.3+The project is a monorepo. `tinyrelay` is the standalone and embeddable Nostr relay, `tiny` is the combined 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.44 5-The Git and frontend components, public `protocol/nostr` and `protocol/auth` packages, and `tinygit.ParseMetadata` establish the current boundaries. Blob, room and narrower community/records services below remain proposed follow-up work.5+The relay, Git and frontend components, public `protocol/nostr` and `protocol/auth` packages, and `tinygit.ParseMetadata` establish the current boundaries. Blob, room and narrower community/records services below remain proposed follow-up work.66 77 ## Keep protocol rules separate from access policy88 @@ -24,13 +24,14 @@ The client can validate a draft for feedback and verify a signer's result, but s
2424 | --- | --- | --- |2525 | Public `protocol/nostr` | Event and filter types, canonical serialization, event IDs, signatures and common encoding rules. | Tenant configuration, membership, SQL or feature-specific tags. |2626 | Public `protocol/auth` | Verification for HTTP, WebSocket and service-specific proof profiles; authenticated actor and proof scope. | Deciding what that actor may do. |27+| Public `tinyrelay` | Generic Nostr transport, standalone persistence, relay metadata, authentication and connection limits. | Tenant ACLs, Git, browser pages or signer services. |2728 | Host access policy | Membership, agent grants, visibility, quotas and admission decisions. | Reinterpreting signed fields or weakening feature protocol validation. |2829 | `tinygit` | Git repository metadata, signed refs, object storage, smart HTTP, repair and Git-specific protocol rules. | Rooms, browser sessions or general relay management. |2930 | `tinyclient` | Server-rendered pages, browser interactions, draft construction and signer-result verification. | Authoritative permissions, repository mutation or event persistence. |3031 | 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. |3132 | Site service | Signed site manifests, routing and site lifecycle over a blob-store contract. | Owning another copy of blob storage or upload authorization. |3233 | 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+| Host assembly | Tenant lifecycle, transaction coordination, worker ownership and adapter wiring for the combined `tiny` host. | A second implementation of feature rules or the standalone relay's generic protocol. |3435 3536 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.3637 @@ -55,6 +56,8 @@ Use small, consumer-owned interfaces for access decisions, persistence and optio
5556 5657 `tinygit` owns its small policy and persistence contracts. Its default SQLite implementation lives in `internal/gitstore`, and tenant assembly adapts the existing host store and policy. The host retains transaction ownership; the engine does not close a supplied store. External-consumer tests exercise the public store interface, while dependency tests guard the protocol packages. `tinygit.ParseMetadata` validates metadata shape without asserting signature validity or authority; its admission entry points combine these checks before consulting host policy.5758 59+The standalone `tinyrelay` profile owns one durable data directory and exposes generic Nostr WebSocket and NIP-11 behavior. It stores opaque events across kinds, has no tenant ACLs, and uses `--owner` for relay metadata only. `--auth-required` applies NIP-42 authentication to reads and requires the authenticated author for publication. Its default 1 MiB message and 4 MiB pending-output limits are protocol configuration, not feature policy. The combined `tiny` host supplies tenant access policy and feature services around the relay contract.60+5861 ## Describe standards and project extensions separately5962 6063 Protocol documentation should classify each behavior as one of:@@ -72,7 +75,7 @@ Keep transport-specific method names in their adapters. A feature operation may
7275 7376 ## Compatibility and terminology7477 75-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.78+Use `tinyrelay` for the standalone relay component, `tiny` for the combined 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.7679 7780 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.7881 @@ -17,12 +17,15 @@ Build all executables with `make build`. Run the focused component suites with:
1717 1818 ```sh1919 go test -race ./protocol/... ./internal/auth ./internal/event ./tinygit ./cmd/tinygit ./tinyclient ./cmd/tinyclient20+go test -race ./tinyrelay ./internal/relaycmd ./cmd/tinyrelay2021 go test ./internal/daemon -run TestStandaloneTinyclient2122 npm run test:tinyclient2223 ```2324 2425 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.2526 27+The tinyrelay tests cover the standalone relay's WebSocket protocol, NIP-11 metadata, durable restart, authentication mode, connection limits and shutdown. The command tests cover flag defaults, public URL normalization, listener startup and cancellation. The standalone profile has generic event visibility; tenant and feature access tests belong to the combined daemon.28+2629 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).2730 2831 ## Remote Linux host@@ -0,0 +1,172 @@
1+// Package relaycmd contains the shared standalone relay command runner.2+package relaycmd3+4+import (5+ "context"6+ "errors"7+ "flag"8+ "fmt"9+ "io"10+ "net"11+ "net/http"12+ "net/url"13+ "os"14+ "strings"15+ "time"16+17+ "github.com/FelineStateMachine/tinyrelay/tinyrelay"18+)19+20+const (21+ defaultMessageBytes = 1 << 2022+ defaultPendingBytes = 4 << 2023+)24+25+type options struct {26+ dataDir, listen, publicURL string27+ name, description, contact string28+ owner string29+ authRequired bool30+ maxMessageBytes int6431+ maxPendingBytes int32+}33+34+func parse(args []string) (options, error) {35+ return parseArgs(args, io.Discard)36+}37+38+func parseArgs(args []string, output io.Writer) (options, error) {39+ opts := options{dataDir: defaultDataDir(), listen: "127.0.0.1:7447", maxMessageBytes: defaultMessageBytes, maxPendingBytes: defaultPendingBytes}40+ fs := flag.NewFlagSet("tinyrelay", flag.ContinueOnError)41+ fs.SetOutput(output)42+ fs.StringVar(&opts.dataDir, "data-dir", opts.dataDir, "relay data directory")43+ fs.StringVar(&opts.listen, "listen", opts.listen, "HTTP/WebSocket listen address")44+ fs.StringVar(&opts.publicURL, "public-url", "", "public HTTP(S) or WS(S) relay URL")45+ fs.StringVar(&opts.name, "name", "", "relay name")46+ fs.StringVar(&opts.description, "description", "", "relay description")47+ fs.StringVar(&opts.contact, "contact", "", "relay operator contact")48+ fs.StringVar(&opts.owner, "owner", "", "relay owner lowercase hex public key")49+ fs.BoolVar(&opts.authRequired, "auth-required", false, "require AUTH before reading and publishing as the authenticated author")50+ fs.Int64Var(&opts.maxMessageBytes, "max-message-bytes", opts.maxMessageBytes, "maximum websocket message size")51+ fs.IntVar(&opts.maxPendingBytes, "max-pending-bytes", opts.maxPendingBytes, "maximum queued output per connection")52+ if err := fs.Parse(args); err != nil {53+ return opts, err54+ }55+ if fs.NArg() != 0 {56+ return opts, errors.New("tinyrelay accepts flags only")57+ }58+ var err error59+ if opts.publicURL, err = normalizeRelayURL(opts.publicURL); err != nil {60+ return opts, err61+ }62+ if opts.maxMessageBytes <= 0 || opts.maxPendingBytes <= 0 {63+ return opts, errors.New("message and pending byte limits must be positive")64+ }65+ if opts.owner != "" && !validOwner(opts.owner) {66+ return opts, errors.New("owner must be a lowercase 64-character hex public key")67+ }68+ return opts, nil69+}70+71+func defaultDataDir() string {72+ if value := strings.TrimSpace(os.Getenv("TINY_RELAY_DATA_DIR")); value != "" {73+ return value74+ }75+ return "tinyrelay-data"76+}77+78+func normalizeRelayURL(value string) (string, error) {79+ if value == "" {80+ return "", nil81+ }82+ u, err := url.Parse(value)83+ if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {84+ return "", errors.New("public URL must be an HTTP(S) or WS(S) URL without query or fragment")85+ }86+ switch u.Scheme {87+ case "http":88+ u.Scheme = "ws"89+ case "https":90+ u.Scheme = "wss"91+ case "ws", "wss":92+ default:93+ return "", errors.New("public URL must use http, https, ws or wss")94+ }95+ u.Path = strings.TrimRight(u.Path, "/")96+ return u.String(), nil97+}98+99+func validOwner(value string) bool {100+ if len(value) != 64 || value != strings.ToLower(value) {101+ return false102+ }103+ for _, char := range value {104+ if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) {105+ return false106+ }107+ }108+ return true109+}110+111+// Run serves a standalone, protocol-compatible relay until ctx is canceled.112+func Run(ctx context.Context, args []string, output io.Writer) error {113+ opts, err := parseArgs(args, output)114+ if errors.Is(err, flag.ErrHelp) {115+ return nil116+ }117+ if err != nil {118+ return err119+ }120+ listener, err := net.Listen("tcp", opts.listen)121+ if err != nil {122+ return fmt.Errorf("listen on %s: %w", opts.listen, err)123+ }124+ defer listener.Close()125+ publicURL := opts.publicURL126+ if publicURL == "" {127+ publicURL = "ws://" + listener.Addr().String()128+ }129+ app, err := tinyrelay.OpenServer(ctx, tinyrelay.ServerConfig{130+ DataDir: opts.dataDir, PublicURL: publicURL, Name: opts.name,131+ Description: opts.description, Contact: opts.contact, Owner: opts.owner,132+ AuthRequired: opts.authRequired, MaxMessageBytes: opts.maxMessageBytes,133+ MaxPendingBytes: opts.maxPendingBytes,134+ })135+ if err != nil {136+ return err137+ }138+ server := &http.Server{Handler: app, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20, BaseContext: func(net.Listener) context.Context { return ctx }}139+ done := make(chan error, 1)140+ go func() { done <- server.Serve(listener) }()141+ if _, err := fmt.Fprintf(output, "tinyrelay listening on %s\n", listener.Addr()); err != nil {142+ _ = server.Close()143+ serveErr := <-done144+ return errors.Join(err, joinServeErrors(serveErr, app.Close()))145+ }146+ select {147+ case serveErr := <-done:148+ closeErr := app.Close()149+ return joinServeErrors(serveErr, closeErr)150+ case <-ctx.Done():151+ return stopServer(server, app, done)152+ }153+}154+155+func stopServer(server *http.Server, app *tinyrelay.Server, done <-chan error) error {156+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)157+ defer cancel()158+ shutdownErr := server.Shutdown(shutdownCtx)159+ if shutdownErr != nil {160+ _ = server.Close()161+ }162+ serveErr := <-done163+ closeErr := app.Close()164+ return errors.Join(shutdownErr, joinServeErrors(serveErr, closeErr))165+}166+167+func joinServeErrors(serveErr, closeErr error) error {168+ if errors.Is(serveErr, http.ErrServerClosed) {169+ serveErr = nil170+ }171+ return errors.Join(serveErr, closeErr)172+}@@ -0,0 +1,100 @@
1+package relaycmd2+3+import (4+ "context"5+ "io"6+ "sync"7+ "testing"8+ "time"9+)10+11+func TestParseDefaultsAndEnvironment(t *testing.T) {12+ t.Setenv("TINY_RELAY_DATA_DIR", "/tmp/relay-data")13+ opts, err := parse([]string{"--name", "Example"})14+ if err != nil {15+ t.Fatal(err)16+ }17+ if opts.dataDir != "/tmp/relay-data" || opts.listen != "127.0.0.1:7447" {18+ t.Fatalf("defaults = %#v", opts)19+ }20+ if opts.maxMessageBytes != 1<<20 || opts.maxPendingBytes != 4<<20 {21+ t.Fatalf("limits = %#v", opts)22+ }23+ if opts.name != "Example" {24+ t.Fatalf("name = %q", opts.name)25+ }26+}27+28+func TestParseNormalizesRelayURL(t *testing.T) {29+ tests := []struct {30+ input string31+ want string32+ }{33+ {"http://relay.example/", "ws://relay.example"},34+ {"https://relay.example/nostr/", "wss://relay.example/nostr"},35+ {"ws://relay.example", "ws://relay.example"},36+ {"wss://relay.example/", "wss://relay.example"},37+ }38+ for _, test := range tests {39+ t.Run(test.input, func(t *testing.T) {40+ opts, err := parse([]string{"--public-url", test.input})41+ if err != nil {42+ t.Fatal(err)43+ }44+ if opts.publicURL != test.want {45+ t.Fatalf("public URL = %q, want %q", opts.publicURL, test.want)46+ }47+ })48+ }49+}50+51+func TestParseRejectsInvalidArguments(t *testing.T) {52+ for _, args := range [][]string{53+ {"--public-url", "ftp://relay.example"},54+ {"--owner", "not-a-public-key"},55+ {"--max-message-bytes", "0"},56+ {"--max-pending-bytes", "-1"},57+ {"extra"},58+ } {59+ if _, err := parse(args); err == nil {60+ t.Fatalf("accepted %v", args)61+ }62+ }63+}64+65+func TestRunStopsWithContext(t *testing.T) {66+ ctx, cancel := context.WithCancel(context.Background())67+ defer cancel()68+ output := &signalWriter{ready: make(chan struct{})}69+ done := make(chan error, 1)70+ go func() {71+ done <- Run(ctx, []string{"--data-dir", t.TempDir(), "--listen", "127.0.0.1:0"}, output)72+ }()73+74+ select {75+ case <-output.ready:76+ case <-time.After(5 * time.Second):77+ t.Fatal("runner did not start")78+ }79+ cancel()80+ select {81+ case err := <-done:82+ if err != nil {83+ t.Fatal(err)84+ }85+ case <-time.After(5 * time.Second):86+ t.Fatal("runner did not stop")87+ }88+}89+90+type signalWriter struct {91+ ready chan struct{}92+ once sync.Once93+}94+95+var _ io.Writer = (*signalWriter)(nil)96+97+func (w *signalWriter) Write(p []byte) (int, error) {98+ w.once.Do(func() { close(w.ready) })99+ return len(p), nil100+}@@ -17,7 +17,7 @@ go build -o tinyclient-server ./cmd/tinyclient
1717 -public-url https://relay.example1818 ```1919 20-The backend must be a compatible tinyrelay backend (`tiny serve`) from this20+The backend must be the combined `tiny` host (`tiny serve`) from this2121 repository. Set its `--public-url` to the same `https://relay.example` address.2222 Route that public2323 origin to tinyclient through your HTTPS reverse proxy, preserving the Host@@ -42,7 +42,7 @@ This JSON endpoint is the standalone service's HTTP binding, not a Nostr WebSock
4242 4343 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.4444 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.45+Those features remain available through the combined `tiny` host and its existing adapters. A standalone build is not a claim of full integrated feature parity or support for every GRASP capability.4646 4747 ## Embed4848 @@ -0,0 +1,42 @@
1+# tinyrelay2+3+`tinyrelay` is a standalone Nostr relay. It accepts ordinary Nostr clients over WebSocket and owns its event store, relay metadata, authentication policy and connection lifecycle. It does not start the web UI, Git hosting or tenant services.4+5+Build and run it directly:6+7+```sh8+make build-tinyrelay9+./bin/tinyrelay --data-dir ./relay-data --listen :7447 \10+ --public-url wss://relay.example --name "Example relay"11+```12+13+The same runner is available as `tiny relay`. Use `--help` for all options. `--public-url` accepts `http`, `https`, `ws` or `wss`; HTTP schemes are normalized to the corresponding WebSocket scheme. When omitted, the relay uses the bound listener address.14+15+The default profile is a public event relay. It accepts opaque Nostr events across kinds and applies protocol validity, expiration and author checks without tenant ACLs. Ephemeral events are delivered live without being stored. Protected events require the author's NIP-42 authentication. `--auth-required` requires authentication for all reads and publications; any authenticated key can read. `--owner` identifies the operator in NIP-11 metadata only.16+17+The server exposes the WebSocket relay and NIP-11 information at `/` (or the configured public URL path), plus `/healthz` and `/readyz`. It advertises the supported NIP-1, NIP-11, NIP-42, NIP-45 and NIP-77 profile. WebSocket messages default to a 1 MiB limit and each connection's queued output defaults to 4 MiB; configure these with `--max-message-bytes` and `--max-pending-bytes`.18+19+Each standalone process owns one data directory. The directory contains the event database and an exclusive lock, so two processes cannot open the same relay data at once. Use a separate directory for each relay and keep it on durable local storage.20+21+## Embedding22+23+Use `OpenServer` when the relay should own persistence and be exposed as an `http.Handler`:24+25+```go26+import "github.com/FelineStateMachine/tinyrelay/tinyrelay"27+28+server, err := tinyrelay.OpenServer(ctx, tinyrelay.ServerConfig{29+ DataDir: "./relay-data",30+ PublicURL: "wss://relay.example",31+ Name: "Example relay",32+ Owner: ownerPublicKey,33+})34+if err != nil {35+ return err36+}37+defer server.Close()38+```39+40+`New` is for a host that owns persistence. Supply a `Backend` implementation to commit events and answer authorized queries, then mount the returned relay as an HTTP handler. The combined `tiny` host uses this form so its tenant projections and relay publication remain coordinated.41+42+The public package contains protocol contracts and lifecycle types. Storage adapters and host assembly remain in `internal/`, while `cmd/` contains thin process entry points.@@ -0,0 +1,105 @@
1+package tinyrelay_test2+3+import (4+ "fmt"5+ "os"6+ "os/exec"7+ "path/filepath"8+ "strings"9+ "testing"10+)11+12+// TestExternalModuleCanUsePublicAPI compiles a consumer in a separate module.13+// Keep this test independent of implementation packages so public API changes14+// fail at the same boundary as a real relay integrator.15+func TestExternalModuleCanUsePublicAPI(t *testing.T) {16+ if testing.Short() {17+ t.Skip("compiles an independent consumer module")18+ }19+ root, err := filepath.Abs("..")20+ if err != nil {21+ t.Fatal(err)22+ }23+ consumer := t.TempDir()24+ mod := fmt.Sprintf("module example.invalid/tinyrelay-consumer\n\ngo 1.27.1\n\nrequire github.com/FelineStateMachine/tinyrelay v0.0.0\n\nreplace github.com/FelineStateMachine/tinyrelay => %s\n", filepath.ToSlash(root))25+ if err := os.WriteFile(filepath.Join(consumer, "go.mod"), []byte(mod), 0600); err != nil {26+ t.Fatal(err)27+ }28+ source := `package consumer29+30+import (31+ "context"32+ "net/http"33+ "net/http/httptest"34+ "path/filepath"35+ "testing"36+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"37+ "github.com/FelineStateMachine/tinyrelay/tinyrelay"38+)39+40+type backend struct{}41+func (backend) Publish(context.Context, nostr.Event, tinyrelay.Session) (string, error) { return "", nil }42+func (backend) Query(context.Context, []nostr.Filter, tinyrelay.Session) ([]nostr.Event, error) { return nil, nil }43+func (backend) Count(context.Context, []nostr.Filter, tinyrelay.Session) (any, error) { return 0, nil }44+func (backend) CanRead(nostr.Event, tinyrelay.Session) bool { return true }45+func (backend) CanReadFilter(nostr.Event, tinyrelay.Session, *nostr.Filter) bool { return true }46+func (backend) QueryHints(context.Context, []nostr.Filter, tinyrelay.Session) ([]nostr.Event, []string, error) { return nil, nil, nil }47+func (backend) Sync(context.Context, nostr.Filter, tinyrelay.Session) ([]tinyrelay.SyncItem, error) { return nil, nil }48+func (backend) CloseSubscriptions(tinyrelay.Session) {}49+50+func TestUse(t *testing.T) {51+ ctx := context.Background()52+ embedded, err := tinyrelay.New(backend{}, tinyrelay.Config{RelayURL: "ws://relay.example"})53+ if err != nil { t.Fatal(err) }54+ var _ http.Handler = embedded55+ if err := embedded.Close(ctx); err != nil { t.Fatal(err) }56+57+ standalone, err := tinyrelay.OpenServer(ctx, tinyrelay.ServerConfig{DataDir: filepath.Join(t.TempDir(), "data"), PublicURL: "ws://relay.example"})58+ if err != nil { t.Fatal(err) }59+ defer standalone.Close()60+ var _ http.Handler = standalone61+ response := httptest.NewRecorder()62+ standalone.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "http://relay.example/", nil))63+ if response.Code != http.StatusOK { t.Fatalf("root status = %d", response.Code) }64+}65+`66+ if err := os.WriteFile(filepath.Join(consumer, "consumer_test.go"), []byte(source), 0600); err != nil {67+ t.Fatal(err)68+ }69+ cmd := exec.Command("go", "test", "-mod=mod", ".")70+ cmd.Dir = consumer71+ cmd.Env = append(os.Environ(), "GOWORK=off", "GOPROXY=off", "GOSUMDB=off")72+ if out, err := cmd.CombinedOutput(); err != nil {73+ t.Fatalf("external consumer: %v\n%s", err, out)74+ }75+}76+77+func TestPublicPackagesAvoidHostedApplicationDependencies(t *testing.T) {78+ if testing.Short() {79+ t.Skip("inspects production package dependencies")80+ }81+ root, err := filepath.Abs("..")82+ if err != nil {83+ t.Fatal(err)84+ }85+ cmd := exec.Command("go", "list", "-mod=mod", "-f", "{{.ImportPath}}", "-deps", "./tinyrelay", "./cmd/tinyrelay")86+ cmd.Dir = root87+ cmd.Env = append(os.Environ(), "GOWORK=off", "GOPROXY=off", "GOSUMDB=off")88+ out, err := cmd.Output()89+ if err != nil {90+ t.Fatalf("list public package dependencies: %v", err)91+ }92+ for _, path := range strings.Fields(string(out)) {93+ forbidden := []string{94+ "github.com/FelineStateMachine/tinyrelay/internal/daemon",95+ "github.com/FelineStateMachine/tinyrelay/internal/tinygit",96+ "github.com/FelineStateMachine/tinyrelay/tinygit",97+ "github.com/FelineStateMachine/tinyrelay/tinyclient",98+ }99+ for _, prefix := range forbidden {100+ if path == prefix || strings.HasPrefix(path, prefix+"/") {101+ t.Fatalf("public relay dependency imports hosted application package %q", path)102+ }103+ }104+ }105+}