| 1 | diff --git a/README.md b/README.md |
| 2 | index 7b5091c..0b2825b 100644 |
| 3 | --- a/README.md |
| 4 | +++ b/README.md |
| 5 | @@ -79,8 +79,6 @@ See [Wiki](docs/wiki.md) for pages, versions and forks, merge requests and redir |
| 6 | |
| 7 | See [Custom views](docs/views.md) for rendering fenced code blocks through a transform you run and keeping the result as signed artifacts. |
| 8 | |
| 9 | -See [Architecture](docs/architecture.md) for service responsibilities, event acceptance and dependency checks. |
| 10 | - |
| 11 | **Manage > Health** shows the relay and script versions and whether the browser's WebMCP tools are registered. See [WebMCP tools](docs/webmcp.md) for browser-agent integration and authorization behavior. |
| 12 | |
| 13 | Agents outside the browser connect to `/mcp`, a stateless Model Context Protocol endpoint authenticated with NIP-98, and read `/llms.txt` for a summary of the relay's machine surface. See [MCP](docs/mcp.md). An agent acts under a grant the owner signs, and the owner reviews what it proposes: |
| 14 | @@ -130,6 +128,20 @@ flowchart LR |
| 15 | |
| 16 | See [Personal relay deployment](docs/personal-relay.md) for the reference installation. |
| 17 | |
| 18 | +## Explore the code |
| 19 | + |
| 20 | +Start with [`cmd/tiny`](cmd/tiny/doc.go) for commands and process lifetime, or [`daemon`](internal/daemon/doc.go) for tenant assembly and event flow. Each internal package has a `doc.go` overview. Comments on interfaces, constructors and transaction hooks explain ownership, inputs and failure behavior where those contracts are declared. Tests live beside the code they exercise. |
| 21 | + |
| 22 | +Use editor symbol help or read the same comments from the command line: |
| 23 | + |
| 24 | +```sh |
| 25 | +go doc ./internal/daemon |
| 26 | +go doc ./internal/storage SaveOptions |
| 27 | +go doc ./internal/work Handler |
| 28 | +``` |
| 29 | + |
| 30 | +See [Architecture](docs/architecture.md) for the package map, event acceptance and dependency checks. |
| 31 | + |
| 32 | ## Test and diagnose |
| 33 | |
| 34 | ```sh |
| 35 | diff --git a/cmd/tiny/doc.go b/cmd/tiny/doc.go |
| 36 | new file mode 100644 |
| 37 | index 0000000..2a43f93 |
| 38 | --- /dev/null |
| 39 | +++ b/cmd/tiny/doc.go |
| 40 | @@ -0,0 +1,9 @@ |
| 41 | +// Command tiny runs and administers a multitenant Nostr relay. |
| 42 | +// |
| 43 | +// main creates a context cancelled by interrupt or SIGTERM and reports the |
| 44 | +// error returned by run. serve binds the HTTP listeners, starts daemon.App, |
| 45 | +// runs optional peer monitoring and joins server shutdown before closing the |
| 46 | +// application. Tenant subcommands open an App for catalog operations. The |
| 47 | +// git-token subcommand produces signed Git authorization material from an |
| 48 | +// operator-selected key source. |
| 49 | +package main |
| 50 | diff --git a/cmd/tiny/main.go b/cmd/tiny/main.go |
| 51 | index 91ff9df..1098a29 100644 |
| 52 | --- a/cmd/tiny/main.go |
| 53 | +++ b/cmd/tiny/main.go |
| 54 | @@ -33,6 +33,8 @@ func main() { |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | +// run dispatches arguments after the executable name and writes command output |
| 59 | +// to out. Returning errors to main keeps process exit at the command boundary. |
| 60 | func run(ctx context.Context, args []string, out io.Writer) error { |
| 61 | if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" { |
| 62 | _, err := fmt.Fprintln(out, "tiny serve [--data-dir PATH] [--listen :7447]\ntiny tenant create --name NAME --owner PUBKEY [--template default]\ntiny tenant list|enable|disable|host [options]\ntiny git-token --repo URL [--key-env TINY_AGENT_KEY] [--format header|value|git]\ntiny templates\ntiny version") |
| 63 | @@ -215,6 +217,8 @@ func hexOwner(value string) bool { |
| 64 | return true |
| 65 | } |
| 66 | |
| 67 | +// serve owns listeners and peer monitoring around the App lifecycle. Shutdown |
| 68 | +// drains the servers and joins peer monitoring before deferred App cleanup. |
| 69 | func serve(ctx context.Context, args []string, out io.Writer) error { |
| 70 | opts, err := parseServe(args) |
| 71 | if err != nil { |
| 72 | @@ -297,6 +301,9 @@ func serve(ctx context.Context, args []string, out io.Writer) error { |
| 73 | return serverErr |
| 74 | } |
| 75 | |
| 76 | +// awaitServers starts shutdown when ctx ends or a server returns. Each server |
| 77 | +// must report once to results; all reports are drained before returning the |
| 78 | +// joined shutdown and serving errors. |
| 79 | func awaitServers(ctx context.Context, servers []*http.Server, results <-chan error) error { |
| 80 | var first error |
| 81 | received := 0 |
| 82 | diff --git a/docs/architecture.md b/docs/architecture.md |
| 83 | index 19a53b8..45c921a 100644 |
| 84 | --- a/docs/architecture.md |
| 85 | +++ b/docs/architecture.md |
| 86 | @@ -1,31 +1,47 @@ |
| 87 | # Architecture |
| 88 | |
| 89 | -Each tenant has a durable store and a set of services. The daemon constructs those services, admits operations and manages their lifetimes. |
| 90 | +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. |
| 91 | + |
| 92 | +## Find your starting point |
| 93 | + |
| 94 | +[`cmd/tiny`](../cmd/tiny/doc.go) owns command dispatch, listeners and process shutdown. [`daemon`](../internal/daemon/doc.go) describes tenant assembly and event flow. Every internal package has a `doc.go` overview, and interface, constructor and transaction comments describe the contracts at their declarations. These comments are also available through `go doc` and editor symbol help. |
| 95 | |
| 96 | ## Responsibilities |
| 97 | |
| 98 | -| Area | Responsibility | |
| 99 | +| Packages | Responsibility | |
| 100 | | --- | --- | |
| 101 | -| `event` and `policy` | Event formats and shared policy decisions. | |
| 102 | -| `storage` | Event persistence, queries and transaction boundaries. | |
| 103 | -| `community` | Membership, moderation, rooms and their projections. | |
| 104 | -| `records` | Relay identity, signed records and notification records. Community data arrives through a required read interface. | |
| 105 | -| `blob` | File storage, upload rules and file HTTP endpoints. | |
| 106 | -| `gitrelay` | Repository admission, storage, synchronization, repair and Git HTTP endpoints. | |
| 107 | -| `replication` | Replication policy, jobs and delivery handlers. | |
| 108 | -| `work` | Durable queue claims, retries and worker lifetimes. The daemon combines handlers from the services. | |
| 109 | -| `webui` | Pages, typed room reads and browser interactions. | |
| 110 | - |
| 111 | -Keep schema changes and data access with the service that owns the data. Share a policy function or a small read contract when several services need the same rule or projection. Avoid introducing a general utility package for unrelated behavior. |
| 112 | +| [`event`](../internal/event/doc.go), [`policy`](../internal/policy/doc.go) | Event and filter values, signing, validation and shared policy decisions. | |
| 113 | +| [`auth`](../internal/auth/doc.go), [`gates`](../internal/gates/doc.go) | Signed request proofs, event admission and visibility checks. | |
| 114 | +| [`storage`](../internal/storage/doc.go), [`work`](../internal/work/doc.go) | Event persistence, queries, transaction hooks and durable queue claims. | |
| 115 | +| [`community`](../internal/community/doc.go), [`communityread`](../internal/communityread/doc.go) | Membership, moderation, rooms, agents and the read models consumed by records. | |
| 116 | +| [`records`](../internal/records/doc.go) | Relay identity, signed protocol records, projections and notifications. | |
| 117 | +| [`blob`](../internal/blob/doc.go), [`sites`](../internal/sites/doc.go) | Content-addressed files, upload rules and sites backed by signed manifests. | |
| 118 | +| [`gitrelay`](../internal/gitrelay/doc.go) | Repository admission, object storage, synchronization, repair and Git HTTP endpoints. | |
| 119 | +| [`replication`](../internal/replication/doc.go), [`syncprotocol`](../internal/syncprotocol/doc.go) | Relay synchronization plans and transports, count sketches and reconciliation sessions. | |
| 120 | +| [`relay`](../internal/relay/doc.go), [`mcp`](../internal/mcp/doc.go), [`webui`](../internal/webui/doc.go) | WebSocket sessions, MCP requests, HTML pages and browser interactions. | |
| 121 | +| [`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. | |
| 122 | +| [`catalog`](../internal/catalog/doc.go), [`domains`](../internal/domains/doc.go) | Tenant lifecycle records, filesystem paths and host mappings. | |
| 123 | +| [`templates`](../internal/templates/doc.go), [`configport`](../internal/configport/doc.go) | Built-in policy templates and configuration import, export and application. | |
| 124 | +| [`telemetry`](../internal/telemetry/doc.go), [`daemon`](../internal/daemon/doc.go) | Observability, service assembly, request routing and worker ownership. | |
| 125 | + |
| 126 | +## Ownership and contracts |
| 127 | + |
| 128 | +`App` owns the catalog, telemetry and active tenants. Each `Tenant` owns its store and service lifetimes. Services borrow the store and policy reader supplied at construction. Policy reads return a shared snapshot; management writes persist and replace that snapshot. Shutdown stops admissions, joins background work and drains active operations before closing storage. |
| 129 | + |
| 130 | +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. |
| 131 | + |
| 132 | +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`. |
| 133 | |
| 134 | ## Event acceptance |
| 135 | |
| 136 | -Client publication and imported events share the persistence and projection path. Each entry point retains its own admission rules and protocol actions. Event projections and durable work records commit together. |
| 137 | +Client publication and imported events use the shared event persistence and projection path. Each entry point applies its admission rules and protocol actions. Signed repository state stays pending until its referenced Git objects are available; promotion makes it visible and plans its optional follow-up work. |
| 138 | |
| 139 | -Callbacks and custom views are optional. Local planning can recover after acceptance, with bounded retries that recheck the source and captured registrations. Completed work keeps its identity and is not reset by planning recovery. Remote delivery and transforms retain their own retry limits. See [callbacks](agents.md#retries-and-pauses) and [custom views](views.md#failures). |
| 140 | +Callbacks and custom views are optional. A planning intent commits with an accepted event and captures its eligible registrations or failed local lookups. Recovery rechecks the source and current registrations, with at most three worker attempts and a 15-minute age limit. Stable child intent IDs preserve completed work. Remote delivery and transforms have their own retry policies. See [callbacks](agents.md#retries-and-pauses) and [custom views](views.md#failures). |
| 141 | |
| 142 | ## Browser boundaries |
| 143 | |
| 144 | -Pages render without JavaScript. The page template owns the shell; page templates own their content sections. Generic components live in `components.js`, room interactions live in `rooms.js`, and signed publication uses the shared `tiny.signing` contract. The verifier checks signed events before publication. |
| 145 | +Pages render without JavaScript. `page.html` owns the shell and panel dispatch; each page template owns one content section. Markup uses elements and IDs for styling in `style.css`. Shared form and data elements live in `components.js`, room interactions live in `rooms.js`, and signed publication uses `tiny.signing`. The signing contract verifies returned events before publication. Browser modules describe their entry points and shared services in their source headers. |
| 146 | + |
| 147 | +## Dependencies and checks |
| 148 | |
| 149 | Go module versions are declared in `go.mod`, with dependency checksums in `go.sum`. Browser dependencies are locked in `package-lock.json`. `make verify` checks the Go and JavaScript code and verifies generated browser bundles against their sources. See [Testing](testing.md) for local and Linux checks. |
| 150 | diff --git a/internal/auth/auth.go b/internal/auth/auth.go |
| 151 | index 169a968..32bd14c 100644 |
| 152 | --- a/internal/auth/auth.go |
| 153 | +++ b/internal/auth/auth.go |
| 154 | @@ -1,5 +1,3 @@ |
| 155 | -// Package auth identifies Nostr callers at HTTP and websocket boundaries. |
| 156 | -// Access policy belongs to the caller; this package only verifies proofs. |
| 157 | package auth |
| 158 | |
| 159 | import ( |
| 160 | @@ -27,6 +25,8 @@ type Validator struct { |
| 161 | seen map[string]time.Time |
| 162 | } |
| 163 | |
| 164 | +// NewValidator uses now to check proof freshness and track replay windows. |
| 165 | +// A nil clock selects the system clock. |
| 166 | func NewValidator(now func() time.Time) *Validator { |
| 167 | if now == nil { |
| 168 | now = time.Now |
| 169 | @@ -34,6 +34,9 @@ func NewValidator(now func() time.Time) *Validator { |
| 170 | return &Validator{now: now, seen: make(map[string]time.Time)} |
| 171 | } |
| 172 | |
| 173 | +// VerifyNIP98 validates a kind 27235 request proof against the exact URL, |
| 174 | +// method and body supplied by the caller. Successfully verified event IDs are |
| 175 | +// retained for the one-minute replay window. |
| 176 | func (v *Validator) VerifyNIP98(header, rawURL, method, body string) (event.Event, error) { |
| 177 | e, err := decodeToken(header, "NIP-98") |
| 178 | if err != nil { |
| 179 | @@ -64,13 +67,15 @@ func (v *Validator) VerifyNIP98(header, rawURL, method, body string) (event.Even |
| 180 | return e, nil |
| 181 | } |
| 182 | |
| 183 | +// VerifyBlossom validates a BUD-11 authorization for action. It accepts the |
| 184 | +// protocol's reusable authorization form and checks its action and expiry. |
| 185 | func (v *Validator) VerifyBlossom(header, action string) (event.Event, error) { |
| 186 | return v.verifyBlossom(header, action, "", "", false) |
| 187 | } |
| 188 | |
| 189 | -// VerifyBlossomRequest verifies a BUD-11 request, including optional server |
| 190 | -// domain and blob-hash scope. It is separate from VerifyBlossom to preserve |
| 191 | -// callers that use the older, server-agnostic helper. |
| 192 | +// VerifyBlossomRequest validates a BUD-11 authorization with optional server |
| 193 | +// and blob-hash scope. Upload, delete, mirror and media actions require an x |
| 194 | +// tag when this scoped form is used. |
| 195 | func (v *Validator) VerifyBlossomRequest(header, action, server, blobHash string) (event.Event, error) { |
| 196 | return v.verifyBlossom(header, action, server, blobHash, true) |
| 197 | } |
| 198 | @@ -215,6 +220,8 @@ type ChallengeManager struct { |
| 199 | active map[string]time.Time |
| 200 | } |
| 201 | |
| 202 | +// NewChallengeManager creates a NIP-42 challenge store for relayURL. Issued |
| 203 | +// challenges are single-use and expire with the validator replay window. |
| 204 | func NewChallengeManager(relayURL string, now func() time.Time) *ChallengeManager { |
| 205 | if now == nil { |
| 206 | now = time.Now |
| 207 | @@ -222,6 +229,7 @@ func NewChallengeManager(relayURL string, now func() time.Time) *ChallengeManage |
| 208 | return &ChallengeManager{relayURL: relayURL, now: now, active: make(map[string]time.Time)} |
| 209 | } |
| 210 | |
| 211 | +// Issue creates and stores a cryptographically random NIP-42 challenge. |
| 212 | func (m *ChallengeManager) Issue() (string, error) { |
| 213 | raw := make([]byte, 32) |
| 214 | if _, err := rand.Read(raw); err != nil { |
| 215 | @@ -236,6 +244,7 @@ func (m *ChallengeManager) Issue() (string, error) { |
| 216 | return challenge, nil |
| 217 | } |
| 218 | |
| 219 | +// Verify validates and consumes a NIP-42 AUTH event for the configured relay. |
| 220 | func (m *ChallengeManager) Verify(e event.Event) error { |
| 221 | if err := event.Validate(e); err != nil { |
| 222 | return authError(err.Error()) |
| 223 | diff --git a/internal/auth/doc.go b/internal/auth/doc.go |
| 224 | new file mode 100644 |
| 225 | index 0000000..a3bd9cc |
| 226 | --- /dev/null |
| 227 | +++ b/internal/auth/doc.go |
| 228 | @@ -0,0 +1,6 @@ |
| 229 | +// Package auth verifies the signed proofs used at HTTP and WebSocket |
| 230 | +// boundaries. It handles NIP-98 request signatures, Blossom authorization, |
| 231 | +// GRASP-08 repository proofs and NIP-42 challenges. Callers use the returned |
| 232 | +// public key to apply relay policy and keep authorization state outside this |
| 233 | +// package. |
| 234 | +package auth |
| 235 | diff --git a/internal/auth/grasp08.go b/internal/auth/grasp08.go |
| 236 | index 3d98b26..39ee850 100644 |
| 237 | --- a/internal/auth/grasp08.go |
| 238 | +++ b/internal/auth/grasp08.go |
| 239 | @@ -8,16 +8,13 @@ import ( |
| 240 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 241 | ) |
| 242 | |
| 243 | -// ErrGRASP08Unauthorized is intentionally detail-free. GRASP-08 uses an |
| 244 | -// empty 401 challenge so a caller cannot distinguish an unknown repository |
| 245 | -// from an unauthorized one. |
| 246 | +// ErrGRASP08Unauthorized is the detail-free GRASP-08 authentication error |
| 247 | +// returned for every failed repository proof. |
| 248 | var ErrGRASP08Unauthorized = errors.New("auth-required:") |
| 249 | |
| 250 | -// VerifyGRASP08 verifies the reusable repository-root proof used by the |
| 251 | -// GRASP-08 private-service profile. The proof is a kind 27235 event naming |
| 252 | -// the repository root and GET; it is valid for 60 seconds and is reusable |
| 253 | -// across the repository's Smart HTTP requests. Unlike NIP-98, payload tags |
| 254 | -// are intentionally ignored and the proof is not replay-tracked. |
| 255 | +// VerifyGRASP08 validates a reusable kind 27235 GET proof for the repository |
| 256 | +// root named by a GRASP-08 Smart HTTP request. The proof remains valid for |
| 257 | +// one minute across the repository's supported Git requests. |
| 258 | func (v *Validator) VerifyGRASP08(header, rawURL string) (event.Event, error) { |
| 259 | e, err := decodeToken(header, "GRASP-08") |
| 260 | if err != nil { |
| 261 | @@ -40,8 +37,8 @@ func (v *Validator) VerifyGRASP08(header, rawURL string) (event.Event, error) { |
| 262 | return e, nil |
| 263 | } |
| 264 | |
| 265 | -// GRASP08RepositoryRoot returns the canonical repository URL for a Smart HTTP |
| 266 | -// request beneath a .git path. |
| 267 | +// GRASP08RepositoryRoot returns the canonical repository URL for a supported |
| 268 | +// Smart HTTP request beneath a .git path. |
| 269 | func GRASP08RepositoryRoot(raw string) (string, bool) { |
| 270 | u, err := url.Parse(raw) |
| 271 | if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil { |
| 272 | diff --git a/internal/blob/doc.go b/internal/blob/doc.go |
| 273 | index c0ee9d9..7632e42 100644 |
| 274 | --- a/internal/blob/doc.go |
| 275 | +++ b/internal/blob/doc.go |
| 276 | @@ -1,2 +1,26 @@ |
| 277 | -// Package blob implements the Blossom and NIP-96 file doors for a local relay. |
| 278 | +// Package blob implements the local relay's Blossom and NIP-96 file endpoints. |
| 279 | +// |
| 280 | +// Service stores immutable blob bytes under Config.Root/blobs, addressed by |
| 281 | +// their lowercase SHA-256 digest. Metadata, uploader claims, moderation |
| 282 | +// state, tombstones and multipart ranges live in the supplied storage.Store. |
| 283 | +// New creates the metadata tables, reconciles files left by an interrupted |
| 284 | +// upload and removes stale multipart state before returning a ready service. |
| 285 | +// The caller drives service operations and mounts its Handler in the HTTP |
| 286 | +// router. Startup and request work run within those calls. |
| 287 | +// |
| 288 | +// Put validates and hashes the input before making a new object visible. Its |
| 289 | +// optional Commit callback runs in the metadata transaction, which lets a |
| 290 | +// caller record related ownership or application state atomically with the |
| 291 | +// blob claim. Get returns an open file only after metadata and moderation |
| 292 | +// checks pass. Delete removes metadata transactionally and then removes the |
| 293 | +// content file; DeleteForUploader removes one claim and removes shared bytes |
| 294 | +// only when the final claim is gone. |
| 295 | +// |
| 296 | +// HTTP authorization is supplied by Config.Authorize. The callback returns |
| 297 | +// the authenticated pubkey used for ownership and quota checks. Config's |
| 298 | +// CanRead, IsOwner, ValidateUpload and RecordReport hooks provide the host |
| 299 | +// policy and moderation boundaries without making this package own identity |
| 300 | +// or event storage. Multipart uploads are reserved in the metadata database, |
| 301 | +// staged under the blob root and finalized only after all declared ranges |
| 302 | +// are present. |
| 303 | package blob |
| 304 | diff --git a/internal/blob/service.go b/internal/blob/service.go |
| 305 | index 871a16a..e973b46 100644 |
| 306 | --- a/internal/blob/service.go |
| 307 | +++ b/internal/blob/service.go |
| 308 | @@ -16,6 +16,7 @@ import ( |
| 309 | "sync" |
| 310 | ) |
| 311 | |
| 312 | +// Action identifies the file-door operation being authorized. |
| 313 | type Action string |
| 314 | |
| 315 | const ( |
| 316 | @@ -27,11 +28,22 @@ const ( |
| 317 | ActionReport Action = "report" |
| 318 | ) |
| 319 | |
| 320 | -type Authorize func(*http.Request, Action) (string, error) |
| 321 | -type CanRead func(context.Context, string, []string) bool |
| 322 | -type ResolveIP func(context.Context, string) ([]net.IP, error) |
| 323 | -type ReportRecorder func(context.Context, *sql.Tx, string, string, string, string, string) error |
| 324 | +// Authorize authenticates an HTTP request for an action and returns the |
| 325 | +// caller's pubkey. |
| 326 | +type Authorize func(request *http.Request, action Action) (pubkey string, err error) |
| 327 | |
| 328 | +// CanRead reports whether any supplied reader pubkey may read the blob. |
| 329 | +type CanRead func(ctx context.Context, blobSHA string, readerPubkeys []string) bool |
| 330 | + |
| 331 | +// ResolveIP resolves a host for mirror URL policy checks. |
| 332 | +type ResolveIP func(ctx context.Context, host string) ([]net.IP, error) |
| 333 | + |
| 334 | +// ReportRecorder records a report in the host moderation transaction. The |
| 335 | +// targetKind and reportType values describe the reported object and report; |
| 336 | +// blob reports currently pass "blob" for both. |
| 337 | +type ReportRecorder func(ctx context.Context, tx *sql.Tx, reporter string, targetBlob string, targetKind string, reportType string, content string) error |
| 338 | + |
| 339 | +// Config supplies durable storage, HTTP policy and host integration hooks. |
| 340 | type Config struct { |
| 341 | Root string |
| 342 | PublicURL string |
| 343 | @@ -57,6 +69,7 @@ type Config struct { |
| 344 | UploadTerms func(context.Context, string) (UploadTerms, error) |
| 345 | } |
| 346 | |
| 347 | +// Limits are applied when each upload starts. Zero leaves that limit open. |
| 348 | type Limits struct { |
| 349 | MaxFileBytes int64 |
| 350 | UserStorageBytes int64 |
| 351 | @@ -76,6 +89,7 @@ type UploadTerms struct { |
| 352 | // image, video, audio or document is plain. |
| 353 | var encryptedTypes = map[string]bool{"application/octet-stream": true, "application/vnd.blossom.directory+msgpack": true} |
| 354 | |
| 355 | +// Blob is the metadata for one content-addressed object. |
| 356 | type Blob struct { |
| 357 | SHA256 string `json:"sha256"` |
| 358 | Size int64 `json:"size"` |
| 359 | @@ -84,17 +98,20 @@ type Blob struct { |
| 360 | Uploaded int64 `json:"uploaded"` |
| 361 | } |
| 362 | |
| 363 | +// PutOptions describes a blob upload and its optional metadata transaction. |
| 364 | type PutOptions struct { |
| 365 | Reader io.Reader |
| 366 | Type string |
| 367 | Uploader string |
| 368 | Hash string |
| 369 | - // Commit records ownership metadata in the blob transaction. The final |
| 370 | - // argument reports whether the blob is new. An error rolls back all claims. |
| 371 | + // Commit records ownership metadata in the blob transaction. Its isNew |
| 372 | + // argument reports whether the blob was newly installed. An error rolls |
| 373 | + // back all claims. |
| 374 | // New bytes remain unavailable until their ownership metadata is durable. |
| 375 | - Commit func(context.Context, *sql.Tx, Blob, bool) error |
| 376 | + Commit func(ctx context.Context, tx *sql.Tx, blob Blob, isNew bool) error |
| 377 | } |
| 378 | |
| 379 | +// Service owns blob files and their metadata operations. |
| 380 | type Service struct { |
| 381 | config Config |
| 382 | root string |
| 383 | diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go |
| 384 | index 9035212..e9b89f8 100644 |
| 385 | --- a/internal/catalog/catalog.go |
| 386 | +++ b/internal/catalog/catalog.go |
| 387 | @@ -1,4 +1,3 @@ |
| 388 | -// Package catalog owns the durable registry of self-hosted relay tenants. |
| 389 | package catalog |
| 390 | |
| 391 | import ( |
| 392 | @@ -20,9 +19,15 @@ import ( |
| 393 | type Status string |
| 394 | |
| 395 | const ( |
| 396 | + // StatusCreating identifies a tenant whose directories or services are |
| 397 | + // still being initialized. |
| 398 | StatusCreating Status = "creating" |
| 399 | - StatusReady Status = "ready" |
| 400 | + // StatusReady identifies a tenant available for normal service. |
| 401 | + StatusReady Status = "ready" |
| 402 | + // StatusDisabled identifies a tenant stopped by the operator and eligible |
| 403 | + // for reactivation or deletion. |
| 404 | StatusDisabled Status = "disabled" |
| 405 | + // StatusDeleting identifies a tenant whose deletion intent is recorded. |
| 406 | StatusDeleting Status = "deleting" |
| 407 | ) |
| 408 | |
| 409 | @@ -36,12 +41,17 @@ var ( |
| 410 | ) |
| 411 | |
| 412 | type CreateOptions struct { |
| 413 | - Name string |
| 414 | - Owner string |
| 415 | + // Name is the operator-visible unique tenant name. |
| 416 | + Name string |
| 417 | + // Owner is the tenant owner's public key. |
| 418 | + Owner string |
| 419 | + // Template identifies the configuration template used during setup. |
| 420 | Template string |
| 421 | - Source string |
| 422 | + // Source records where the tenant configuration came from. |
| 423 | + Source string |
| 424 | } |
| 425 | |
| 426 | +// Paths contains the private filesystem locations assigned to a tenant. |
| 427 | type Paths struct { |
| 428 | Root string |
| 429 | Database string |
| 430 | diff --git a/internal/catalog/doc.go b/internal/catalog/doc.go |
| 431 | new file mode 100644 |
| 432 | index 0000000..350f62d |
| 433 | --- /dev/null |
| 434 | +++ b/internal/catalog/doc.go |
| 435 | @@ -0,0 +1,11 @@ |
| 436 | +// Package catalog owns the durable registry and filesystem paths for |
| 437 | +// self-hosted relay tenants. |
| 438 | +// |
| 439 | +// Open creates or opens the catalog database, configures SQLite durability, |
| 440 | +// prepares the tenant tables and resumes recorded deletions. Create records a |
| 441 | +// tenant in StatusCreating and creates its private directories; callers move |
| 442 | +// it to StatusReady after tenant initialization succeeds. Disabled tenants |
| 443 | +// can be enabled again or permanently removed with Delete. Delete records its |
| 444 | +// intent, stages the tenant directory, removes the staged data, then removes |
| 445 | +// the catalog rows, allowing Open to resume an interrupted deletion. |
| 446 | +package catalog |
| 447 | diff --git a/internal/community/community.go b/internal/community/community.go |
| 448 | index 01cfa75..0f174b4 100644 |
| 449 | --- a/internal/community/community.go |
| 450 | +++ b/internal/community/community.go |
| 451 | @@ -1,4 +1,3 @@ |
| 452 | -// Package community owns tenant membership, moderation, invitations and audit. |
| 453 | package community |
| 454 | |
| 455 | import ( |
| 456 | diff --git a/internal/community/config.go b/internal/community/config.go |
| 457 | index 919f646..89a9d69 100644 |
| 458 | --- a/internal/community/config.go |
| 459 | +++ b/internal/community/config.go |
| 460 | @@ -175,8 +175,10 @@ func readConfigRows(ctx context.Context, db *sql.DB, query string, scan func(*sq |
| 461 | return rows.Err() |
| 462 | } |
| 463 | |
| 464 | -// ApplyConfigTx replaces selected community configuration sections inside a |
| 465 | -// caller-owned transaction. Empty sections are meaningful replacements. |
| 466 | +// ApplyConfigTx replaces each selected community configuration section inside |
| 467 | +// the caller-owned transaction. A selected empty section clears that section; |
| 468 | +// an unselected section is left unchanged. The caller commits or rolls back |
| 469 | +// the transaction after all related configuration writes succeed. |
| 470 | func ApplyConfigTx(ctx context.Context, tx *sql.Tx, c ConfigSnapshot, members, bans, addresses, eventBans, kinds, retention bool) error { |
| 471 | now := time.Now().Unix() |
| 472 | if members { |
| 473 | diff --git a/internal/community/doc.go b/internal/community/doc.go |
| 474 | new file mode 100644 |
| 475 | index 0000000..e155c33 |
| 476 | --- /dev/null |
| 477 | +++ b/internal/community/doc.go |
| 478 | @@ -0,0 +1,16 @@ |
| 479 | +// Package community owns the tenant's membership, moderation, invitations, |
| 480 | +// rooms, agents and audit records. |
| 481 | +// |
| 482 | +// A Service stores community state in the tenant database and exposes direct |
| 483 | +// operations plus transaction hooks for event ingestion. Methods whose names |
| 484 | +// end in Tx and accept *sql.Tx use a transaction owned by the caller and leave |
| 485 | +// commit or rollback to that caller. Event handlers whose callback accepts |
| 486 | +// *sql.Tx open and close the transaction internally; the callback stores the |
| 487 | +// accepted event in that transaction, so event, community state and |
| 488 | +// projection work commit or roll back together. |
| 489 | +// |
| 490 | +// Community policy is read through the function supplied to ConfigurePolicy. |
| 491 | +// Records consumes the narrow read model implemented by Members, |
| 492 | +// MemberStatus and ModerationCounts, which keeps records independent of the |
| 493 | +// community tables. |
| 494 | +package community |
| 495 | diff --git a/internal/community/membership.go b/internal/community/membership.go |
| 496 | index 8e0f675..fd15539 100644 |
| 497 | --- a/internal/community/membership.go |
| 498 | +++ b/internal/community/membership.go |
| 499 | @@ -71,11 +71,10 @@ type MembershipResult struct { |
| 500 | NewRequest bool |
| 501 | } |
| 502 | |
| 503 | -// HandleMembershipEventTx applies join/leave admission and invokes persist in |
| 504 | -// the same SQLite transaction. The callback must store the accepted event (or |
| 505 | -// return an error); membership and projection work then roll back together. |
| 506 | -// Management events that need records-owned state should use the root's |
| 507 | -// transaction hooks and EnqueueProjectionTx directly. |
| 508 | +// HandleMembershipEventTx applies join/leave admission in a transaction owned |
| 509 | +// by the service and invokes persist with that transaction. persist owns |
| 510 | +// accepted-event storage; returning an error rolls back membership, the |
| 511 | +// accepted event and queued projection work together. |
| 512 | func (s *Service) HandleMembershipEventTx(ctx context.Context, ev event.Event, persist func(*sql.Tx) error) (MembershipResult, error) { |
| 513 | if persist == nil { |
| 514 | return MembershipResult{}, errors.New("community: nil event persistence callback") |
| 515 | @@ -281,9 +280,8 @@ func (s *Service) HandleModerationEventTx(ctx context.Context, ev event.Event, p |
| 516 | return out, err |
| 517 | } |
| 518 | |
| 519 | -// OnMembershipApplied is retained as a source compatibility shim. Projection |
| 520 | -// refresh is now driven by durable records-projection work intents; invoking a |
| 521 | -// relay callback here can deadlock a publish fence. |
| 522 | +// OnMembershipApplied ignores fn. Projection refresh uses durable |
| 523 | +// records-projection work intents. |
| 524 | func (s *Service) OnMembershipApplied(fn func(event.Event)) { |
| 525 | _ = fn |
| 526 | } |
| 527 | diff --git a/internal/community/records_reader.go b/internal/community/records_reader.go |
| 528 | index 6edda93..8b26732 100644 |
| 529 | --- a/internal/community/records_reader.go |
| 530 | +++ b/internal/community/records_reader.go |
| 531 | @@ -31,7 +31,7 @@ func (s *Service) Members(ctx context.Context) ([]communityread.Member, error) { |
| 532 | return out, rows.Err() |
| 533 | } |
| 534 | |
| 535 | -// MemberStatus reports heir membership and the current member count in one |
| 536 | +// MemberStatus reports the key's membership and the current member count in one |
| 537 | // call so succession checks use a consistent community snapshot. |
| 538 | func (s *Service) MemberStatus(ctx context.Context, pubkey string) (bool, int, error) { |
| 539 | var count int |
| 540 | diff --git a/internal/communityread/communityread.go b/internal/communityread/communityread.go |
| 541 | index 22c18d1..29c24f8 100644 |
| 542 | --- a/internal/communityread/communityread.go |
| 543 | +++ b/internal/communityread/communityread.go |
| 544 | @@ -1,13 +1,16 @@ |
| 545 | -// Package communityread contains the small read models shared by community |
| 546 | -// and records. It has no storage or service dependencies. |
| 547 | package communityread |
| 548 | |
| 549 | type Member struct { |
| 550 | + // PubKey is the member's 32-byte public key in lowercase hexadecimal form. |
| 551 | PubKey string |
| 552 | - Name string |
| 553 | - Role string |
| 554 | + // Name is the member's optional NIP-05-style local name. |
| 555 | + Name string |
| 556 | + // Role is the current community role. |
| 557 | + Role string |
| 558 | } |
| 559 | |
| 560 | +// ModerationCounts contains moderation activity counted from a caller-supplied |
| 561 | +// Unix timestamp. |
| 562 | type ModerationCounts struct { |
| 563 | Bans, Reports, Resolved, Hidden, BlockedAddresses int |
| 564 | } |
| 565 | diff --git a/internal/communityread/doc.go b/internal/communityread/doc.go |
| 566 | new file mode 100644 |
| 567 | index 0000000..1cc8330 |
| 568 | --- /dev/null |
| 569 | +++ b/internal/communityread/doc.go |
| 570 | @@ -0,0 +1,6 @@ |
| 571 | +// Package communityread defines the read models shared by community and |
| 572 | +// records. |
| 573 | +// |
| 574 | +// These structs let records consume community data through its CommunityReader |
| 575 | +// interface. The community service supplies the underlying queries. |
| 576 | +package communityread |
| 577 | diff --git a/internal/configport/configport.go b/internal/configport/configport.go |
| 578 | index 4fa9eb4..c2936be 100644 |
| 579 | --- a/internal/configport/configport.go |
| 580 | +++ b/internal/configport/configport.go |
| 581 | @@ -1,4 +1,3 @@ |
| 582 | -// Package configport imports, exports, plans and applies bind.ws relay files. |
| 583 | package configport |
| 584 | |
| 585 | import ( |
| 586 | @@ -21,16 +20,23 @@ import ( |
| 587 | |
| 588 | const Format = "bind.ws/relay-config/2" |
| 589 | |
| 590 | +// Member is a community member in an exported configuration. |
| 591 | type Member struct { |
| 592 | PubKey string `json:"pubkey"` |
| 593 | Name string `json:"name,omitempty"` |
| 594 | Note string `json:"note,omitempty"` |
| 595 | Role string `json:"role"` |
| 596 | } |
| 597 | + |
| 598 | +// Rule is a retention rule. A nil Kind applies to the tenant default. |
| 599 | type Rule struct { |
| 600 | Kind *int `json:"kind"` |
| 601 | Days int `json:"days"` |
| 602 | } |
| 603 | + |
| 604 | +// Config is the JSON representation accepted by Parse and produced by |
| 605 | +// Export. Sections records which keys were present in the input so Apply can |
| 606 | +// distinguish omission from an explicit empty replacement. |
| 607 | type Config struct { |
| 608 | Format string `json:"format"` |
| 609 | ExportedAt int64 `json:"exported_at,omitempty"` |
| 610 | @@ -51,6 +57,8 @@ type Config struct { |
| 611 | Sections []string `json:"-"` |
| 612 | Warnings []string `json:"-"` |
| 613 | } |
| 614 | + |
| 615 | +// Changes describes the policy and community sections changed by an apply. |
| 616 | type Changes struct { |
| 617 | Policy []string `json:"policy,omitempty"` |
| 618 | Members []string `json:"members,omitempty"` |
| 619 | @@ -58,6 +66,9 @@ type Changes struct { |
| 620 | Warnings []string `json:"warnings,omitempty"` |
| 621 | FinalPolicy policy.Policy `json:"-"` |
| 622 | } |
| 623 | + |
| 624 | +// ConfigStore coordinates configuration with the tenant store, community |
| 625 | +// service and policy callbacks. |
| 626 | type ConfigStore struct { |
| 627 | Store *storage.Store |
| 628 | Community *community.Service |
| 629 | @@ -70,6 +81,7 @@ type ConfigStore struct { |
| 630 | ValidatePolicy func(policy.Policy) error |
| 631 | } |
| 632 | |
| 633 | +// ApplyOptions controls an import or preset application. |
| 634 | type ApplyOptions struct { |
| 635 | DryRun bool |
| 636 | MigrationOwner string |
| 637 | diff --git a/internal/configport/doc.go b/internal/configport/doc.go |
| 638 | new file mode 100644 |
| 639 | index 0000000..e8ce03f |
| 640 | --- /dev/null |
| 641 | +++ b/internal/configport/doc.go |
| 642 | @@ -0,0 +1,10 @@ |
| 643 | +// Package configport imports, exports, plans and applies bind.ws relay |
| 644 | +// configuration files. |
| 645 | +// |
| 646 | +// Parse accepts the current relay configuration format and records which |
| 647 | +// sections were present. ApplyWithOptions merges policy values, replaces each |
| 648 | +// supplied community section, and writes policy, community data, connections |
| 649 | +// and jobs in one storage transaction. An omitted section is left unchanged; |
| 650 | +// an included empty section clears that section. OnApplied runs only after a |
| 651 | +// successful commit, while ValidatePolicy runs before the transaction. |
| 652 | +package configport |
| 653 | diff --git a/internal/daemon/callbacks.go b/internal/daemon/callbacks.go |
| 654 | index cfc5580..f6cf681 100644 |
| 655 | --- a/internal/daemon/callbacks.go |
| 656 | +++ b/internal/daemon/callbacks.go |
| 657 | @@ -604,8 +604,8 @@ func (s *callbackService) callbackOwnerCanSee(ctx context.Context, owner string, |
| 658 | return s.gate.CanSee(ctx, e, relay.Session{PubKeys: []string{owner}, RelayURL: s.relayURL}, nil) |
| 659 | } |
| 660 | |
| 661 | -// Prepare preserves the original planning API for callers that do not yet |
| 662 | -// persist captured candidate IDs alongside the event. |
| 663 | +// Prepare matches current callback registrations and rechecks their access |
| 664 | +// before returning delivery intents. The caller persists the returned work. |
| 665 | func (s *callbackService) Prepare(ctx context.Context, e event.Event) ([]storage.Intent, error) { |
| 666 | ids, err := s.CandidateIDs(ctx, e) |
| 667 | if err != nil { |
| 668 | @@ -614,8 +614,9 @@ func (s *callbackService) Prepare(ctx context.Context, e event.Event) ([]storage |
| 669 | return s.PrepareFor(ctx, e, ids) |
| 670 | } |
| 671 | |
| 672 | -// notifyCallbacks runs after an event is stored. It preserves the legacy |
| 673 | -// best-effort queueing path for callers that do not share the event tx. |
| 674 | +// notifyCallbacks plans and queues delivery in its own transaction, logging |
| 675 | +// planning or storage failures. Ephemeral publication uses this path because |
| 676 | +// its event is carried by the delivery payload. |
| 677 | func (s *callbackService) notifyCallbacks(ctx context.Context, e event.Event) { |
| 678 | intents, err := s.Prepare(ctx, e) |
| 679 | if err != nil { |
| 680 | diff --git a/internal/daemon/callbacks_service.go b/internal/daemon/callbacks_service.go |
| 681 | index 041de27..752c5c3 100644 |
| 682 | --- a/internal/daemon/callbacks_service.go |
| 683 | +++ b/internal/daemon/callbacks_service.go |
| 684 | @@ -13,7 +13,8 @@ import ( |
| 685 | ) |
| 686 | |
| 687 | // callbackService owns callback registration, matching and durable delivery. |
| 688 | -// Its dependencies are explicit so callback state does not enlarge Tenant. |
| 689 | +// It borrows the tenant store, community and gate. Per-registration locks |
| 690 | +// serialize remote delivery; the daemon owns worker startup and shutdown. |
| 691 | type callbackService struct { |
| 692 | store *storage.Store |
| 693 | community *community.Service |
| 694 | @@ -30,6 +31,9 @@ type callbackService struct { |
| 695 | callbackClient *http.Client |
| 696 | } |
| 697 | |
| 698 | +// callbackServiceConfig binds storage, current policy and visibility checks. |
| 699 | +// PublicURL identifies the HTTP relay in deliveries; RelayURL supplies the |
| 700 | +// WebSocket address used by event visibility checks. |
| 701 | type callbackServiceConfig struct { |
| 702 | Store *storage.Store |
| 703 | Community *community.Service |
| 704 | diff --git a/internal/daemon/custom_views.go b/internal/daemon/custom_views.go |
| 705 | index b6c63ea..ee88ea9 100644 |
| 706 | --- a/internal/daemon/custom_views.go |
| 707 | +++ b/internal/daemon/custom_views.go |
| 708 | @@ -31,8 +31,9 @@ import ( |
| 709 | ) |
| 710 | |
| 711 | // customViewService owns custom view definitions, transform execution and |
| 712 | -// artifact storage. Tenant supplies the narrow lifecycle callbacks when it |
| 713 | -// constructs the service. |
| 714 | +// artifact storage. It borrows tenant dependencies and serializes transforms |
| 715 | +// per view. Git is bound after repository initialization and before workers |
| 716 | +// start; Records supplies the signing identity for generated artifacts. |
| 717 | type customViewService struct { |
| 718 | store *storage.Store |
| 719 | community *community.Service |
| 720 | diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go |
| 721 | index 260fcac..4ad2a73 100644 |
| 722 | --- a/internal/daemon/daemon.go |
| 723 | +++ b/internal/daemon/daemon.go |
| 724 | @@ -1,4 +1,3 @@ |
| 725 | -// Package daemon composes the self-hosted services and owns their lifetimes. |
| 726 | package daemon |
| 727 | |
| 728 | import ( |
| 729 | @@ -21,6 +20,10 @@ import ( |
| 730 | "github.com/FelineStateMachine/tinyrelay/internal/templates" |
| 731 | ) |
| 732 | |
| 733 | +// Config supplies process-level paths, public addresses, transport limits and |
| 734 | +// operator permissions. Tenant policy supplies each relay's access rules. |
| 735 | +// DataDir is required; DefaultTenant defaults to "main". PublicURL, when set, |
| 736 | +// is an absolute HTTP or HTTPS address used for externally visible URLs. |
| 737 | type Config struct { |
| 738 | DataDir string |
| 739 | PublicURL string |
| 740 | @@ -39,13 +42,15 @@ type Config struct { |
| 741 | // host-wide push registrations. Tenant policy PushCallbacks is a separate |
| 742 | // owner-controlled allowlist; both approvals are required. |
| 743 | PushCallbackOrigins []string |
| 744 | - // PeerMonitor configures optional NIP-66 liveness probes for explicitly |
| 745 | - // configured peers. It never discovers or crawls public relays. |
| 746 | + // PeerMonitor configures NIP-66 liveness probes for its listed peers. |
| 747 | PeerMonitor *PeerMonitorConfig |
| 748 | } |
| 749 | |
| 750 | type CreateOptions = catalog.CreateOptions |
| 751 | |
| 752 | +// App owns the catalog, active tenants and process-wide service lifecycle. |
| 753 | +// New constructs it, Start starts serving work, and Close releases resources. |
| 754 | +// An HTTP server uses App as its handler. |
| 755 | type App struct { |
| 756 | cfg Config |
| 757 | catalog *catalog.Catalog |
| 758 | @@ -57,6 +62,9 @@ type App struct { |
| 759 | lifecycle lifecycleState |
| 760 | } |
| 761 | |
| 762 | +// New opens catalog and telemetry resources and attempts to resume incomplete |
| 763 | +// tenant creation. The caller owns the returned App and calls Close to release |
| 764 | +// it. Start begins the serving lifecycle. |
| 765 | func New(ctx context.Context, cfg Config) (*App, error) { |
| 766 | if cfg.DataDir == "" { |
| 767 | return nil, errors.New("data directory is required") |
| 768 | @@ -143,6 +151,10 @@ func (a *App) PeerMonitorRun(ctx context.Context) error { |
| 769 | return a.peerMonitor.Run(ctx) |
| 770 | } |
| 771 | |
| 772 | +// Create provisions a tenant from a template, initializes its durable state |
| 773 | +// and marks it ready. A running App also starts its services. The returned |
| 774 | +// metadata identifies the creation record even if a later setup step fails; |
| 775 | +// RecoverCreating resumes incomplete creation from that durable record. |
| 776 | func (a *App) Create(ctx context.Context, opts CreateOptions) (catalog.Tenant, error) { |
| 777 | if template, ok := templates.Find(opts.Template); ok && template.Source == "required" && strings.TrimSpace(opts.Source) == "" { |
| 778 | return catalog.Tenant{}, fmt.Errorf("template %q requires a source relay", opts.Template) |
| 779 | @@ -271,6 +283,9 @@ func (a *App) tenant(ctx context.Context, meta catalog.Tenant, publicURL string) |
| 780 | return t, nil |
| 781 | } |
| 782 | |
| 783 | +// Close stops catalog reconciliation and tenant services, then closes shared |
| 784 | +// resources and releases the process lock. It joins cleanup errors and returns |
| 785 | +// nil on subsequent calls. The caller shuts down its HTTP servers separately. |
| 786 | func (a *App) Close(ctx context.Context) error { |
| 787 | a.mu.Lock() |
| 788 | if a.closed { |
| 789 | @@ -294,6 +309,8 @@ func (a *App) Close(ctx context.Context) error { |
| 790 | return errors.Join(errs...) |
| 791 | } |
| 792 | |
| 793 | +// ServeHTTP resolves the request's tenant or hosted site and delegates to its |
| 794 | +// handler. Health and relay discovery routes are served at the process level. |
| 795 | func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 796 | if r.URL.Path == "/relays" && a.ServeLanding(w, r) { |
| 797 | return |
| 798 | diff --git a/internal/daemon/doc.go b/internal/daemon/doc.go |
| 799 | new file mode 100644 |
| 800 | index 0000000..0b5c12f |
| 801 | --- /dev/null |
| 802 | +++ b/internal/daemon/doc.go |
| 803 | @@ -0,0 +1,52 @@ |
| 804 | +// Package daemon assembles the services of a multitenant Nostr relay. |
| 805 | +// App owns the tenant catalog, telemetry and active Tenant instances. Each |
| 806 | +// Tenant owns one store, its policy snapshot, protocol handlers and background |
| 807 | +// services. HTTP, WebSocket and management requests reach domain services |
| 808 | +// through these adapters. |
| 809 | +// |
| 810 | +// # Process and tenant lifetimes |
| 811 | +// |
| 812 | +// New opens the catalog and telemetry and attempts to resume incomplete tenant |
| 813 | +// creation. |
| 814 | +// Start acquires the serving process lock, opens ready tenants and starts |
| 815 | +// catalog reconciliation. The caller serves App as an HTTP handler and calls |
| 816 | +// Close after stopping its HTTP listeners. Close stops reconciliation, closes |
| 817 | +// tenants, releases shared resources and releases the process lock. The |
| 818 | +// optional peer monitor runs under the context passed to PeerMonitorRun. |
| 819 | +// |
| 820 | +// A tenant initializes community state and its admission gate before building |
| 821 | +// the protocol and feature services. Custom-view storage is ready when Git |
| 822 | +// initialization can promote repository events; the Git service is then bound |
| 823 | +// to the view service. Handler registration completes before worker goroutines |
| 824 | +// start. Tenant.Close stops new operations, cancels and joins background work, |
| 825 | +// closes live connections and drains active operations before closing storage. |
| 826 | +// |
| 827 | +// # Admission and persistence |
| 828 | +// |
| 829 | +// Tenant implements the relay backend and the typed room reader used by the |
| 830 | +// web UI. Execute dispatches management methods under the supplied actor's |
| 831 | +// current authority. HTTP and WebSocket adapters establish that actor through |
| 832 | +// request authentication. Policy returns a read-only snapshot; policy changes |
| 833 | +// are serialized and replace the snapshot after persistence succeeds. |
| 834 | +// |
| 835 | +// Client publication and replication ingestion use eventCommitter to compose |
| 836 | +// event storage, domain projections and durable intents. Client entry points |
| 837 | +// also process membership, moderation and room actions. The relay router owns |
| 838 | +// live event delivery. A repository state stays pending until Git promotion |
| 839 | +// makes its referenced objects available. |
| 840 | +// |
| 841 | +// # Optional work and maintenance |
| 842 | +// |
| 843 | +// callbackService owns webhook registrations and delivery. customViewService |
| 844 | +// owns transform definitions, requests and signed artifacts. eventFollowups |
| 845 | +// captures matching registrations and persists planning work with accepted |
| 846 | +// events. Planning checks current sources and registrations, uses stable work |
| 847 | +// identities and retries within bounded attempts and age. Each feature's |
| 848 | +// handler owns its remote delivery or transform policy. |
| 849 | +// |
| 850 | +// The maintenance gate admits ordinary operations while exclusive maintenance |
| 851 | +// is idle. A maintenance operation blocks new admissions and drains active |
| 852 | +// operations. Nested calls carry the admission in their context, and callers |
| 853 | +// release it with the returned completion function. Workers use the same gate |
| 854 | +// while operating on the tenant's services and store. |
| 855 | +package daemon |
| 856 | diff --git a/internal/daemon/event_followups.go b/internal/daemon/event_followups.go |
| 857 | index 65804e4..477d397 100644 |
| 858 | --- a/internal/daemon/event_followups.go |
| 859 | +++ b/internal/daemon/event_followups.go |
| 860 | @@ -20,9 +20,9 @@ const ( |
| 861 | followupMaxAttempts = 3 |
| 862 | ) |
| 863 | |
| 864 | -// eventFollowups plans optional work without depending on a remote service. |
| 865 | -// Its outbox record commits with the source event; delivery and transforms |
| 866 | -// retain their own bounded retry policies and deterministic intent IDs. |
| 867 | +// eventFollowups turns accepted events into optional callback and transform |
| 868 | +// intents using local state. Its planning record commits with the source |
| 869 | +// event; feature handlers perform remote work using their own retry policies. |
| 870 | type eventFollowups struct { |
| 871 | store *storage.Store |
| 872 | callbacks *callbackService |
| 873 | @@ -31,6 +31,10 @@ type eventFollowups struct { |
| 874 | push func(context.Context, replicationPushFollowupPayload) ([]storage.Intent, error) |
| 875 | } |
| 876 | |
| 877 | +// eventFollowupPayload captures the signed event and matching registrations. |
| 878 | +// Discovery flags retain failed local lookups for retry. Released identifies |
| 879 | +// repository state whose objects are available, and RecoverPush requests the |
| 880 | +// client registration snapshot saved before Git promotion. |
| 881 | type eventFollowupPayload struct { |
| 882 | Event event.Event `json:"event"` |
| 883 | AcceptedAt int64 `json:"accepted_at"` |
| 884 | @@ -47,6 +51,10 @@ func (f *eventFollowups) prepare(ctx context.Context, e event.Event, now int64, |
| 885 | return f.prepareWithPush(ctx, e, now, released, nil) |
| 886 | } |
| 887 | |
| 888 | +// prepareWithPush captures eligible targets or lookup failures in an intent |
| 889 | +// for the event transaction. It returns nil for ephemeral events and events |
| 890 | +// with no eligible work. Repository webhook and view discovery starts when |
| 891 | +// released is true. |
| 892 | func (f *eventFollowups) prepareWithPush(ctx context.Context, e event.Event, now int64, released bool, push *replicationPushFollowupPayload) *storage.Intent { |
| 893 | if event.IsEphemeral(e.Kind) { |
| 894 | return nil |
| 895 | @@ -80,9 +88,9 @@ func (f *eventFollowups) prepareWithPush(ctx context.Context, e event.Event, now |
| 896 | return &storage.Intent{Kind: eventFollowupKind, EventID: e.ID, Target: target, Payload: string(raw)} |
| 897 | } |
| 898 | |
| 899 | -// try runs only local planning after commit. A failure leaves the outbox |
| 900 | -// pending for the worker; success marks planning complete with its derived |
| 901 | -// intents, so a later retry never replays already planned remote work. |
| 902 | +// try runs local planning after commit. A planning failure leaves the parent |
| 903 | +// pending for the worker. Success commits its derived intents and parent |
| 904 | +// completion together. Stable child IDs preserve existing job states on retry. |
| 905 | func (f *eventFollowups) try(ctx context.Context, intent *storage.Intent) { |
| 906 | if intent == nil { |
| 907 | return |
| 908 | @@ -93,6 +101,8 @@ func (f *eventFollowups) try(ctx context.Context, intent *storage.Intent) { |
| 909 | } |
| 910 | } |
| 911 | |
| 912 | +// handle is the queue adapter. It makes exhausted planning failures terminal; |
| 913 | +// the worker owns completion, retry scheduling and claim-token checks. |
| 914 | func (f *eventFollowups) handle(ctx context.Context, job work.Intent) error { |
| 915 | err := f.plan(ctx, job, false) |
| 916 | if err != nil && job.Attempts >= followupMaxAttempts { |
| 917 | @@ -101,6 +111,10 @@ func (f *eventFollowups) handle(ctx context.Context, job work.Intent) error { |
| 918 | return err |
| 919 | } |
| 920 | |
| 921 | +// plan rechecks the source, then persists the work each available feature can |
| 922 | +// prepare. A feature error keeps the parent retryable while successful child |
| 923 | +// intents retain their identity. pending allows the synchronous path to mark |
| 924 | +// an unclaimed parent complete; a running worker completes its own claim. |
| 925 | func (f *eventFollowups) plan(ctx context.Context, job work.Intent, pending bool) error { |
| 926 | var payload eventFollowupPayload |
| 927 | if err := json.Unmarshal([]byte(job.Payload), &payload); err != nil || payload.Event.ID != job.EventID { |
| 928 | @@ -171,8 +185,9 @@ func (f *eventFollowups) releasedPush(ctx context.Context, eventID string) (*rep |
| 929 | return payload.Push, nil |
| 930 | } |
| 931 | |
| 932 | -// Discovery is retried only when the original local lookup failed. The |
| 933 | -// acceptance cutoff prevents a new registration from receiving old events. |
| 934 | +// callbackIntents rechecks captured registrations and retries failed discovery. |
| 935 | +// Recovery requires a creation time strictly before acceptance. Both times |
| 936 | +// have second precision, so uncertain same-second registrations are skipped. |
| 937 | func (f *eventFollowups) callbackIntents(ctx context.Context, payload eventFollowupPayload) ([]storage.Intent, error) { |
| 938 | if payload.DiscoverCallbacks { |
| 939 | ids, err := f.callbacks.CandidateIDs(ctx, payload.Event) |
| 940 | @@ -192,6 +207,8 @@ func (f *eventFollowups) callbackIntents(ctx context.Context, payload eventFollo |
| 941 | return f.callbacks.PrepareFor(ctx, payload.Event, payload.Callbacks) |
| 942 | } |
| 943 | |
| 944 | +// viewIntents rechecks registration generations and source relevance. Failed |
| 945 | +// discovery uses the same strict acceptance cutoff as callbackIntents. |
| 946 | func (f *eventFollowups) viewIntents(ctx context.Context, payload eventFollowupPayload) ([]storage.Intent, error) { |
| 947 | if payload.DiscoverViews { |
| 948 | targets, err := f.views.CandidateTargets(ctx, payload.Event) |
| 949 | diff --git a/internal/daemon/http.go b/internal/daemon/http.go |
| 950 | index 02bde41..386d0a4 100644 |
| 951 | --- a/internal/daemon/http.go |
| 952 | +++ b/internal/daemon/http.go |
| 953 | @@ -500,6 +500,10 @@ func (t *Tenant) manageHTTP(w http.ResponseWriter, r *http.Request) { |
| 954 | writeJSON(w, 200, map[string]any{"result": result}) |
| 955 | } |
| 956 | |
| 957 | +// Execute dispatches a management method with positional JSON parameters. |
| 958 | +// actor is the authenticated public key, or empty for an anonymous caller. |
| 959 | +// The transport establishes actor identity; method handlers apply current |
| 960 | +// membership and resource permissions. Results retain each method's wire shape. |
| 961 | func (t *Tenant) Execute(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) { |
| 962 | ctx, done, admissionErr := t.beginOperation(ctx) |
| 963 | if admissionErr != nil { |
| 964 | diff --git a/internal/daemon/lifecycle.go b/internal/daemon/lifecycle.go |
| 965 | index bb6ef8b..6d0383d 100644 |
| 966 | --- a/internal/daemon/lifecycle.go |
| 967 | +++ b/internal/daemon/lifecycle.go |
| 968 | @@ -27,6 +27,9 @@ type lifecycleState struct { |
| 969 | done chan struct{} |
| 970 | } |
| 971 | |
| 972 | +// AcquireProcessLock exclusively locks dataDir for a serving process. Lock |
| 973 | +// acquisition fails immediately when another process holds it. The caller |
| 974 | +// retains the lock until every tenant using the directory has closed. |
| 975 | func AcquireProcessLock(dataDir string) (*ProcessLock, error) { |
| 976 | if dataDir == "" { |
| 977 | return nil, errors.New("daemon: data directory is required") |
| 978 | @@ -45,6 +48,7 @@ func AcquireProcessLock(dataDir string) (*ProcessLock, error) { |
| 979 | return &ProcessLock{file: file}, nil |
| 980 | } |
| 981 | |
| 982 | +// Close releases the process lock and closes its file handle. |
| 983 | func (l *ProcessLock) Close() error { |
| 984 | if l == nil || l.file == nil { |
| 985 | return nil |
| 986 | @@ -52,8 +56,9 @@ func (l *ProcessLock) Close() error { |
| 987 | return errors.Join(syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN), l.file.Close()) |
| 988 | } |
| 989 | |
| 990 | -// Start eagerly opens permanent ready tenants, so jobs resume after restart |
| 991 | -// without waiting for the first browser or websocket request. |
| 992 | +// Start acquires the process lock, opens ready tenants and starts catalog |
| 993 | +// reconciliation. baseURL supplies their public address. ctx bounds startup; |
| 994 | +// App.Close ends the running lifecycle. Calls on a started App return nil. |
| 995 | func (a *App) Start(ctx context.Context, baseURL string) error { |
| 996 | a.lifecycle.mu.Lock() |
| 997 | defer a.lifecycle.mu.Unlock() |
| 998 | diff --git a/internal/daemon/maintenance.go b/internal/daemon/maintenance.go |
| 999 | index e607310..b55beed 100644 |
| 1000 | --- a/internal/daemon/maintenance.go |
| 1001 | +++ b/internal/daemon/maintenance.go |
| 1002 | @@ -6,12 +6,17 @@ import ( |
| 1003 | "sync" |
| 1004 | ) |
| 1005 | |
| 1006 | +// Admission errors distinguish exclusive maintenance from tenant shutdown. |
| 1007 | +// Callers may retry maintenance refusals after the active operation finishes. |
| 1008 | var ( |
| 1009 | ErrMaintenance = errors.New("retryable: tenant maintenance in progress") |
| 1010 | ErrMaintenanceBusy = errors.New("retryable: tenant maintenance is already active") |
| 1011 | ErrTenantClosing = errors.New("tenant is closing") |
| 1012 | ) |
| 1013 | |
| 1014 | +// maintenanceGate counts admitted operations and grants exclusive maintenance |
| 1015 | +// after those operations drain. Context markers carry admission across nested |
| 1016 | +// service calls. A returned completion function releases each admission once. |
| 1017 | type maintenanceGate struct { |
| 1018 | mu sync.Mutex |
| 1019 | active int |
| 1020 | @@ -27,9 +32,14 @@ type maintenanceContext struct { |
| 1021 | maintenance bool |
| 1022 | } |
| 1023 | |
| 1024 | +// beginOperation admits ordinary tenant work. The caller passes the returned |
| 1025 | +// context to nested operations and defers the completion function on success. |
| 1026 | func (t *Tenant) beginOperation(ctx context.Context) (context.Context, func(), error) { |
| 1027 | return t.maintenance.beginOperation(ctx) |
| 1028 | } |
| 1029 | + |
| 1030 | +// beginMaintenance grants exclusive tenant access after active work drains. |
| 1031 | +// The returned context admits maintenance's nested calls until completion. |
| 1032 | func (t *Tenant) beginMaintenance(ctx context.Context) (context.Context, func(), error) { |
| 1033 | return t.maintenance.beginMaintenance(ctx) |
| 1034 | } |
| 1035 | diff --git a/internal/daemon/private_grasp.go b/internal/daemon/private_grasp.go |
| 1036 | index d3ad27b..99b0814 100644 |
| 1037 | --- a/internal/daemon/private_grasp.go |
| 1038 | +++ b/internal/daemon/private_grasp.go |
| 1039 | @@ -23,10 +23,9 @@ func privateGitProof(r *http.Request) (event.Event, bool) { |
| 1040 | return proof, ok |
| 1041 | } |
| 1042 | |
| 1043 | -// PrivateServiceProfile is the protocol-facing description of a GRASP-08 |
| 1044 | -// tenant. It intentionally contains no tenant name, description, tags, or |
| 1045 | -// repository coordinates: those are private data until the caller has |
| 1046 | -// authenticated and passed the membership check. |
| 1047 | +// PrivateServiceProfile describes a GRASP-08 tenant's configured protocol, |
| 1048 | +// authentication methods, read policy and owner identity. Protocol adapters |
| 1049 | +// choose the fields appropriate to the request's authenticated audience. |
| 1050 | type PrivateServiceProfile struct { |
| 1051 | Enabled bool |
| 1052 | Protocol string |
| 1053 | @@ -35,9 +34,8 @@ type PrivateServiceProfile struct { |
| 1054 | Owner string |
| 1055 | } |
| 1056 | |
| 1057 | -// PrivateAccess is evaluated against the durable community table for every |
| 1058 | -// request. Repository maintainers are deliberately absent from this type; |
| 1059 | -// maintaining a repository does not grant tenant membership. |
| 1060 | +// PrivateAccess records tenant-level authority resolved from current community |
| 1061 | +// membership. Owner, member and moderator roles grant private-service access. |
| 1062 | type PrivateAccess struct { |
| 1063 | PubKey string |
| 1064 | Role string |
| 1065 | @@ -68,8 +66,8 @@ func (t *Tenant) PrivateProfile() PrivateServiceProfile { |
| 1066 | } |
| 1067 | } |
| 1068 | |
| 1069 | -// PrivateAccess resolves current membership. It deliberately does not cache |
| 1070 | -// the result, so removals and bans take effect on the next request. |
| 1071 | +// PrivateAccess reads current membership and bans for each request, so access |
| 1072 | +// changes apply to the next authorization check. |
| 1073 | func (t *Tenant) PrivateAccess(ctx context.Context, pubkey string) (PrivateAccess, error) { |
| 1074 | access := PrivateAccess{PubKey: pubkey} |
| 1075 | if t == nil || !t.PrivateServiceEnabled() { |
| 1076 | diff --git a/internal/daemon/services.go b/internal/daemon/services.go |
| 1077 | index a10f24f..3d80178 100644 |
| 1078 | --- a/internal/daemon/services.go |
| 1079 | +++ b/internal/daemon/services.go |
| 1080 | @@ -33,9 +33,9 @@ import ( |
| 1081 | "github.com/FelineStateMachine/tinyrelay/internal/work" |
| 1082 | ) |
| 1083 | |
| 1084 | -// initServices constructs the doors around a tenant's single durable store. |
| 1085 | -// Each service receives the same policy snapshot function so policy changes |
| 1086 | -// take effect without reopening the tenant. |
| 1087 | +// initServices constructs feature services around the tenant store and policy |
| 1088 | +// reader. Custom-view storage is initialized before Git can promote events; |
| 1089 | +// the resulting Git service is bound before handlers and workers start. |
| 1090 | func (t *Tenant) initServices(ctx context.Context) error { |
| 1091 | var err error |
| 1092 | if err := t.initSessions(ctx); err != nil { |
| 1093 | @@ -224,6 +224,9 @@ func (b backend) ReadAllowed(ctx context.Context, actor string) error { |
| 1094 | return b.tenant.requirePrivateAccess(ctx, actor) |
| 1095 | } |
| 1096 | |
| 1097 | +// closeServices cancels and joins the tenant's worker, scheduler and Git |
| 1098 | +// loops, then returns the worker's terminal queue error, if any. Store and |
| 1099 | +// relay connection cleanup belong to Tenant.Close. |
| 1100 | func (t *Tenant) closeServices(ctx context.Context) error { |
| 1101 | if t.workCancel != nil { |
| 1102 | t.workCancel() |
| 1103 | @@ -614,6 +617,10 @@ func (t *Tenant) workHandlers() map[string]work.Handler { |
| 1104 | return handlers |
| 1105 | } |
| 1106 | |
| 1107 | +// releaseGit publishes a repository state after Git verifies its objects. It |
| 1108 | +// removes the pending marker and persists optional planning in one transaction |
| 1109 | +// under the router's publication lock. Push planning retains the registrations |
| 1110 | +// captured at client acceptance; webhook and view matching occurs at release. |
| 1111 | func (t *Tenant) releaseGit(ctx context.Context, id string) error { |
| 1112 | var followup *storage.Intent |
| 1113 | if t.followups != nil { |
| 1114 | @@ -741,6 +748,10 @@ func (t *Tenant) ingest(ctx context.Context, e event.Event, _ replication.Origin |
| 1115 | return err |
| 1116 | } |
| 1117 | |
| 1118 | +// commitImported applies import admission and commits the shared projections |
| 1119 | +// and optional planning for an event. It returns persistence errors to the |
| 1120 | +// caller, which decides how to handle duplicate and superseded imports. |
| 1121 | +// origin supplies the replication source for planning. |
| 1122 | func (t *Tenant) commitImported(ctx context.Context, e event.Event, origin replication.Origin) error { |
| 1123 | if err := t.gate.Import(ctx, e, time.Now().Unix()); err != nil { |
| 1124 | return err |
| 1125 | diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go |
| 1126 | index 2aef1d6..8ce2e04 100644 |
| 1127 | --- a/internal/daemon/tenant.go |
| 1128 | +++ b/internal/daemon/tenant.go |
| 1129 | @@ -40,6 +40,9 @@ type tenantConfig struct { |
| 1130 | publicURL string |
| 1131 | } |
| 1132 | |
| 1133 | +// Tenant composes one relay's policy, storage, protocol adapters and services. |
| 1134 | +// App creates and owns tenants. Request handlers and workers enter the tenant's |
| 1135 | +// maintenance gate before accessing shared services. |
| 1136 | type Tenant struct { |
| 1137 | app *App |
| 1138 | meta catalog.Tenant |
| 1139 | @@ -146,6 +149,9 @@ func (t *Tenant) authenticated(ctx context.Context, s relay.Session) error { |
| 1140 | return nil |
| 1141 | } |
| 1142 | |
| 1143 | +// Policy returns the current read-only policy snapshot. Its maps and slices |
| 1144 | +// share the snapshot's backing data; callers preserve them and submit changes |
| 1145 | +// through the policy management path. |
| 1146 | func (t *Tenant) Policy() policy.Policy { |
| 1147 | t.mu.RLock() |
| 1148 | defer t.mu.RUnlock() |
| 1149 | @@ -153,6 +159,10 @@ func (t *Tenant) Policy() policy.Policy { |
| 1150 | return t.policy |
| 1151 | } |
| 1152 | func (t *Tenant) RelayURL() string { return strings.Replace(t.publicURL, "http", "ws", 1) } |
| 1153 | + |
| 1154 | +// Close stops new operations, joins background services, closes live relay |
| 1155 | +// connections and waits for active operations before closing the store. If |
| 1156 | +// that wait exceeds ctx, the store stays open for the remaining operations. |
| 1157 | func (t *Tenant) Close(ctx context.Context) error { |
| 1158 | t.maintenance.markClosing() |
| 1159 | servicesErr := t.closeServices(ctx) |
| 1160 | @@ -163,6 +173,12 @@ func (t *Tenant) Close(ctx context.Context) error { |
| 1161 | return errors.Join(servicesErr, routerErr, t.store.Close()) |
| 1162 | } |
| 1163 | |
| 1164 | +// Publish applies write admission and the event kind's persistence or control |
| 1165 | +// action for session s. Its string result is the relay OK message; duplicate |
| 1166 | +// events produce a duplicate message with a nil error. Durable event writes |
| 1167 | +// include projections and follow-up planning intents in their transaction. |
| 1168 | +// Git metadata staging runs after that commit and before a successful reply. |
| 1169 | +// The relay router handles live fan-out of accepted events. |
| 1170 | func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (string, error) { |
| 1171 | ctx, done, admissionErr := t.beginOperation(ctx) |
| 1172 | if admissionErr != nil { |
| 1173 | @@ -234,8 +250,8 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s |
| 1174 | return reason, nil |
| 1175 | } |
| 1176 | |
| 1177 | -// roomScope names the room an event is addressed to when that room is not |
| 1178 | -// the tenant's own group, which keeps its established handling. |
| 1179 | +// roomScope returns the named room for a room event. An empty result selects |
| 1180 | +// the tenant-wide group path, including Marmot group events. |
| 1181 | func (t *Tenant) roomScope(e event.Event) string { |
| 1182 | if e.Kind == event.KIND_MARMOT_GROUP { |
| 1183 | return "" |
| 1184 | @@ -289,6 +305,10 @@ func hexLower(value string) bool { |
| 1185 | return true |
| 1186 | } |
| 1187 | |
| 1188 | +// Query returns the authorized union of the filters for session s. It applies |
| 1189 | +// per-filter limits, removes duplicate IDs and sorts by descending creation |
| 1190 | +// time, then ascending ID. Matching NIP-43 invitations are generated for the |
| 1191 | +// requesting session alongside the stored results. |
| 1192 | func (t *Tenant) Query(ctx context.Context, filters []event.Filter, s relay.Session) ([]event.Event, error) { |
| 1193 | ctx, done, admissionErr := t.beginOperation(ctx) |
| 1194 | if admissionErr != nil { |
| 1195 | @@ -333,6 +353,9 @@ func (t *Tenant) Query(ctx context.Context, filters []event.Filter, s relay.Sess |
| 1196 | return result, nil |
| 1197 | } |
| 1198 | |
| 1199 | +// Count returns a NIP-45 result with the size of Query's authorized union, |
| 1200 | +// using filters with their limits cleared. A supported single-filter request |
| 1201 | +// also receives an HLL sketch. The tenant's count feature must be enabled. |
| 1202 | func (t *Tenant) Count(ctx context.Context, filters []event.Filter, s relay.Session) (any, error) { |
| 1203 | ctx, done, admissionErr := t.beginOperation(ctx) |
| 1204 | if admissionErr != nil { |
| 1205 | @@ -367,6 +390,8 @@ func (t *Tenant) Count(ctx context.Context, filters []event.Filter, s relay.Sess |
| 1206 | return result, nil |
| 1207 | } |
| 1208 | |
| 1209 | +// Sync supplies visible event IDs and timestamps for a reconciliation session. |
| 1210 | +// It clears the filter limit and requires the tenant's sync feature. |
| 1211 | func (t *Tenant) Sync(ctx context.Context, f event.Filter, s relay.Session) ([]syncprotocol.Item, error) { |
| 1212 | ctx, done, admissionErr := t.beginOperation(ctx) |
| 1213 | if admissionErr != nil { |
| 1214 | @@ -388,14 +413,20 @@ func (t *Tenant) Sync(ctx context.Context, f event.Filter, s relay.Session) ([]s |
| 1215 | return items, nil |
| 1216 | } |
| 1217 | |
| 1218 | +// CanRead checks an event against current policy and session authority for |
| 1219 | +// live delivery, including pending state, moderation and room visibility. |
| 1220 | func (t *Tenant) CanRead(e event.Event, s relay.Session) bool { |
| 1221 | return t.gate.CanSee(context.Background(), e, s, nil) |
| 1222 | } |
| 1223 | |
| 1224 | +// CanReadFilter checks live visibility with the subscription filter's context, |
| 1225 | +// including addressed signer messages available during authentication. |
| 1226 | func (t *Tenant) CanReadFilter(e event.Event, s relay.Session, f *event.Filter) bool { |
| 1227 | return t.gate.CanSee(context.Background(), e, s, f) |
| 1228 | } |
| 1229 | |
| 1230 | +// QueryHints returns Query's events and relay completion hints: "finish" or |
| 1231 | +// "more", plus "auth" when authentication can affect the query's visibility. |
| 1232 | func (t *Tenant) QueryHints(ctx context.Context, filters []event.Filter, s relay.Session) ([]event.Event, []string, error) { |
| 1233 | ctx, done, admissionErr := t.beginOperation(ctx) |
| 1234 | if admissionErr != nil { |
| 1235 | diff --git a/internal/domains/doc.go b/internal/domains/doc.go |
| 1236 | new file mode 100644 |
| 1237 | index 0000000..c72bfa2 |
| 1238 | --- /dev/null |
| 1239 | +++ b/internal/domains/doc.go |
| 1240 | @@ -0,0 +1,11 @@ |
| 1241 | +// Package domains manages operator-configured host mappings for one tenant. |
| 1242 | +// |
| 1243 | +// Service writes mappings through catalog and validates host names and site |
| 1244 | +// labels before doing so. Management methods require the configured owner |
| 1245 | +// check; list access is available to every caller of Execute. Check resolves |
| 1246 | +// the mapping and, when configured, performs DNS lookup for the host. |
| 1247 | +// |
| 1248 | +// WebAddressHandler serves the NIP-05A web-address discovery endpoint. It |
| 1249 | +// validates the requested path, applies the configured authorization and read |
| 1250 | +// checks, and asks the host for the storage-backed address filter. |
| 1251 | +package domains |
| 1252 | diff --git a/internal/domains/domains.go b/internal/domains/domains.go |
| 1253 | index 5b69feb..b6492ec 100644 |
| 1254 | --- a/internal/domains/domains.go |
| 1255 | +++ b/internal/domains/domains.go |
| 1256 | @@ -1,6 +1,3 @@ |
| 1257 | -// Package domains implements operator-managed local host mappings. A mapping |
| 1258 | -// is explicit configuration; it never provisions DNS, certificates, or a |
| 1259 | -// wildcard route on the operator's behalf. |
| 1260 | package domains |
| 1261 | |
| 1262 | import ( |
| 1263 | @@ -20,11 +17,18 @@ import ( |
| 1264 | type ResolveDNS func(context.Context, string) ([]net.IP, error) |
| 1265 | |
| 1266 | type Config struct { |
| 1267 | - Catalog *catalog.Catalog |
| 1268 | - TenantID string |
| 1269 | - BaseHost string |
| 1270 | - RelayURL string |
| 1271 | - Owner func(string) bool |
| 1272 | + // Catalog stores the tenant's primary and secondary host mappings. |
| 1273 | + Catalog *catalog.Catalog |
| 1274 | + // TenantID scopes all writes and reads performed by Service. |
| 1275 | + TenantID string |
| 1276 | + // BaseHost is the relay's canonical host and may be empty for a tenant |
| 1277 | + // without a primary host. |
| 1278 | + BaseHost string |
| 1279 | + // RelayURL is returned in discovery responses. |
| 1280 | + RelayURL string |
| 1281 | + // Owner authorizes management calls made through Execute. |
| 1282 | + Owner func(string) bool |
| 1283 | + // ResolveDNS checks the configured host's DNS records when Check runs. |
| 1284 | ResolveDNS ResolveDNS |
| 1285 | } |
| 1286 | |
| 1287 | diff --git a/internal/event/doc.go b/internal/event/doc.go |
| 1288 | new file mode 100644 |
| 1289 | index 0000000..763a8d4 |
| 1290 | --- /dev/null |
| 1291 | +++ b/internal/event/doc.go |
| 1292 | @@ -0,0 +1,5 @@ |
| 1293 | +// Package event defines the relay's Nostr event and filter values. It owns |
| 1294 | +// wire parsing, canonical serialization, Schnorr signing and signature |
| 1295 | +// validation, plus the common kind and tag helpers used by storage and |
| 1296 | +// protocol gates. |
| 1297 | +package event |
| 1298 | diff --git a/internal/event/event.go b/internal/event/event.go |
| 1299 | index 9a6d8ba..0257c8b 100644 |
| 1300 | --- a/internal/event/event.go |
| 1301 | +++ b/internal/event/event.go |
| 1302 | @@ -27,6 +27,8 @@ type Event struct { |
| 1303 | Sig string `json:"sig"` |
| 1304 | } |
| 1305 | |
| 1306 | +// Validate checks the wire shape, canonical ID and Schnorr signature of e. |
| 1307 | +// Kind-specific checks are applied by the protocol gate that owns the kind. |
| 1308 | func Validate(e Event) error { |
| 1309 | if !hex64(e.ID) { |
| 1310 | return errors.New("invalid: bad id") |
| 1311 | @@ -62,6 +64,7 @@ func Validate(e Event) error { |
| 1312 | return nil |
| 1313 | } |
| 1314 | |
| 1315 | +// Parse decodes and validates one complete JSON event object. |
| 1316 | func Parse(raw []byte) (Event, error) { |
| 1317 | if err := requireFields(raw); err != nil { |
| 1318 | return Event{}, err |
| 1319 | @@ -76,6 +79,7 @@ func Parse(raw []byte) (Event, error) { |
| 1320 | return e, nil |
| 1321 | } |
| 1322 | |
| 1323 | +// Canonical encodes e as the JSON representation used on relay boundaries. |
| 1324 | func Canonical(e Event) ([]byte, error) { |
| 1325 | var out bytes.Buffer |
| 1326 | encoder := json.NewEncoder(&out) |
| 1327 | @@ -86,6 +90,8 @@ func Canonical(e Event) ([]byte, error) { |
| 1328 | return bytes.TrimSuffix(out.Bytes(), []byte{'\n'}), nil |
| 1329 | } |
| 1330 | |
| 1331 | +// Tag returns the first value for name, or an empty string when the tag is |
| 1332 | +// absent or has no value. |
| 1333 | func Tag(e Event, name string) string { |
| 1334 | for _, tag := range e.Tags { |
| 1335 | if len(tag) > 0 && tag[0] == name && len(tag) > 1 { |
| 1336 | @@ -95,6 +101,7 @@ func Tag(e Event, name string) string { |
| 1337 | return "" |
| 1338 | } |
| 1339 | |
| 1340 | +// TagValues returns every first value belonging to tags named name. |
| 1341 | func TagValues(e Event, name string) []string { |
| 1342 | values := make([]string, 0) |
| 1343 | for _, tag := range e.Tags { |
| 1344 | @@ -105,6 +112,8 @@ func TagValues(e Event, name string) []string { |
| 1345 | return values |
| 1346 | } |
| 1347 | |
| 1348 | +// Expiration returns the positive Unix timestamp in the expiration tag. |
| 1349 | +// Malformed, missing and nonpositive values return zero. |
| 1350 | func Expiration(e Event) int64 { |
| 1351 | v, err := strconv.ParseInt(Tag(e, "expiration"), 10, 64) |
| 1352 | if err != nil || v <= 0 { |
| 1353 | @@ -113,6 +122,7 @@ func Expiration(e Event) int64 { |
| 1354 | return v |
| 1355 | } |
| 1356 | |
| 1357 | +// Difficulty returns the NIP-13 proof-of-work difficulty encoded by id. |
| 1358 | func Difficulty(id string) int { |
| 1359 | parsed, err := nostr.IDFromHex(id) |
| 1360 | if err != nil { |
| 1361 | @@ -121,6 +131,7 @@ func Difficulty(id string) int { |
| 1362 | return nip13.Difficulty(parsed) |
| 1363 | } |
| 1364 | |
| 1365 | +// CommittedDifficulty returns the target in a nonce tag's third field. |
| 1366 | func CommittedDifficulty(e Event) int { |
| 1367 | for _, tag := range e.Tags { |
| 1368 | if len(tag) > 2 && tag[0] == "nonce" { |
| 1369 | @@ -131,6 +142,8 @@ func CommittedDifficulty(e Event) int { |
| 1370 | return 0 |
| 1371 | } |
| 1372 | |
| 1373 | +// Sign derives the public key, ID and Schnorr signature for e from secretHex. |
| 1374 | +// A nil tag slice is encoded as an empty tag list before signing. |
| 1375 | func Sign(e *Event, secretHex string) error { |
| 1376 | secret, err := decodeSecret(secretHex) |
| 1377 | if err != nil { |
| 1378 | @@ -155,6 +168,7 @@ func Sign(e *Event, secretHex string) error { |
| 1379 | return nil |
| 1380 | } |
| 1381 | |
| 1382 | +// GenerateKey creates a random nonzero 32-byte private key in hexadecimal. |
| 1383 | func GenerateKey() (string, error) { |
| 1384 | for { |
| 1385 | key := make([]byte, 32) |
| 1386 | @@ -167,6 +181,7 @@ func GenerateKey() (string, error) { |
| 1387 | } |
| 1388 | } |
| 1389 | |
| 1390 | +// PublicKey derives the x-only Schnorr public key for secretHex. |
| 1391 | func PublicKey(secretHex string) (string, error) { |
| 1392 | secret, err := decodeSecret(secretHex) |
| 1393 | if err != nil { |
| 1394 | @@ -176,9 +191,16 @@ func PublicKey(secretHex string) (string, error) { |
| 1395 | return hex.EncodeToString(schnorr.SerializePubKey(pubkey)), nil |
| 1396 | } |
| 1397 | |
| 1398 | -func IsEphemeral(k int) bool { return k >= 20000 && k < 30000 } |
| 1399 | +// IsEphemeral reports whether k is in Nostr's ephemeral kind range. |
| 1400 | +func IsEphemeral(k int) bool { return k >= 20000 && k < 30000 } |
| 1401 | + |
| 1402 | +// IsReplaceable reports whether k is a replaceable event kind. |
| 1403 | func IsReplaceable(k int) bool { return k == 0 || k == 3 || k >= 10000 && k < 20000 } |
| 1404 | + |
| 1405 | +// IsAddressable reports whether k is an addressable event kind. |
| 1406 | func IsAddressable(k int) bool { return k >= 30000 && k < 40000 } |
| 1407 | + |
| 1408 | +// IsPrivate reports whether k carries recipient-scoped content. |
| 1409 | func IsPrivate(k int) bool { |
| 1410 | return k == 4 || k == 13 || k == KIND_WRAP || k == 21059 || k == KIND_NOSTR_CONNECT || k == KIND_PUSH_REGISTRATION |
| 1411 | } |
| 1412 | diff --git a/internal/event/filter.go b/internal/event/filter.go |
| 1413 | index 0cb01f7..8f804d2 100644 |
| 1414 | --- a/internal/event/filter.go |
| 1415 | +++ b/internal/event/filter.go |
| 1416 | @@ -6,6 +6,12 @@ import ( |
| 1417 | "strings" |
| 1418 | ) |
| 1419 | |
| 1420 | +// Filter describes the event fields and tags a subscription or query accepts. |
| 1421 | +// Fields combine with AND; values within a list are alternatives. Nil IDs, |
| 1422 | +// Authors and Kinds leave that field unrestricted, while an empty non-nil list |
| 1423 | +// matches nothing. Tags uses names without the wire prefix #. Since and Until |
| 1424 | +// are inclusive Unix timestamps. Limit controls a historical result batch; |
| 1425 | +// Matches evaluates each event independently of that limit. |
| 1426 | type Filter struct { |
| 1427 | IDs []string `json:"ids,omitempty"` |
| 1428 | Authors []string `json:"authors,omitempty"` |
| 1429 | @@ -17,6 +23,8 @@ type Filter struct { |
| 1430 | Search string `json:"search,omitempty"` |
| 1431 | } |
| 1432 | |
| 1433 | +// ParseFilter decodes supported Nostr filter fields, including #tag keys, and |
| 1434 | +// checks their JSON types. |
| 1435 | func ParseFilter(raw []byte) (Filter, error) { |
| 1436 | var values map[string]json.RawMessage |
| 1437 | if err := json.Unmarshal(raw, &values); err != nil { |
| 1438 | @@ -79,6 +87,7 @@ func ParseFilter(raw []byte) (Filter, error) { |
| 1439 | return f, nil |
| 1440 | } |
| 1441 | |
| 1442 | +// MarshalJSON encodes tag constraints with their Nostr #name wire keys. |
| 1443 | func (f Filter) MarshalJSON() ([]byte, error) { |
| 1444 | values := make(map[string]json.RawMessage, 7+len(f.Tags)) |
| 1445 | if f.IDs != nil { |
| 1446 | @@ -108,6 +117,8 @@ func (f Filter) MarshalJSON() ([]byte, error) { |
| 1447 | return json.Marshal(values) |
| 1448 | } |
| 1449 | |
| 1450 | +// Matches reports whether e satisfies every populated constraint in f. |
| 1451 | +// Search terms are matched against public content and exclude private kinds. |
| 1452 | func Matches(f Filter, e Event) bool { |
| 1453 | if f.IDs != nil && !contains(f.IDs, e.ID) || f.Authors != nil && !contains(f.Authors, e.PubKey) || f.Kinds != nil && !containsInt(f.Kinds, e.Kind) { |
| 1454 | return false |
| 1455 | @@ -136,6 +147,8 @@ func Matches(f Filter, e Event) bool { |
| 1456 | return true |
| 1457 | } |
| 1458 | |
| 1459 | +// SearchTerms splits a search query into terms and drops protocol-style |
| 1460 | +// name:value tokens. |
| 1461 | func SearchTerms(query string) []string { return slicesFilter(strings.Fields(query)) } |
| 1462 | |
| 1463 | func slicesFilter(words []string) []string { |
| 1464 | diff --git a/internal/event/kinds.go b/internal/event/kinds.go |
| 1465 | index 67ad632..da02910 100644 |
| 1466 | --- a/internal/event/kinds.go |
| 1467 | +++ b/internal/event/kinds.go |
| 1468 | @@ -80,16 +80,19 @@ var JobFeedbackStatuses = []string{"payment-required", "processing", "error", "s |
| 1469 | // JobInputTypes is the NIP-90 vocabulary of the i tag's input type. |
| 1470 | var JobInputTypes = []string{"url", "event", "job", "text"} |
| 1471 | |
| 1472 | +// IsJobRequest reports whether kind is a NIP-90 request kind. |
| 1473 | func IsJobRequest(kind int) bool { return kind >= KIND_JOB_REQUEST_MIN && kind <= KIND_JOB_REQUEST_MAX } |
| 1474 | -func IsJobResult(kind int) bool { return kind >= KIND_JOB_RESULT_MIN && kind <= KIND_JOB_RESULT_MAX } |
| 1475 | + |
| 1476 | +// IsJobResult reports whether kind is a NIP-90 result kind. |
| 1477 | +func IsJobResult(kind int) bool { return kind >= KIND_JOB_RESULT_MIN && kind <= KIND_JOB_RESULT_MAX } |
| 1478 | |
| 1479 | // IsJobKind reports whether the kind is a job request, result or feedback. |
| 1480 | func IsJobKind(kind int) bool { |
| 1481 | return IsJobRequest(kind) || IsJobResult(kind) || kind == KIND_JOB_FEEDBACK |
| 1482 | } |
| 1483 | |
| 1484 | -// JobResultKind returns the result kind for a request kind, or 0 when the |
| 1485 | -// kind is not a job request. |
| 1486 | +// JobResultKind returns the result kind for a request kind, or 0 for other |
| 1487 | +// kinds. |
| 1488 | func JobResultKind(request int) int { |
| 1489 | if !IsJobRequest(request) { |
| 1490 | return 0 |
| 1491 | @@ -97,6 +100,8 @@ func JobResultKind(request int) int { |
| 1492 | return request + 1000 |
| 1493 | } |
| 1494 | |
| 1495 | +// IsJobFeedbackStatus reports whether status is in the NIP-90 feedback |
| 1496 | +// vocabulary. |
| 1497 | func IsJobFeedbackStatus(status string) bool { |
| 1498 | for _, known := range JobFeedbackStatuses { |
| 1499 | if known == status { |
| 1500 | @@ -106,6 +111,7 @@ func IsJobFeedbackStatus(status string) bool { |
| 1501 | return false |
| 1502 | } |
| 1503 | |
| 1504 | +// IsJobInputType reports whether kind is a supported NIP-90 input type. |
| 1505 | func IsJobInputType(kind string) bool { |
| 1506 | for _, known := range JobInputTypes { |
| 1507 | if known == kind { |
| 1508 | diff --git a/internal/gates/doc.go b/internal/gates/doc.go |
| 1509 | new file mode 100644 |
| 1510 | index 0000000..cdc74c2 |
| 1511 | --- /dev/null |
| 1512 | +++ b/internal/gates/doc.go |
| 1513 | @@ -0,0 +1,4 @@ |
| 1514 | +// Package gates applies tenant policy and protocol admission rules to relay |
| 1515 | +// events. Gate checks are shared by client writes, host imports, queries and |
| 1516 | +// live delivery, while membership and storage services supply durable state. |
| 1517 | +package gates |
| 1518 | diff --git a/internal/gates/gates.go b/internal/gates/gates.go |
| 1519 | index 2ed14c0..416d234 100644 |
| 1520 | --- a/internal/gates/gates.go |
| 1521 | +++ b/internal/gates/gates.go |
| 1522 | @@ -1,4 +1,3 @@ |
| 1523 | -// Package gates implements policy checks shared by ingest, query and live fan-out. |
| 1524 | package gates |
| 1525 | |
| 1526 | import ( |
| 1527 | @@ -16,6 +15,8 @@ import ( |
| 1528 | "github.com/dlclark/regexp2" |
| 1529 | ) |
| 1530 | |
| 1531 | +// Config supplies the policy and optional durable services used by Gate. |
| 1532 | +// Store and Community enable checks that depend on persisted relay state. |
| 1533 | type Config struct { |
| 1534 | Store *storage.Store |
| 1535 | Community *community.Service |
| 1536 | @@ -23,6 +24,7 @@ type Config struct { |
| 1537 | Slug string |
| 1538 | } |
| 1539 | |
| 1540 | +// Gate evaluates event admission and visibility for one tenant configuration. |
| 1541 | type Gate struct { |
| 1542 | cfg Config |
| 1543 | agents agentLimiter |
| 1544 | @@ -116,6 +118,8 @@ func New(cfg Config) (*Gate, error) { |
| 1545 | return &Gate{cfg: cfg}, nil |
| 1546 | } |
| 1547 | |
| 1548 | +// Write validates a client-published event against protocol shape, tenant |
| 1549 | +// policy, membership, bans, room access, retention and proof-of-work rules. |
| 1550 | func (g *Gate) Write(ctx context.Context, e event.Event, s relay.Session, now int64) error { |
| 1551 | p := g.cfg.Policy() |
| 1552 | if e.Kind == event.KIND_AUTH { |
| 1553 | @@ -286,9 +290,9 @@ func inboxAdmission(p policy.Policy, e event.Event) bool { |
| 1554 | return false |
| 1555 | } |
| 1556 | |
| 1557 | -// Import applies only the common event gate used by host-side pulls. It does |
| 1558 | -// not apply client admission rules, kind allowlists, retention, PoW, or create |
| 1559 | -// delivery fan-out; the imported event's origin must be recorded by storage. |
| 1560 | +// Import validates an event received from another host using protocol shape, |
| 1561 | +// safety, ban, agent and room existence checks. The caller records its origin |
| 1562 | +// in storage after this common gate succeeds. |
| 1563 | func (g *Gate) Import(ctx context.Context, e event.Event, now int64) error { |
| 1564 | p := g.cfg.Policy() |
| 1565 | if err := event.Validate(e); err != nil { |
| 1566 | @@ -466,6 +470,8 @@ func (g *Gate) kindRules(ctx context.Context, p policy.Policy, kind int) (bool, |
| 1567 | return allowedAny, blocked, nil |
| 1568 | } |
| 1569 | |
| 1570 | +// RetentionDays returns the configured retention period for kind, using the |
| 1571 | +// wildcard rule when no exact rule exists. |
| 1572 | func (g *Gate) RetentionDays(ctx context.Context, kind int) int { |
| 1573 | if g.cfg.Community == nil { |
| 1574 | wildcard := 0 |
| 1575 | @@ -606,6 +612,8 @@ func (g *Gate) guestPass(ctx context.Context, p policy.Policy, e event.Event, a |
| 1576 | return false |
| 1577 | } |
| 1578 | |
| 1579 | +// Read validates a subscription request and returns an AUTH hint when a |
| 1580 | +// subscriber may receive private or members-only results after authenticating. |
| 1581 | func (g *Gate) Read(ctx context.Context, filters []event.Filter, s relay.Session) (bool, error) { |
| 1582 | p := g.cfg.Policy() |
| 1583 | if p.Features.Signer && len(filters) > 0 { |
| 1584 | @@ -662,6 +670,8 @@ func (g *Gate) Read(ctx context.Context, filters []event.Filter, s relay.Session |
| 1585 | return authHint, nil |
| 1586 | } |
| 1587 | |
| 1588 | +// CanSee reports whether session s may receive e. It applies hidden-event, |
| 1589 | +// ban, repository privacy, policy, room and recipient checks. |
| 1590 | func (g *Gate) CanSee(ctx context.Context, e event.Event, s relay.Session, f *event.Filter) bool { |
| 1591 | p := g.cfg.Policy() |
| 1592 | if g.cfg.Store != nil { |
| 1593 | diff --git a/internal/gitrelay/backup_import.go b/internal/gitrelay/backup_import.go |
| 1594 | index d5c3acb..8a515b1 100644 |
| 1595 | --- a/internal/gitrelay/backup_import.go |
| 1596 | +++ b/internal/gitrelay/backup_import.go |
| 1597 | @@ -19,6 +19,7 @@ import ( |
| 1598 | "strings" |
| 1599 | ) |
| 1600 | |
| 1601 | +// BackupObject is one validated Git object and its compressed content chunks. |
| 1602 | type BackupObject struct { |
| 1603 | OID string `json:"oid"` |
| 1604 | Type string `json:"type"` |
| 1605 | @@ -26,6 +27,7 @@ type BackupObject struct { |
| 1606 | Chunks []string `json:"chunks"` |
| 1607 | } |
| 1608 | |
| 1609 | +// BackupRepository contains the objects and refs for one imported repository. |
| 1610 | type BackupRepository struct { |
| 1611 | Key string `json:"key"` |
| 1612 | Owner string `json:"owner"` |
| 1613 | @@ -35,11 +37,13 @@ type BackupRepository struct { |
| 1614 | Refs map[string]string `json:"refs"` |
| 1615 | } |
| 1616 | |
| 1617 | +// GitBackup is the complete bind.ws/git-sqlite/1 import document. |
| 1618 | type GitBackup struct { |
| 1619 | Format string `json:"format"` |
| 1620 | Repositories []BackupRepository `json:"repositories"` |
| 1621 | } |
| 1622 | |
| 1623 | +// ValidateGitBackup validates an import document without writing Git state. |
| 1624 | func ValidateGitBackup(input []byte) (GitBackup, error) { |
| 1625 | var backup GitBackup |
| 1626 | if err := json.Unmarshal(input, &backup); err != nil { |
| 1627 | diff --git a/internal/gitrelay/doc.go b/internal/gitrelay/doc.go |
| 1628 | new file mode 100644 |
| 1629 | index 0000000..f16166c |
| 1630 | --- /dev/null |
| 1631 | +++ b/internal/gitrelay/doc.go |
| 1632 | @@ -0,0 +1,24 @@ |
| 1633 | +// Package gitrelay coordinates signed Nostr repository events with native |
| 1634 | +// Git object storage and smart HTTP. Signed events establish repository refs, |
| 1635 | +// metadata and maintainer authority. |
| 1636 | +// |
| 1637 | +// GitRelay stores bare repositories under Config.Root. Journal files under |
| 1638 | +// .tinyrelay/journal make event-to-ref transitions recoverable, while pending |
| 1639 | +// state files keep a signed ref announcement out of the visible repository |
| 1640 | +// until every advertised tip object is present. New creates the root, |
| 1641 | +// recovers journals and reloads repository metadata from the event store. |
| 1642 | +// Synchronization runs through Service.Tick or transport callbacks invoked by |
| 1643 | +// the caller. |
| 1644 | +// |
| 1645 | +// Validate checks event and maintainer admission. After the caller durably |
| 1646 | +// stores the event, CommitAfterStore stages the corresponding Git transition. |
| 1647 | +// CommitAfterStoreNoNotify serves callers already holding the host publication |
| 1648 | +// fence. PromotePending completes a receive-pack repair and invokes OnPromote |
| 1649 | +// after state becomes visible. |
| 1650 | +// |
| 1651 | +// ServeHTTP exposes Git smart HTTP for public and private repositories. |
| 1652 | +// Private requests pass through AuthorizeHTTP. GRASP-08 private peer traffic |
| 1653 | +// is limited to Config.PrivatePeers and is signed by HTTPAuth. GitSync and |
| 1654 | +// EventSync supply synchronization transports; the daemon supplies the relay |
| 1655 | +// client and WebSocket lifecycle. |
| 1656 | +package gitrelay |
| 1657 | diff --git a/internal/gitrelay/gitrelay.go b/internal/gitrelay/gitrelay.go |
| 1658 | index 310d6b9..e030113 100644 |
| 1659 | --- a/internal/gitrelay/gitrelay.go |
| 1660 | +++ b/internal/gitrelay/gitrelay.go |
| 1661 | @@ -1,6 +1,3 @@ |
| 1662 | -// Package gitrelay provides the self-hosted GRASP smart-HTTP boundary. Git |
| 1663 | -// own receive-pack/upload-pack remain the object and pack implementation; |
| 1664 | -// signed Nostr repository events remain the authority for refs. |
| 1665 | package gitrelay |
| 1666 | |
| 1667 | import ( |
| 1668 | @@ -17,6 +14,7 @@ import ( |
| 1669 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 1670 | ) |
| 1671 | |
| 1672 | +// Repository is the resolved authority and Git state for one hosted project. |
| 1673 | type Repository struct { |
| 1674 | Owner string |
| 1675 | Identifier string |
| 1676 | @@ -48,6 +46,7 @@ type MaintainerSource interface { |
| 1677 | IsMaintainer(ctx context.Context, r Repository, pubkey string) bool |
| 1678 | } |
| 1679 | |
| 1680 | +// Config supplies the event store, Git root, authorization and transport hooks. |
| 1681 | type Config struct { |
| 1682 | Store *storage.Store |
| 1683 | Root string |
| 1684 | @@ -63,15 +62,15 @@ type Config struct { |
| 1685 | // advertised clone URLs. It is optional for an embedded relay. |
| 1686 | ServiceURL string |
| 1687 | // PublicURL is the canonical HTTPS URL used in repository advertisements; |
| 1688 | - // ServiceURL remains accepted as a compatibility alias. |
| 1689 | + // ServiceURL is used when PublicURL is empty. |
| 1690 | PublicURL string |
| 1691 | // EnableGRASP06 enables the alternative PR repository surface. It is kept |
| 1692 | // opt-in because publishing a capability creates an interoperability |
| 1693 | // promise beyond the base GRASP-01 HTTP endpoint. |
| 1694 | EnableGRASP06 bool |
| 1695 | - // AllowMissingObjects is retained for configuration compatibility. Missing |
| 1696 | - // objects always enter the durable pending path; this flag no longer makes |
| 1697 | - // incomplete state visible. |
| 1698 | + // AllowMissingObjects is ignored. |
| 1699 | + // Missing objects enter the durable pending path and stay hidden until |
| 1700 | + // receive-pack or repair supplies every advertised tip. |
| 1701 | AllowMissingObjects bool |
| 1702 | // AllowPrivateRelays permits operator-controlled private DNS targets for |
| 1703 | // self-hosted networks. Public deployments should leave it disabled. |
| 1704 | @@ -92,6 +91,7 @@ type Config struct { |
| 1705 | OnPromote func(context.Context, string, Repository) error |
| 1706 | } |
| 1707 | |
| 1708 | +// GitRelay coordinates signed repository events with native bare repositories. |
| 1709 | type GitRelay struct { |
| 1710 | store *storage.Store |
| 1711 | root string |
| 1712 | diff --git a/internal/mcp/doc.go b/internal/mcp/doc.go |
| 1713 | new file mode 100644 |
| 1714 | index 0000000..9ec13a1 |
| 1715 | --- /dev/null |
| 1716 | +++ b/internal/mcp/doc.go |
| 1717 | @@ -0,0 +1,11 @@ |
| 1718 | +// Package mcp serves the Model Context Protocol revision 2026-07-28 through |
| 1719 | +// its stateless Streamable HTTP binding. |
| 1720 | +// |
| 1721 | +// Server accepts one JSON-RPC request or notification per POST. Protocol |
| 1722 | +// version, method, tool name and selected resource name are mirrored in HTTP |
| 1723 | +// headers and checked against the JSON body. Registry keeps tools in |
| 1724 | +// registration order, validates their JSON Schema subset, and invokes each |
| 1725 | +// Handler with an authenticated actor and request context. Tool failures are |
| 1726 | +// returned as execution results; transport and validation failures use |
| 1727 | +// JSON-RPC errors. |
| 1728 | +package mcp |
| 1729 | diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go |
| 1730 | index 086cc4d..cdbadde 100644 |
| 1731 | --- a/internal/mcp/mcp.go |
| 1732 | +++ b/internal/mcp/mcp.go |
| 1733 | @@ -1,7 +1,3 @@ |
| 1734 | -// Package mcp implements the Model Context Protocol, revision 2026-07-28, in |
| 1735 | -// the stateless form of its Streamable HTTP binding: one endpoint that |
| 1736 | -// accepts POST, no sessions, no initialize handshake, and every request |
| 1737 | -// carrying its protocol version in both the headers and the body. |
| 1738 | package mcp |
| 1739 | |
| 1740 | import ( |
| 1741 | @@ -14,9 +10,7 @@ import ( |
| 1742 | // Version is the protocol revision this package implements. |
| 1743 | const Version = "2026-07-28" |
| 1744 | |
| 1745 | -// SupportedVersions lists the protocol revisions the server accepts. Older |
| 1746 | -// revisions negotiated a session with an initialize handshake and are not |
| 1747 | -// served. |
| 1748 | +// SupportedVersions lists the protocol revisions the server accepts. |
| 1749 | var SupportedVersions = []string{Version} |
| 1750 | |
| 1751 | // Header names mirrored from the JSON-RPC body so intermediaries can route |
| 1752 | diff --git a/internal/mcp/server.go b/internal/mcp/server.go |
| 1753 | index f65cddc..f82efd3 100644 |
| 1754 | --- a/internal/mcp/server.go |
| 1755 | +++ b/internal/mcp/server.go |
| 1756 | @@ -62,8 +62,6 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.Handle(w, |
| 1757 | func (s *Server) Handle(w http.ResponseWriter, r *http.Request) Observation { |
| 1758 | w.Header().Set("Cache-Control", "no-store") |
| 1759 | if r.Method != http.MethodPost { |
| 1760 | - // GET opened a server stream and DELETE ended a session in earlier |
| 1761 | - // revisions. Neither exists in this one. |
| 1762 | w.Header().Set("Allow", "POST") |
| 1763 | http.Error(w, "method not allowed: the MCP endpoint accepts POST", http.StatusMethodNotAllowed) |
| 1764 | return Observation{Outcome: OutcomeInvalid, Status: http.StatusMethodNotAllowed} |
| 1765 | diff --git a/internal/policy/access.go b/internal/policy/access.go |
| 1766 | index afa16cf..96f7b0d 100644 |
| 1767 | --- a/internal/policy/access.go |
| 1768 | +++ b/internal/policy/access.go |
| 1769 | @@ -13,6 +13,8 @@ type Access struct { |
| 1770 | Owner bool |
| 1771 | } |
| 1772 | |
| 1773 | +// CanRead applies the read rule, kind blocks, private repository rules and |
| 1774 | +// recipient addressing to one event. |
| 1775 | func CanRead(p Policy, e event.Event, a Access) bool { |
| 1776 | if containsInt(p.BlockedKinds, e.Kind) { |
| 1777 | return false |
| 1778 | @@ -44,6 +46,8 @@ func IsPrivateRepository(e event.Event) bool { |
| 1779 | return false |
| 1780 | } |
| 1781 | |
| 1782 | +// CanWrite applies kind blocks, allowlists and the tenant write rule to one |
| 1783 | +// event. |
| 1784 | func CanWrite(p Policy, e event.Event, a Access) bool { |
| 1785 | if containsInt(p.BlockedKinds, e.Kind) { |
| 1786 | return false |
| 1787 | diff --git a/internal/policy/doc.go b/internal/policy/doc.go |
| 1788 | new file mode 100644 |
| 1789 | index 0000000..43693b4 |
| 1790 | --- /dev/null |
| 1791 | +++ b/internal/policy/doc.go |
| 1792 | @@ -0,0 +1,4 @@ |
| 1793 | +// Package policy defines the durable tenant policy and the access decisions |
| 1794 | +// derived from it. Policies are JSON values validated at their boundary; |
| 1795 | +// access checks receive the caller's authenticated keys and membership facts. |
| 1796 | +package policy |
| 1797 | diff --git a/internal/policy/policy.go b/internal/policy/policy.go |
| 1798 | index 40ac263..07142b6 100644 |
| 1799 | --- a/internal/policy/policy.go |
| 1800 | +++ b/internal/policy/policy.go |
| 1801 | @@ -1,4 +1,3 @@ |
| 1802 | -// Package policy contains the durable, host-independent policy for a relay. |
| 1803 | package policy |
| 1804 | |
| 1805 | import ( |
| 1806 | @@ -10,11 +9,13 @@ import ( |
| 1807 | "strings" |
| 1808 | ) |
| 1809 | |
| 1810 | +// Succession describes the account handoff after a period of inactivity. |
| 1811 | type Succession struct { |
| 1812 | Heir string `json:"heir"` |
| 1813 | AfterDays int `json:"afterDays"` |
| 1814 | } |
| 1815 | |
| 1816 | +// Notify selects tenant notification categories. |
| 1817 | type Notify struct { |
| 1818 | Reports bool `json:"reports"` |
| 1819 | Jobs bool `json:"jobs"` |
| 1820 | @@ -22,16 +23,19 @@ type Notify struct { |
| 1821 | Digest bool `json:"digest"` |
| 1822 | } |
| 1823 | |
| 1824 | +// MemberInvites controls invitation depth and quota. |
| 1825 | type MemberInvites struct { |
| 1826 | Depth int `json:"depth"` |
| 1827 | Quota int `json:"quota"` |
| 1828 | } |
| 1829 | |
| 1830 | +// Sites controls hosted site publication and mirroring. |
| 1831 | type Sites struct { |
| 1832 | Enabled bool `json:"enabled"` |
| 1833 | Mirror bool `json:"mirror"` |
| 1834 | } |
| 1835 | |
| 1836 | +// Features selects relay protocols and tenant capabilities. |
| 1837 | type Features struct { |
| 1838 | Search string `json:"search"` |
| 1839 | Sync bool `json:"sync"` |
| 1840 | @@ -57,10 +61,12 @@ type Features struct { |
| 1841 | Jobs bool `json:"jobs"` |
| 1842 | } |
| 1843 | |
| 1844 | +// Delivery controls event delivery services. |
| 1845 | type Delivery struct { |
| 1846 | Enabled bool `json:"enabled"` |
| 1847 | } |
| 1848 | |
| 1849 | +// Inbox controls targeted inbox admission. |
| 1850 | type Inbox struct { |
| 1851 | Targeted bool `json:"targeted"` |
| 1852 | } |
| 1853 | @@ -71,6 +77,7 @@ type FileLimits struct { |
| 1854 | UserStorageBytes int64 `json:"userStorageBytes"` |
| 1855 | } |
| 1856 | |
| 1857 | +// CustomHost records a tenant-managed host and its provisioning state. |
| 1858 | type CustomHost struct { |
| 1859 | Host string `json:"host"` |
| 1860 | ID string `json:"id"` |
| 1861 | @@ -80,13 +87,15 @@ type CustomHost struct { |
| 1862 | SSLStatus string `json:"sslStatus"` |
| 1863 | } |
| 1864 | |
| 1865 | +// RetentionRule assigns a retention period to one kind; negative kinds are |
| 1866 | +// used as the wildcard rule by the gate. |
| 1867 | type RetentionRule struct { |
| 1868 | Kind int `json:"kind"` |
| 1869 | Days int `json:"days"` |
| 1870 | } |
| 1871 | |
| 1872 | -// Policy is the tenant policy. Host capacity and scheduling controls are |
| 1873 | -// intentionally outside this type; they are not tenant entitlements. |
| 1874 | +// Policy is the durable policy for one tenant. It combines identity and |
| 1875 | +// presentation settings, access rules, feature switches and storage limits. |
| 1876 | type Policy struct { |
| 1877 | Owner string `json:"owner"` |
| 1878 | CustomHosts []CustomHost `json:"customHosts"` |
| 1879 | @@ -142,9 +151,8 @@ const DefaultRooms = 64 |
| 1880 | // name one. |
| 1881 | const DefaultCallbacks = 4 |
| 1882 | |
| 1883 | -// UnmarshalJSON keeps the room and callback allowances and the long task |
| 1884 | -// switch at their defaults when a stored policy predates the fields, so |
| 1885 | -// existing tenants do not lose room creation, callbacks or job traffic. |
| 1886 | +// UnmarshalJSON applies the current defaults for room, callback and job |
| 1887 | +// settings before decoding a stored policy. |
| 1888 | func (p *Policy) UnmarshalJSON(data []byte) error { |
| 1889 | type plain Policy |
| 1890 | decoded := plain{Rooms: DefaultRooms, Callbacks: DefaultCallbacks} |
| 1891 | @@ -162,6 +170,8 @@ func Defaults(owner string) Policy { |
| 1892 | Notify: Notify{}, Views: map[string]string{}, Tags: []string{}, LanguageTags: []string{}, RelayCountries: []string{}, OpenKinds: []int{}, BlockedWords: []string{}, PushCallbacks: []string{}, Delivery: Delivery{}} |
| 1893 | } |
| 1894 | |
| 1895 | +// Validate checks policy enum values, URL fields, feature dependencies and |
| 1896 | +// numeric bounds. |
| 1897 | func Validate(p Policy) error { |
| 1898 | if p.FileLimits.MaxFileBytes < 0 || p.FileLimits.UserStorageBytes < 0 { |
| 1899 | return errors.New("fileLimits: byte allowances cannot be negative") |
| 1900 | diff --git a/internal/policy/privacy.go b/internal/policy/privacy.go |
| 1901 | index 714ef0e..3851745 100644 |
| 1902 | --- a/internal/policy/privacy.go |
| 1903 | +++ b/internal/policy/privacy.go |
| 1904 | @@ -12,13 +12,11 @@ import ( |
| 1905 | // replaceable repository state events. It keeps the policy package independent |
| 1906 | // from the storage implementation. |
| 1907 | type PrivateRepositoryLookup interface { |
| 1908 | - IsPrivateRepository(context.Context, string, string) (bool, error) |
| 1909 | + IsPrivateRepository(ctx context.Context, pubkey, identifier string) (bool, error) |
| 1910 | } |
| 1911 | |
| 1912 | -// PrivateServiceEnabled reports whether the tenant can serve the complete |
| 1913 | -// GRASP-08 private service contract. Valid policies already enforce these |
| 1914 | -// implications, but keeping the predicate here gives callers one definition |
| 1915 | -// when inspecting policy values during startup or migration. |
| 1916 | +// PrivateServiceEnabled reports whether the tenant has the complete GRASP-08 |
| 1917 | +// private service contract enabled. |
| 1918 | func (p Policy) PrivateServiceEnabled() bool { |
| 1919 | return p.Features.Grasp && p.Features.Grasp08 && p.Reads == "members" |
| 1920 | } |
| 1921 | diff --git a/internal/records/doc.go b/internal/records/doc.go |
| 1922 | new file mode 100644 |
| 1923 | index 0000000..30bc75a |
| 1924 | --- /dev/null |
| 1925 | +++ b/internal/records/doc.go |
| 1926 | @@ -0,0 +1,15 @@ |
| 1927 | +// Package records owns durable relay identity, signed protocol records, |
| 1928 | +// projections, views, notifications and succession state. |
| 1929 | +// |
| 1930 | +// New initializes the records schema and loads the relay signing key from the |
| 1931 | +// tenant settings. Config.Community is required because membership and |
| 1932 | +// moderation projections are part of the records service's read contract. |
| 1933 | +// |
| 1934 | +// Signed records that advance the durable records clock run under the service |
| 1935 | +// signing lock and persist the clock before invoking Config.OnGenerated. |
| 1936 | +// OnGenerated runs after that transaction commits and commonly persists or |
| 1937 | +// publishes the returned event. Its error is returned to the caller; the clock |
| 1938 | +// remains durable even when the callback fails. |
| 1939 | +// GenerateEphemeral signs a response without advancing the durable clock or |
| 1940 | +// invoking OnGenerated. |
| 1941 | +package records |
| 1942 | diff --git a/internal/records/records.go b/internal/records/records.go |
| 1943 | index aab41bd..1aed113 100644 |
| 1944 | --- a/internal/records/records.go |
| 1945 | +++ b/internal/records/records.go |
| 1946 | @@ -1,6 +1,3 @@ |
| 1947 | -// Package records owns the durable records that a relay signs about itself. |
| 1948 | -// It deliberately keeps the relay secret inside the tenant settings table and |
| 1949 | -// exposes only public keys and generated events to callers. |
| 1950 | package records |
| 1951 | |
| 1952 | import ( |
| 1953 | @@ -18,21 +15,26 @@ import ( |
| 1954 | ) |
| 1955 | |
| 1956 | type Config struct { |
| 1957 | - Store *storage.Store |
| 1958 | - Policy func() policy.Policy |
| 1959 | - SetPolicy func(policy.Policy) error |
| 1960 | - RelayURL string |
| 1961 | - GroupID string |
| 1962 | - OnGenerated func(context.Context, event.Event) error |
| 1963 | + // Store holds the tenant database and records settings. |
| 1964 | + Store *storage.Store |
| 1965 | + // Policy returns the current durable tenant policy when records evaluates |
| 1966 | + // access or builds a projection. |
| 1967 | + Policy func() policy.Policy |
| 1968 | + // SetPolicy persists a policy change requested by records management. |
| 1969 | + SetPolicy func(policy.Policy) error |
| 1970 | + RelayURL string |
| 1971 | + GroupID string |
| 1972 | + // OnGenerated receives a signed event after its records clock transaction |
| 1973 | + // commits. The callback commonly persists, publishes or queues the event. |
| 1974 | + OnGenerated func(context.Context, event.Event) error |
| 1975 | + // DeliverNotification delivers a sealed notification event to its target. |
| 1976 | DeliverNotification func(context.Context, event.Event, string) error |
| 1977 | // PushNotification receives a plaintext summary of each notification for |
| 1978 | // device delivery. The gift wrap itself is opaque to the relay once sealed. |
| 1979 | PushNotification func(context.Context, string, string, string, string) error |
| 1980 | OnTransfer func(context.Context, string, string) error |
| 1981 | - // Community supplies membership and moderation projections. Keeping these |
| 1982 | - // reads behind callbacks prevents records from depending on community's |
| 1983 | - // table layout. A nil reader is reserved for isolated records use; hosts |
| 1984 | - // serving community projections must provide it. |
| 1985 | + // Community supplies membership and moderation projections through the |
| 1986 | + // CommunityReader boundary. |
| 1987 | Community CommunityReader |
| 1988 | } |
| 1989 | |
| 1990 | @@ -40,9 +42,9 @@ type Config struct { |
| 1991 | // Implementations own the community schema and may use a transaction-backed |
| 1992 | // snapshot when the host is publishing a projection. |
| 1993 | type CommunityReader interface { |
| 1994 | - Members(context.Context) ([]communityread.Member, error) |
| 1995 | - ModerationCounts(context.Context, int64) (communityread.ModerationCounts, error) |
| 1996 | - MemberStatus(context.Context, string) (member bool, memberCount int, err error) |
| 1997 | + Members(ctx context.Context) ([]communityread.Member, error) |
| 1998 | + ModerationCounts(ctx context.Context, since int64) (communityread.ModerationCounts, error) |
| 1999 | + MemberStatus(ctx context.Context, pubkey string) (member bool, memberCount int, err error) |
| 2000 | } |
| 2001 | |
| 2002 | type Member = communityread.Member |
| 2003 | diff --git a/internal/relay/doc.go b/internal/relay/doc.go |
| 2004 | new file mode 100644 |
| 2005 | index 0000000..dad3541 |
| 2006 | --- /dev/null |
| 2007 | +++ b/internal/relay/doc.go |
| 2008 | @@ -0,0 +1,10 @@ |
| 2009 | +// Package relay is the WebSocket transport boundary for the relay. |
| 2010 | +// |
| 2011 | +// Router owns connection lifecycle, authentication messages, subscriptions, |
| 2012 | +// request and count queries, event publication and NIP-77 negotiation. A |
| 2013 | +// Backend supplies durable publication and query behavior; optional interfaces |
| 2014 | +// add reader privacy, query hints, policy invalidation and sync snapshots. |
| 2015 | +// Router serializes publication with subscription registration and snapshots, |
| 2016 | +// so a live event cannot pass a historical query boundary. Close stops every |
| 2017 | +// connection and waits for its reader and writer goroutines to finish. |
| 2018 | +package relay |
| 2019 | diff --git a/internal/relay/relay.go b/internal/relay/relay.go |
| 2020 | index 4716205..1965073 100644 |
| 2021 | --- a/internal/relay/relay.go |
| 2022 | +++ b/internal/relay/relay.go |
| 2023 | @@ -1,6 +1,3 @@ |
| 2024 | -// Package relay owns the websocket protocol boundary. It deliberately keeps |
| 2025 | -// storage and policy behind Backend so the wire protocol cannot accidentally |
| 2026 | -// become the tenant's data model. |
| 2027 | package relay |
| 2028 | |
| 2029 | import ( |
| 2030 | @@ -23,56 +20,80 @@ import ( |
| 2031 | ) |
| 2032 | |
| 2033 | type Session struct { |
| 2034 | - PubKeys []string |
| 2035 | + // PubKeys contains public keys established by the connection's AUTH flow. |
| 2036 | + PubKeys []string |
| 2037 | + // RelayURL is the relay identity used when authenticating and publishing. |
| 2038 | RelayURL string |
| 2039 | + // RemoteIP is the peer address observed by the HTTP server. |
| 2040 | RemoteIP string |
| 2041 | } |
| 2042 | |
| 2043 | -// Backend publishes while the Router's write gate is held. Publish must make |
| 2044 | -// the event and any delivery intent durable before returning nil. |
| 2045 | +// Backend publishes while the Router's write gate is held. Publish performs |
| 2046 | +// the backend's admission, persistence, duplicate handling or control action |
| 2047 | +// before returning. A nil error allows Router to fan out the event unless the |
| 2048 | +// returned reason identifies a duplicate. Persistence and delivery planning |
| 2049 | +// remain backend responsibilities for event kinds that require them. |
| 2050 | type Backend interface { |
| 2051 | - Publish(context.Context, event.Event, Session) (string, error) |
| 2052 | - Query(context.Context, []event.Filter, Session) ([]event.Event, error) |
| 2053 | - Count(context.Context, []event.Filter, Session) (any, error) |
| 2054 | + Publish(ctx context.Context, event event.Event, session Session) (reason string, err error) |
| 2055 | + Query(ctx context.Context, filters []event.Filter, session Session) ([]event.Event, error) |
| 2056 | + Count(ctx context.Context, filters []event.Filter, session Session) (any, error) |
| 2057 | } |
| 2058 | |
| 2059 | // Reader-side privacy can be stricter than the query filter. It is checked for |
| 2060 | // both historical and live delivery, including encrypted signer traffic. |
| 2061 | type Reader interface { |
| 2062 | - CanRead(event.Event, Session) bool |
| 2063 | + CanRead(event event.Event, session Session) bool |
| 2064 | } |
| 2065 | |
| 2066 | +// FilterReader applies reader privacy with the subscription filter available |
| 2067 | +// to the policy decision. |
| 2068 | type FilterReader interface { |
| 2069 | - CanReadFilter(event.Event, Session, *event.Filter) bool |
| 2070 | + CanReadFilter(event event.Event, session Session, filter *event.Filter) bool |
| 2071 | } |
| 2072 | |
| 2073 | +// QueryHints can return query results together with completion hints. Router |
| 2074 | +// places these values in the EOSE response; the relay backend uses values such |
| 2075 | +// as "finish", "more" and "auth". |
| 2076 | type QueryHints interface { |
| 2077 | - QueryHints(context.Context, []event.Filter, Session) ([]event.Event, []string, error) |
| 2078 | + QueryHints(ctx context.Context, filters []event.Filter, session Session) ([]event.Event, []string, error) |
| 2079 | } |
| 2080 | |
| 2081 | -// Policy changes can invalidate subscriptions. The Router removes them from |
| 2082 | -// clients immediately; the optional callback lets storage invalidate caches. |
| 2083 | -type PolicyChanges interface{ CloseSubscriptions(Session) } |
| 2084 | +// PolicyChanges can invalidate subscriptions in storage after an access |
| 2085 | +// policy revision. Router removes client state before invoking the callback. |
| 2086 | +type PolicyChanges interface { |
| 2087 | + CloseSubscriptions(session Session) |
| 2088 | +} |
| 2089 | |
| 2090 | // SyncBackend supplies the authorized event snapshot for a NIP-77 session. |
| 2091 | // The Router holds its read gate while calling Sync, so a concurrent Publish |
| 2092 | // cannot create an opening between snapshot selection and NEG-OPEN. |
| 2093 | type SyncBackend interface { |
| 2094 | - Sync(context.Context, event.Filter, Session) ([]syncprotocol.Item, error) |
| 2095 | + Sync(ctx context.Context, filter event.Filter, session Session) ([]syncprotocol.Item, error) |
| 2096 | } |
| 2097 | |
| 2098 | type Config struct { |
| 2099 | - RelayURL string |
| 2100 | + // RelayURL is the default relay identity included in sessions. |
| 2101 | + RelayURL string |
| 2102 | + // RequestRelayURL optionally derives the relay identity from each request. |
| 2103 | RequestRelayURL func(*http.Request) string |
| 2104 | - OnAuthenticate func(context.Context, Session) error |
| 2105 | + // OnAuthenticate checks the authenticated session candidate and may persist |
| 2106 | + // authentication bookkeeping. The Session argument is a value snapshot; |
| 2107 | + // Router records the newly authenticated public key after this callback. |
| 2108 | + OnAuthenticate func(context.Context, Session) error |
| 2109 | + // MaxMessageBytes limits websocket text messages. Zero leaves the limit |
| 2110 | + // unlimited; a negative value selects the 512 KiB default. |
| 2111 | MaxMessageBytes int64 |
| 2112 | + // MaxPendingBytes bounds queued output per connection. |
| 2113 | MaxPendingBytes int |
| 2114 | - ReadLimit time.Duration |
| 2115 | - WriteLimit time.Duration |
| 2116 | - PingInterval time.Duration |
| 2117 | - OriginPatterns []string |
| 2118 | - OnConnection func(int) |
| 2119 | - OnSubscription func(int) |
| 2120 | + // ReadLimit, WriteLimit and PingInterval control websocket liveness. |
| 2121 | + ReadLimit time.Duration |
| 2122 | + WriteLimit time.Duration |
| 2123 | + PingInterval time.Duration |
| 2124 | + // OriginPatterns is passed to the websocket origin check. |
| 2125 | + OriginPatterns []string |
| 2126 | + // OnConnection and OnSubscription receive +1 and -1 lifecycle deltas. |
| 2127 | + OnConnection func(int) |
| 2128 | + OnSubscription func(int) |
| 2129 | } |
| 2130 | |
| 2131 | func (c Config) withDefaults() Config { |
| 2132 | diff --git a/internal/replication/backup.go b/internal/replication/backup.go |
| 2133 | index 53aadc3..be2b6d8 100644 |
| 2134 | --- a/internal/replication/backup.go |
| 2135 | +++ b/internal/replication/backup.go |
| 2136 | @@ -18,8 +18,10 @@ import ( |
| 2137 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 2138 | ) |
| 2139 | |
| 2140 | +// BackupFormat identifies the JSON archive format produced by this package. |
| 2141 | const BackupFormat = "tinyrelay/backup/1" |
| 2142 | |
| 2143 | +// BackupArchive is a sealed event and application-state export. |
| 2144 | type BackupArchive struct { |
| 2145 | Format string `json:"format"` |
| 2146 | Events []event.Event `json:"events"` |
| 2147 | @@ -28,6 +30,7 @@ type BackupArchive struct { |
| 2148 | Created int64 `json:"created_at"` |
| 2149 | } |
| 2150 | |
| 2151 | +// BackupState contains application-owned state and exported objects. |
| 2152 | type BackupState struct { |
| 2153 | Config json.RawMessage `json:"config,omitempty"` |
| 2154 | Community json.RawMessage `json:"community,omitempty"` |
| 2155 | @@ -39,12 +42,14 @@ type BackupState struct { |
| 2156 | Database []BackupTable `json:"database,omitempty"` |
| 2157 | } |
| 2158 | |
| 2159 | +// BackupTable is a named table snapshot represented as JSON rows. |
| 2160 | type BackupTable struct { |
| 2161 | Name string `json:"name"` |
| 2162 | Columns []string `json:"columns"` |
| 2163 | Rows [][]json.RawMessage `json:"rows"` |
| 2164 | } |
| 2165 | |
| 2166 | +// BackupObject is an exported named file or blob with an integrity digest. |
| 2167 | type BackupObject struct { |
| 2168 | Name string `json:"name"` |
| 2169 | SHA256 string `json:"sha256"` |
| 2170 | @@ -54,18 +59,23 @@ type BackupObject struct { |
| 2171 | Uploaded int64 `json:"uploaded,omitempty"` |
| 2172 | } |
| 2173 | |
| 2174 | +// BackupProvider snapshots and restores application state associated with a |
| 2175 | +// relay database. The provider owns its external resources. |
| 2176 | type BackupProvider interface { |
| 2177 | - Snapshot(context.Context) (BackupState, error) |
| 2178 | - Restore(context.Context, BackupState) error |
| 2179 | + Snapshot(ctx context.Context) (BackupState, error) |
| 2180 | + Restore(ctx context.Context, state BackupState) error |
| 2181 | } |
| 2182 | |
| 2183 | +// FullBackupProvider can produce a complete archive using its own snapshot |
| 2184 | +// coordination. |
| 2185 | type FullBackupProvider interface { |
| 2186 | BackupProvider |
| 2187 | - SnapshotArchive(context.Context, int64) (BackupArchive, error) |
| 2188 | + SnapshotArchive(ctx context.Context, created int64) (BackupArchive, error) |
| 2189 | } |
| 2190 | |
| 2191 | +// RestoreFinalizer runs application cleanup after a successful restore. |
| 2192 | type RestoreFinalizer interface { |
| 2193 | - FinalizeRestore(context.Context) error |
| 2194 | + FinalizeRestore(ctx context.Context) error |
| 2195 | } |
| 2196 | |
| 2197 | func CreateBackup(ctx context.Context, store *storage.Store, now int64) (BackupArchive, error) { |
| 2198 | diff --git a/internal/replication/doc.go b/internal/replication/doc.go |
| 2199 | new file mode 100644 |
| 2200 | index 0000000..2904766 |
| 2201 | --- /dev/null |
| 2202 | +++ b/internal/replication/doc.go |
| 2203 | @@ -0,0 +1,19 @@ |
| 2204 | +// Package replication plans and executes durable relay synchronization. |
| 2205 | +// |
| 2206 | +// Planning is separate from transport. RelayDirectory and Discovery provide |
| 2207 | +// routing inputs; PrepareIntents turns them into idempotent storage.Intent |
| 2208 | +// values that can be committed with the event that caused them. Delivery, |
| 2209 | +// PullTransport and PushTransport perform network operations and are owned by |
| 2210 | +// the application that constructs the Service or calls the standalone sync |
| 2211 | +// functions. Those callers retain ownership of their transports and stores. |
| 2212 | +// |
| 2213 | +// Service owns replication's durable work queue and job state. Handlers can be |
| 2214 | +// composed with other work handlers and run by work.RunPool. A handler error |
| 2215 | +// is retried by the work package with backoff; a terminal error can be marked |
| 2216 | +// with work.Stop. Successful recurring jobs enqueue a new intent, preserving a |
| 2217 | +// completed record for the prior run. |
| 2218 | +// |
| 2219 | +// WebSocket transports validate relay URLs and public address resolution before |
| 2220 | +// dialing. Backup and artifact helpers write complete files through temporary |
| 2221 | +// files and same-directory rename. |
| 2222 | +package replication |
| 2223 | diff --git a/internal/replication/jobs.go b/internal/replication/jobs.go |
| 2224 | index caf5b65..cde513a 100644 |
| 2225 | --- a/internal/replication/jobs.go |
| 2226 | +++ b/internal/replication/jobs.go |
| 2227 | @@ -13,6 +13,7 @@ import ( |
| 2228 | "github.com/FelineStateMachine/tinyrelay/internal/work" |
| 2229 | ) |
| 2230 | |
| 2231 | +// JobKind selects the durable replication operation. |
| 2232 | type JobKind string |
| 2233 | |
| 2234 | const ( |
| 2235 | @@ -24,6 +25,8 @@ const ( |
| 2236 | JobBackup JobKind = "backup" |
| 2237 | ) |
| 2238 | |
| 2239 | +// JobSpec describes one immediate or recurring replication job. Every is an |
| 2240 | +// interval in hours; zero runs once. |
| 2241 | type JobSpec struct { |
| 2242 | ID string `json:"id"` |
| 2243 | Kind JobKind `json:"kind"` |
| 2244 | @@ -33,10 +36,12 @@ type JobSpec struct { |
| 2245 | DiscoverPubKey string `json:"discoverPubKey,omitempty"` |
| 2246 | } |
| 2247 | |
| 2248 | +// JobRunner executes a validated job specification. |
| 2249 | type JobRunner interface { |
| 2250 | Run(ctx context.Context, spec JobSpec) error |
| 2251 | } |
| 2252 | |
| 2253 | +// JobHandler decodes and validates durable job intents before delegating them. |
| 2254 | type JobHandler struct { |
| 2255 | runner JobRunner |
| 2256 | } |
| 2257 | diff --git a/internal/replication/replication.go b/internal/replication/replication.go |
| 2258 | index 56c6c83..61fc385 100644 |
| 2259 | --- a/internal/replication/replication.go |
| 2260 | +++ b/internal/replication/replication.go |
| 2261 | @@ -1,7 +1,3 @@ |
| 2262 | -// Package replication contains durable self-hosted relay fanout and job |
| 2263 | -// planning. Network I/O is deliberately behind small interfaces so a relay |
| 2264 | -// can use local test transports, a normal websocket client, or an operator's |
| 2265 | -// proxy without changing queue semantics. |
| 2266 | package replication |
| 2267 | |
| 2268 | import ( |
| 2269 | @@ -16,6 +12,8 @@ import ( |
| 2270 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 2271 | ) |
| 2272 | |
| 2273 | +// Origin identifies how an event entered the relay and controls whether it is |
| 2274 | +// eligible for outbound fanout. |
| 2275 | type Origin string |
| 2276 | |
| 2277 | const ( |
| 2278 | @@ -26,8 +24,8 @@ const ( |
| 2279 | OriginBackupRestore Origin = "backup_restore" |
| 2280 | ) |
| 2281 | |
| 2282 | -// Policy contains only routing decisions. It has no lease, fuel, or product |
| 2283 | -// quota fields; the host controls process capacity independently. |
| 2284 | +// Policy supplies current fanout decisions and relay identity. The host |
| 2285 | +// controls process capacity independently. |
| 2286 | type Policy struct { |
| 2287 | Enabled bool |
| 2288 | ReadMembersOnly bool |
| 2289 | @@ -35,26 +33,31 @@ type Policy struct { |
| 2290 | SelfRelayURL string |
| 2291 | } |
| 2292 | |
| 2293 | +// RelayDirectory returns write relays for an author and read relays for a |
| 2294 | +// recipient. Returned URLs are filtered by PrepareIntents. |
| 2295 | type RelayDirectory interface { |
| 2296 | WriteRelays(pubkey string) []string |
| 2297 | ReadRelays(pubkey string) []string |
| 2298 | } |
| 2299 | |
| 2300 | -// RelayListDiscovery preserves the source order of all NIP-65 relay tags for |
| 2301 | +// RelayListDiscovery returns the source-ordered NIP-65 relay list used by |
| 2302 | // owner backfill. RelayDirectory's directional methods remain sufficient for |
| 2303 | // delivery planning. |
| 2304 | type RelayListDiscovery interface { |
| 2305 | RelayList(pubkey string) []string |
| 2306 | } |
| 2307 | |
| 2308 | +// Discovery resolves write relays for an author. |
| 2309 | type Discovery interface { |
| 2310 | DiscoverRelays(ctx context.Context, pubkey string) ([]string, error) |
| 2311 | } |
| 2312 | |
| 2313 | +// ReadDiscovery resolves read relays for a recipient. |
| 2314 | type ReadDiscovery interface { |
| 2315 | DiscoverReadRelays(ctx context.Context, pubkey string) ([]string, error) |
| 2316 | } |
| 2317 | |
| 2318 | +// IntentPayload records the event origin in a delivery intent. |
| 2319 | type IntentPayload struct { |
| 2320 | Origin Origin `json:"origin"` |
| 2321 | } |
| 2322 | @@ -173,10 +176,14 @@ func canonicalRelayURL(raw string) string { |
| 2323 | return scheme + "://" + host + path + "?" + u.RawQuery |
| 2324 | } |
| 2325 | |
| 2326 | +// DeliveryTransport sends one event to a relay target. The implementation |
| 2327 | +// owns connection reuse, request deadlines and transport shutdown. |
| 2328 | type DeliveryTransport interface { |
| 2329 | Send(ctx context.Context, target string, e event.Event) (DeliveryResult, error) |
| 2330 | } |
| 2331 | |
| 2332 | +// DeliveryResult reports the relay's acceptance response. Duplicate |
| 2333 | +// acknowledgments are treated as successful delivery by the handlers. |
| 2334 | type DeliveryResult struct { |
| 2335 | Accepted bool |
| 2336 | Message string |
| 2337 | diff --git a/internal/replication/service.go b/internal/replication/service.go |
| 2338 | index 81ab8fb..352d3c2 100644 |
| 2339 | --- a/internal/replication/service.go |
| 2340 | +++ b/internal/replication/service.go |
| 2341 | @@ -16,6 +16,10 @@ import ( |
| 2342 | "github.com/FelineStateMachine/tinyrelay/internal/work" |
| 2343 | ) |
| 2344 | |
| 2345 | +// Config supplies a Service's store, routing policy, transports and optional |
| 2346 | +// application hooks. Store is required. DataDir defaults to the store's |
| 2347 | +// directory; Workers defaults to work.RunPool's worker count when Run is used. |
| 2348 | +// The caller retains ownership of the supplied transports and store. |
| 2349 | type Config struct { |
| 2350 | Store *storage.Store |
| 2351 | Policy Policy |
| 2352 | @@ -36,12 +40,14 @@ type Config struct { |
| 2353 | Workers int |
| 2354 | } |
| 2355 | |
| 2356 | +// Service owns replication job state and the durable queue built on Store. |
| 2357 | type Service struct { |
| 2358 | store *storage.Store |
| 2359 | queue *work.Queue |
| 2360 | config Config |
| 2361 | } |
| 2362 | |
| 2363 | +// NewService initializes replication job tables and returns a service. |
| 2364 | func NewService(config Config) (*Service, error) { |
| 2365 | if config.Store == nil { |
| 2366 | return nil, errors.New("replication: store is required") |
| 2367 | @@ -55,8 +61,10 @@ func NewService(config Config) (*Service, error) { |
| 2368 | return &Service{store: config.Store, queue: work.New(config.Store), config: config}, nil |
| 2369 | } |
| 2370 | |
| 2371 | +// Store returns the service's supplied store. |
| 2372 | func (s *Service) Store() *storage.Store { return s.store } |
| 2373 | |
| 2374 | +// Queue returns the service's durable replication queue. |
| 2375 | func (s *Service) Queue() *work.Queue { return s.queue } |
| 2376 | |
| 2377 | func (s *Service) Prepare(e event.Event, origin Origin) []storage.Intent { |
| 2378 | diff --git a/internal/replication/sync.go b/internal/replication/sync.go |
| 2379 | index 2829817..234be00 100644 |
| 2380 | --- a/internal/replication/sync.go |
| 2381 | +++ b/internal/replication/sync.go |
| 2382 | @@ -9,14 +9,19 @@ import ( |
| 2383 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 2384 | ) |
| 2385 | |
| 2386 | +// PullTransport reads events from target. The transport owns network clients |
| 2387 | +// and their lifecycle; PullOnce owns validation and storage. |
| 2388 | type PullTransport interface { |
| 2389 | Query(ctx context.Context, target string, filter event.Filter) ([]event.Event, error) |
| 2390 | } |
| 2391 | |
| 2392 | +// PushTransport sends one event to target. PushAfter advances its cursor only |
| 2393 | +// after Send returns, including a relay duplicate acknowledgment. |
| 2394 | type PushTransport interface { |
| 2395 | Send(ctx context.Context, target string, e event.Event) (DeliveryResult, error) |
| 2396 | } |
| 2397 | |
| 2398 | +// SyncStats summarizes one pull or push pass. |
| 2399 | type SyncStats struct { |
| 2400 | Stored int |
| 2401 | Duplicates int |
| 2402 | diff --git a/internal/replication/websocket.go b/internal/replication/websocket.go |
| 2403 | index d1425f9..4201611 100644 |
| 2404 | --- a/internal/replication/websocket.go |
| 2405 | +++ b/internal/replication/websocket.go |
| 2406 | @@ -18,20 +18,28 @@ import ( |
| 2407 | "github.com/coder/websocket" |
| 2408 | ) |
| 2409 | |
| 2410 | +// Socket is the message-oriented connection used by NostrTransport. The |
| 2411 | +// caller that receives a Socket owns it and closes it when the exchange ends. |
| 2412 | type Socket interface { |
| 2413 | - Read(context.Context) ([]byte, error) |
| 2414 | - Write(context.Context, []byte) error |
| 2415 | - Close(error) error |
| 2416 | + Read(ctx context.Context) ([]byte, error) |
| 2417 | + Write(ctx context.Context, message []byte) error |
| 2418 | + Close(cause error) error |
| 2419 | } |
| 2420 | |
| 2421 | +// Dialer opens a Socket for one relay target and returns its HTTP handshake. |
| 2422 | type Dialer interface { |
| 2423 | - Dial(context.Context, string) (Socket, *http.Response, error) |
| 2424 | + Dial(ctx context.Context, target string) (Socket, *http.Response, error) |
| 2425 | } |
| 2426 | |
| 2427 | +// IPResolver resolves relay hostnames for address-policy checks and dialing. |
| 2428 | type IPResolver interface { |
| 2429 | - LookupIPAddr(context.Context, string) ([]net.IPAddr, error) |
| 2430 | + LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) |
| 2431 | } |
| 2432 | |
| 2433 | +// WebsocketDialer validates websocket targets, blocks private addresses unless |
| 2434 | +// AllowPrivate is set and optionally answers NIP-42 authentication challenges. |
| 2435 | +// HTTPClient and Resolver are caller-supplied dependencies and remain owned by |
| 2436 | +// the caller. |
| 2437 | type WebsocketDialer struct { |
| 2438 | AllowPrivate bool |
| 2439 | MaxMessageBytes int64 |
| 2440 | @@ -250,6 +258,9 @@ func (s coderSocket) Close(err error) error { |
| 2441 | return s.conn.Close(code, "replication complete") |
| 2442 | } |
| 2443 | |
| 2444 | +// NostrTransport implements PullTransport and PushTransport over Nostr |
| 2445 | +// websocket protocols. Dialer defaults to WebsocketDialer when unset, and |
| 2446 | +// Timeout defaults to 20 seconds. |
| 2447 | type NostrTransport struct { |
| 2448 | Dialer Dialer |
| 2449 | Timeout time.Duration |
| 2450 | diff --git a/internal/sites/doc.go b/internal/sites/doc.go |
| 2451 | new file mode 100644 |
| 2452 | index 0000000..ee61705 |
| 2453 | --- /dev/null |
| 2454 | +++ b/internal/sites/doc.go |
| 2455 | @@ -0,0 +1,21 @@ |
| 2456 | +// Package sites implements NIP-5A site manifests and serves their content. |
| 2457 | +// |
| 2458 | +// A manifest event maps URL paths to blob hashes. SiteLabel and ParseSite |
| 2459 | +// translate the three supported labels: an owner's npub, a named site, and a |
| 2460 | +// snapshot. ValidateManifest checks path entries, aggregate hashes and |
| 2461 | +// lineage before a manifest is indexed. |
| 2462 | +// |
| 2463 | +// Service keeps the label-to-event index in the site_manifests table of the |
| 2464 | +// supplied storage.Store. New creates that table and its deletion trigger. |
| 2465 | +// SaveOptions supplies the storage transaction hook that updates the index |
| 2466 | +// with an accepted event and, when mirroring is enabled by policy, queues a |
| 2467 | +// site-mirror intent. ApplyTx and DeleteTx are the corresponding transaction |
| 2468 | +// hooks for callers that already own the event transaction. |
| 2469 | +// |
| 2470 | +// Mirror reads missing files from the servers advertised by a manifest, |
| 2471 | +// verifies each response against its declared hash and stores it through the |
| 2472 | +// configured BlobPut callback. RunMirrorIntent resolves the event captured by |
| 2473 | +// storage and retries that idempotent operation. Handler serves only GET and |
| 2474 | +// HEAD content after host, policy and optional ReadAccess checks; the manifest |
| 2475 | +// remains the authority for path selection and discovery responses. |
| 2476 | +package sites |
| 2477 | diff --git a/internal/sites/service.go b/internal/sites/service.go |
| 2478 | index 1d7aedf..92f1fe0 100644 |
| 2479 | --- a/internal/sites/service.go |
| 2480 | +++ b/internal/sites/service.go |
| 2481 | @@ -23,19 +23,34 @@ import ( |
| 2482 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 2483 | ) |
| 2484 | |
| 2485 | +// Blob is a fetched site file and its declared content metadata. |
| 2486 | type Blob struct { |
| 2487 | Body io.ReadCloser |
| 2488 | Type string |
| 2489 | Size int64 |
| 2490 | } |
| 2491 | |
| 2492 | -type BlobGet func(context.Context, string) (Blob, error) |
| 2493 | -type BlobPut func(context.Context, string, string, io.Reader) error |
| 2494 | -type Fetch func(context.Context, *http.Request) (*http.Response, error) |
| 2495 | -type ResolveIP func(context.Context, string) ([]net.IP, error) |
| 2496 | -type ResolveHost func(context.Context, string) (string, error) |
| 2497 | -type ReadAccess func(*http.Request) bool |
| 2498 | +// BlobGet opens a local blob by its SHA-256 hash. The caller closes Blob.Body. |
| 2499 | +type BlobGet func(ctx context.Context, blobSHA string) (Blob, error) |
| 2500 | |
| 2501 | +// BlobPut stores bytes under blobSHA with the supplied content type. The |
| 2502 | +// callback consumes the reader before returning. |
| 2503 | +type BlobPut func(ctx context.Context, blobSHA string, contentType string, body io.Reader) error |
| 2504 | + |
| 2505 | +// Fetch performs an outbound site file request. The caller closes the |
| 2506 | +// returned response body. |
| 2507 | +type Fetch func(ctx context.Context, request *http.Request) (*http.Response, error) |
| 2508 | + |
| 2509 | +// ResolveIP resolves a host before an outbound site request is allowed. |
| 2510 | +type ResolveIP func(ctx context.Context, host string) ([]net.IP, error) |
| 2511 | + |
| 2512 | +// ResolveHost resolves a request host to a site label. |
| 2513 | +type ResolveHost func(ctx context.Context, host string) (label string, err error) |
| 2514 | + |
| 2515 | +// ReadAccess authorizes a request for site content. |
| 2516 | +type ReadAccess func(request *http.Request) bool |
| 2517 | + |
| 2518 | +// Config supplies storage, blob, network and request policy dependencies. |
| 2519 | type Config struct { |
| 2520 | Store *storage.Store |
| 2521 | GetBlob BlobGet |
| 2522 | diff --git a/internal/sites/sites.go b/internal/sites/sites.go |
| 2523 | index 4cfa6f3..0a6531d 100644 |
| 2524 | --- a/internal/sites/sites.go |
| 2525 | +++ b/internal/sites/sites.go |
| 2526 | @@ -28,6 +28,7 @@ var ( |
| 2527 | pathPattern = regexp.MustCompile(`^/(?:[^/]+/)*[^/.]+\.[^/.]+$`) |
| 2528 | ) |
| 2529 | |
| 2530 | +// Site identifies a manifest family and its addressable key. |
| 2531 | type Site struct { |
| 2532 | Kind int |
| 2533 | PubKey string |
| 2534 | @@ -35,6 +36,7 @@ type Site struct { |
| 2535 | ID string |
| 2536 | } |
| 2537 | |
| 2538 | +// Base36 encodes a lowercase 32-byte hexadecimal key as a fixed-width label. |
| 2539 | func Base36(value string) (string, error) { |
| 2540 | if !hexPattern.MatchString(value) { |
| 2541 | return "", errors.New("expected lowercase 32-byte hex") |
| 2542 | @@ -47,6 +49,7 @@ func Base36(value string) (string, error) { |
| 2543 | return strings.Repeat("0", 50-len(value36)) + value36, nil |
| 2544 | } |
| 2545 | |
| 2546 | +// Unbase36 decodes a fixed-width site label into lowercase hexadecimal. |
| 2547 | func Unbase36(value string) (string, bool) { |
| 2548 | if len(value) != 50 || !regexp.MustCompile(`^[0-9a-z]+$`).MatchString(value) { |
| 2549 | return "", false |
| 2550 | @@ -58,6 +61,7 @@ func Unbase36(value string) (string, bool) { |
| 2551 | return fmt.Sprintf("%064x", n), true |
| 2552 | } |
| 2553 | |
| 2554 | +// ParseSite parses an npub, named-site or snapshot label. |
| 2555 | func ParseSite(label string) (Site, bool) { |
| 2556 | if strings.HasPrefix(label, "npub1") { |
| 2557 | key, ok := decodeNpub(label) |
| 2558 | @@ -81,6 +85,7 @@ func ParseSite(label string) (Site, bool) { |
| 2559 | return Site{Kind: KindNamedSite, PubKey: key, D: label[50:]}, true |
| 2560 | } |
| 2561 | |
| 2562 | +// SiteLabel returns the host label represented by a manifest event. |
| 2563 | func SiteLabel(e event.Event) string { |
| 2564 | switch e.Kind { |
| 2565 | case KindSite: |
| 2566 | @@ -106,6 +111,7 @@ func SiteLabel(e event.Event) string { |
| 2567 | } |
| 2568 | } |
| 2569 | |
| 2570 | +// SitePaths returns the path tags in manifest order. |
| 2571 | func SitePaths(e event.Event) [][]string { |
| 2572 | paths := make([][]string, 0) |
| 2573 | for _, tag := range e.Tags { |
| 2574 | @@ -116,6 +122,7 @@ func SitePaths(e event.Event) [][]string { |
| 2575 | return paths |
| 2576 | } |
| 2577 | |
| 2578 | +// Aggregate hashes the canonical path and blob mappings in a manifest. |
| 2579 | func Aggregate(paths [][]string) string { |
| 2580 | lines := make([]string, 0, len(paths)) |
| 2581 | for _, tag := range paths { |
| 2582 | @@ -128,6 +135,7 @@ func Aggregate(paths [][]string) string { |
| 2583 | return hex.EncodeToString(sum[:]) |
| 2584 | } |
| 2585 | |
| 2586 | +// ValidateManifest checks a site manifest's paths, aggregate and lineage. |
| 2587 | func ValidateManifest(e event.Event) error { |
| 2588 | if e.Kind != KindSite && e.Kind != KindNamedSite && e.Kind != KindSiteSnapshot { |
| 2589 | return nil |
| 2590 | diff --git a/internal/storage/doc.go b/internal/storage/doc.go |
| 2591 | new file mode 100644 |
| 2592 | index 0000000..f15e209 |
| 2593 | --- /dev/null |
| 2594 | +++ b/internal/storage/doc.go |
| 2595 | @@ -0,0 +1,22 @@ |
| 2596 | +// Package storage owns a tenant's SQLite database and the transaction boundary |
| 2597 | +// for event data, projections and durable work intents. |
| 2598 | +// |
| 2599 | +// Open creates the database and schema with SQLite WAL mode, full synchronous |
| 2600 | +// writes, foreign-key enforcement and one database connection. Each method |
| 2601 | +// uses its context for database work. |
| 2602 | +// |
| 2603 | +// Save applies duplicate, deletion and replacement rules, stores searchable |
| 2604 | +// indexes and inserts durable work intents in one transaction. Callers validate |
| 2605 | +// events before passing them to storage. |
| 2606 | +// SaveTx performs the same operation inside a transaction supplied by the |
| 2607 | +// caller. The caller owns that transaction and commits it after SaveTx |
| 2608 | +// succeeds. WithTx provides the matching begin, commit and rollback boundary; |
| 2609 | +// its callback uses the supplied *sql.Tx for all database operations. |
| 2610 | +// |
| 2611 | +// Query excludes expired, hidden and pending events and applies recipient |
| 2612 | +// checks through QueryOptions.Access. It returns events in created-at |
| 2613 | +// descending order with ID ascending as the stable tie breaker. The tenant |
| 2614 | +// gate applies the remaining policy and resource visibility rules. After |
| 2615 | +// reads events by their increasing insertion sequence for cursored |
| 2616 | +// replication; removed events leave gaps in that sequence. |
| 2617 | +package storage |
| 2618 | diff --git a/internal/storage/history.go b/internal/storage/history.go |
| 2619 | index 7f18ffd..b699803 100644 |
| 2620 | --- a/internal/storage/history.go |
| 2621 | +++ b/internal/storage/history.go |
| 2622 | @@ -8,6 +8,7 @@ import ( |
| 2623 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 2624 | ) |
| 2625 | |
| 2626 | +// HistoryRow identifies one retained version of a replaceable list. |
| 2627 | type HistoryRow struct { |
| 2628 | Kind int `json:"kind"` |
| 2629 | D string `json:"d"` |
| 2630 | @@ -33,21 +34,31 @@ func (s *Store) ListHistory(ctx context.Context, owner string, now int64) ([]His |
| 2631 | return result, rows.Err() |
| 2632 | } |
| 2633 | |
| 2634 | +// RestoreResult contains a prior list event draft and its difference from the |
| 2635 | +// current list. |
| 2636 | type RestoreResult struct { |
| 2637 | Draft Draft `json:"draft"` |
| 2638 | Diff Diff `json:"diff"` |
| 2639 | } |
| 2640 | + |
| 2641 | +// Draft is the event body needed to publish a restored list version. |
| 2642 | type Draft struct { |
| 2643 | Kind int `json:"kind"` |
| 2644 | CreatedAt int64 `json:"created_at"` |
| 2645 | Tags [][]string `json:"tags"` |
| 2646 | Content string `json:"content"` |
| 2647 | } |
| 2648 | + |
| 2649 | +// Diff describes content and tag changes between the retained and current |
| 2650 | +// versions. |
| 2651 | type Diff struct { |
| 2652 | AddedTags [][]string `json:"addedTags"` |
| 2653 | RemovedTags [][]string `json:"removedTags"` |
| 2654 | ContentChanged bool `json:"contentChanged"` |
| 2655 | } |
| 2656 | + |
| 2657 | +// RestoreOptions identifies the retained list event and the query time used |
| 2658 | +// for expiration checks. |
| 2659 | type RestoreOptions struct { |
| 2660 | Owner string |
| 2661 | EventID string |
| 2662 | diff --git a/internal/storage/maintenance.go b/internal/storage/maintenance.go |
| 2663 | index 324405c..53742e4 100644 |
| 2664 | --- a/internal/storage/maintenance.go |
| 2665 | +++ b/internal/storage/maintenance.go |
| 2666 | @@ -10,6 +10,7 @@ import ( |
| 2667 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 2668 | ) |
| 2669 | |
| 2670 | +// Stats reports event counts, database size and the current WAL size. |
| 2671 | type Stats struct { |
| 2672 | Events int64 `json:"events"` |
| 2673 | Bytes int64 `json:"bytes"` |
| 2674 | @@ -40,6 +41,8 @@ func (s *Store) Stats(ctx context.Context) (Stats, error) { |
| 2675 | return result, nil |
| 2676 | } |
| 2677 | |
| 2678 | +// Vanish records a deletion horizon and removes the author's events and |
| 2679 | +// related history through until, inclusive. |
| 2680 | func (s *Store) Vanish(ctx context.Context, pubkey string, until int64) error { |
| 2681 | return s.WithTx(ctx, func(tx *sql.Tx) error { |
| 2682 | statements := []struct { |
| 2683 | @@ -61,6 +64,8 @@ func (s *Store) Vanish(ctx context.Context, pubkey string, until int64) error { |
| 2684 | }) |
| 2685 | } |
| 2686 | |
| 2687 | +// SweepExpired removes expired events and history and returns the next event |
| 2688 | +// expiration timestamp, or zero when none remain. |
| 2689 | func (s *Store) SweepExpired(ctx context.Context, now int64) (int64, error) { |
| 2690 | var next int64 |
| 2691 | err := s.WithTx(ctx, func(tx *sql.Tx) error { |
| 2692 | @@ -74,6 +79,7 @@ func (s *Store) SweepExpired(ctx context.Context, now int64) (int64, error) { |
| 2693 | return next, err |
| 2694 | } |
| 2695 | |
| 2696 | +// DeleteEvent removes one event by ID and reports whether a row was removed. |
| 2697 | func (s *Store) DeleteEvent(ctx context.Context, id string) (bool, error) { |
| 2698 | result, err := s.db.ExecContext(ctx, "DELETE FROM events WHERE id=?", id) |
| 2699 | if err != nil { |
| 2700 | @@ -83,6 +89,7 @@ func (s *Store) DeleteEvent(ctx context.Context, id string) (bool, error) { |
| 2701 | return n > 0, err |
| 2702 | } |
| 2703 | |
| 2704 | +// EraseAuthor removes an author's events and retained list and wiki history. |
| 2705 | func (s *Store) EraseAuthor(ctx context.Context, pubkey string) (int64, error) { |
| 2706 | var n int64 |
| 2707 | err := s.WithTx(ctx, func(tx *sql.Tx) error { |
| 2708 | @@ -103,6 +110,7 @@ func (s *Store) EraseAuthor(ctx context.Context, pubkey string) (int64, error) { |
| 2709 | return n, err |
| 2710 | } |
| 2711 | |
| 2712 | +// GetSetting decodes a JSON setting into dest. |
| 2713 | func (s *Store) GetSetting(ctx context.Context, key string, dest any) error { |
| 2714 | var raw string |
| 2715 | if err := s.db.QueryRowContext(ctx, "SELECT value FROM settings WHERE key=?", key).Scan(&raw); err != nil { |
| 2716 | @@ -114,6 +122,7 @@ func (s *Store) GetSetting(ctx context.Context, key string, dest any) error { |
| 2717 | return nil |
| 2718 | } |
| 2719 | |
| 2720 | +// PutSetting encodes value as JSON and upserts it under key. |
| 2721 | func (s *Store) PutSetting(ctx context.Context, key string, value any) error { |
| 2722 | return s.WithTx(ctx, func(tx *sql.Tx) error { return PutSetting(ctx, tx, key, value) }) |
| 2723 | } |
| 2724 | @@ -154,6 +163,7 @@ func (s *Store) PruneSettings(ctx context.Context, prefix string, keep map[strin |
| 2725 | return nil |
| 2726 | } |
| 2727 | |
| 2728 | +// PutSetting writes one setting using a caller-owned transaction. |
| 2729 | func PutSetting(ctx context.Context, tx *sql.Tx, key string, value any) error { |
| 2730 | raw, err := json.Marshal(value) |
| 2731 | if err != nil { |
| 2732 | diff --git a/internal/storage/query.go b/internal/storage/query.go |
| 2733 | index bd71504..5569b80 100644 |
| 2734 | --- a/internal/storage/query.go |
| 2735 | +++ b/internal/storage/query.go |
| 2736 | @@ -11,10 +11,16 @@ import ( |
| 2737 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 2738 | ) |
| 2739 | |
| 2740 | +// Access controls which private and callback events a query may return. All |
| 2741 | +// bypasses recipient filtering but still excludes hidden, pending and expired |
| 2742 | +// rows. |
| 2743 | type Access struct { |
| 2744 | PubKeys []string |
| 2745 | All bool |
| 2746 | } |
| 2747 | + |
| 2748 | +// QueryOptions supplies query time, access principals, an optional result |
| 2749 | +// limit and a cursor from a prior Query result. |
| 2750 | type QueryOptions struct { |
| 2751 | Now int64 |
| 2752 | Access Access |
| 2753 | @@ -27,6 +33,9 @@ type EventCursor struct { |
| 2754 | CreatedAt int64 |
| 2755 | ID string |
| 2756 | } |
| 2757 | + |
| 2758 | +// QueryResult contains the matching events and whether the limit hid more |
| 2759 | +// rows. |
| 2760 | type QueryResult struct { |
| 2761 | Events []event.Event |
| 2762 | More bool |
| 2763 | diff --git a/internal/storage/retention.go b/internal/storage/retention.go |
| 2764 | index b3c2c95..ee3db71 100644 |
| 2765 | --- a/internal/storage/retention.go |
| 2766 | +++ b/internal/storage/retention.go |
| 2767 | @@ -7,14 +7,21 @@ import ( |
| 2768 | "fmt" |
| 2769 | ) |
| 2770 | |
| 2771 | +// RetentionRule removes non-identity events older than Days. A negative Kind |
| 2772 | +// applies the catch-all rule to kinds eligible for retention. |
| 2773 | type RetentionRule struct { |
| 2774 | Kind int |
| 2775 | Days int |
| 2776 | } |
| 2777 | + |
| 2778 | +// MemberRetention removes eligible events and list history for one author |
| 2779 | +// older than Days. |
| 2780 | type MemberRetention struct { |
| 2781 | PubKey string |
| 2782 | Days int |
| 2783 | } |
| 2784 | + |
| 2785 | +// RetentionOptions supplies the retention time, relay identity and rules. |
| 2786 | type RetentionOptions struct { |
| 2787 | Now int64 |
| 2788 | Identity string |
| 2789 | @@ -86,6 +93,7 @@ func deleteCount(ctx context.Context, tx *sql.Tx, query string, args ...any) (in |
| 2790 | return result.RowsAffected() |
| 2791 | } |
| 2792 | |
| 2793 | +// KindStats reports count, size and time bounds for one stored event kind. |
| 2794 | type KindStats struct { |
| 2795 | Kind int `json:"kind"` |
| 2796 | N int64 `json:"n"` |
| 2797 | diff --git a/internal/storage/save.go b/internal/storage/save.go |
| 2798 | index 5907b85..89cb0ea 100644 |
| 2799 | --- a/internal/storage/save.go |
| 2800 | +++ b/internal/storage/save.go |
| 2801 | @@ -13,6 +13,9 @@ import ( |
| 2802 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 2803 | ) |
| 2804 | |
| 2805 | +// Intent describes durable work to insert with an event save. The tuple of |
| 2806 | +// Kind, EventID and Target determines its stable ID; Payload is retained for |
| 2807 | +// the worker handling that kind. |
| 2808 | type Intent struct { |
| 2809 | Kind string `json:"kind"` |
| 2810 | EventID string `json:"event_id"` |
| 2811 | @@ -20,6 +23,12 @@ type Intent struct { |
| 2812 | Payload string `json:"payload"` |
| 2813 | } |
| 2814 | |
| 2815 | +// SaveOptions controls event persistence. Now is the Unix time used for |
| 2816 | +// history and intent timestamps. SearchMode is empty for the default indexed |
| 2817 | +// kinds, "full" for all public content and "off" to skip content indexing. |
| 2818 | +// For stored events, Intents and BeforeCommit run in the event transaction. |
| 2819 | +// Ephemeral events return before those hooks. A BeforeCommit error rolls back |
| 2820 | +// the transaction owned by Save; SaveTx returns it to the transaction's owner. |
| 2821 | type SaveOptions struct { |
| 2822 | Now int64 |
| 2823 | SearchMode string |
| 2824 | @@ -27,11 +36,17 @@ type SaveOptions struct { |
| 2825 | BeforeCommit func(context.Context, *sql.Tx) error |
| 2826 | } |
| 2827 | |
| 2828 | +// SaveResult reports the event sequence. Ephemeral events return without a |
| 2829 | +// database row and set Ephemeral. |
| 2830 | type SaveResult struct { |
| 2831 | Sequence int64 |
| 2832 | Ephemeral bool |
| 2833 | } |
| 2834 | |
| 2835 | +// Save commits an event, its indexes and the supplied transaction hooks |
| 2836 | +// together. Callers validate the event before saving. Duplicate, replaced, |
| 2837 | +// deleted and vanished events return a Rejection. Ephemeral events return an |
| 2838 | +// Ephemeral result without writing rows or running the hooks. |
| 2839 | func (s *Store) Save(ctx context.Context, e event.Event, opts SaveOptions) (SaveResult, error) { |
| 2840 | var result SaveResult |
| 2841 | err := s.WithTx(ctx, func(tx *sql.Tx) error { |
| 2842 | diff --git a/internal/storage/store.go b/internal/storage/store.go |
| 2843 | index 9cd9c5e..70b7e2d 100644 |
| 2844 | --- a/internal/storage/store.go |
| 2845 | +++ b/internal/storage/store.go |
| 2846 | @@ -1,4 +1,3 @@ |
| 2847 | -// Package storage owns each tenant's local SQLite database and transaction boundary. |
| 2848 | package storage |
| 2849 | |
| 2850 | import ( |
| 2851 | @@ -15,6 +14,7 @@ import ( |
| 2852 | _ "modernc.org/sqlite" |
| 2853 | ) |
| 2854 | |
| 2855 | +// Rejection is a stable Save error that callers can match with errors.Is. |
| 2856 | type Rejection string |
| 2857 | |
| 2858 | func (e Rejection) Error() string { return string(e) } |
| 2859 | @@ -26,10 +26,14 @@ const ( |
| 2860 | ErrVanished Rejection = "blocked: this pubkey asked for its events to be deleted" |
| 2861 | ) |
| 2862 | |
| 2863 | +// Observer receives operation name, outcome and duration for instrumented |
| 2864 | +// Store operations. |
| 2865 | type Observer interface { |
| 2866 | - ObserveStorage(string, string, time.Duration) |
| 2867 | + ObserveStorage(operation, outcome string, duration time.Duration) |
| 2868 | } |
| 2869 | |
| 2870 | +// Store is a tenant's SQLite database. A Store must be closed when its owner |
| 2871 | +// is done with it. |
| 2872 | type Store struct { |
| 2873 | db *sql.DB |
| 2874 | path string |
| 2875 | @@ -37,6 +41,9 @@ type Store struct { |
| 2876 | observer Observer |
| 2877 | } |
| 2878 | |
| 2879 | +// Open creates or opens path, initializes the tenant schema and returns a |
| 2880 | +// Store. The parent directory is created with mode 0700 and the database file |
| 2881 | +// with mode 0600. |
| 2882 | func Open(ctx context.Context, path string) (*Store, error) { |
| 2883 | if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { |
| 2884 | return nil, fmt.Errorf("create database directory: %w", err) |
| 2885 | @@ -58,8 +65,8 @@ func Open(ctx context.Context, path string) (*Store, error) { |
| 2886 | if err != nil { |
| 2887 | return nil, fmt.Errorf("open database: %w", err) |
| 2888 | } |
| 2889 | - // One connection is the initial serialization boundary. Reader pools can be |
| 2890 | - // introduced after measurements without changing transaction semantics. |
| 2891 | + // One connection serializes tenant database operations. Transaction hooks |
| 2892 | + // use their supplied transaction while it holds this connection. |
| 2893 | db.SetMaxOpenConns(1) |
| 2894 | db.SetMaxIdleConns(1) |
| 2895 | s := &Store{db: db, path: path} |
| 2896 | @@ -69,9 +76,18 @@ func Open(ctx context.Context, path string) (*Store, error) { |
| 2897 | return s, nil |
| 2898 | } |
| 2899 | |
| 2900 | -func (s *Store) DB() *sql.DB { return s.db } |
| 2901 | +// DB returns the database used by tenant services for their schemas, queries |
| 2902 | +// and transactions. Event persistence uses Save or SaveTx to apply storage's |
| 2903 | +// replacement, deletion and indexing rules. |
| 2904 | +func (s *Store) DB() *sql.DB { return s.db } |
| 2905 | + |
| 2906 | +// Path returns the database file path supplied to Open. |
| 2907 | func (s *Store) Path() string { return s.path } |
| 2908 | + |
| 2909 | +// Close releases the database connection. |
| 2910 | func (s *Store) Close() error { return s.db.Close() } |
| 2911 | + |
| 2912 | +// SetObserver replaces the observer used for operation timing and outcomes. |
| 2913 | func (s *Store) SetObserver(o Observer) { |
| 2914 | s.observerMu.Lock() |
| 2915 | defer s.observerMu.Unlock() |
| 2916 | @@ -91,8 +107,9 @@ func (s *Store) observe(operation string, started time.Time, err error) { |
| 2917 | s.observer.ObserveStorage(operation, outcome, time.Since(started)) |
| 2918 | } |
| 2919 | |
| 2920 | -// WithTx must not call another Store method: the callback owns the connection. |
| 2921 | -// A successful return guarantees all domain changes and work intents committed. |
| 2922 | +// WithTx opens a transaction, calls fn and commits after fn succeeds. It rolls |
| 2923 | +// back on failure. The callback uses the supplied transaction for database |
| 2924 | +// work because it holds the store's sole connection until completion. |
| 2925 | func (s *Store) WithTx(ctx context.Context, fn func(*sql.Tx) error) (err error) { |
| 2926 | started := time.Now() |
| 2927 | defer func() { s.observe("commit", started, err) }() |
| 2928 | diff --git a/internal/syncprotocol/doc.go b/internal/syncprotocol/doc.go |
| 2929 | new file mode 100644 |
| 2930 | index 0000000..4e7936c |
| 2931 | --- /dev/null |
| 2932 | +++ b/internal/syncprotocol/doc.go |
| 2933 | @@ -0,0 +1,4 @@ |
| 2934 | +// Package syncprotocol contains the relay's NIP-45 count sketch and NIP-77 |
| 2935 | +// negentropy session wrappers. The wrappers exchange protocol strings while |
| 2936 | +// exposing relay-friendly event IDs and reconciliation results. |
| 2937 | +package syncprotocol |
| 2938 | diff --git a/internal/syncprotocol/hll.go b/internal/syncprotocol/hll.go |
| 2939 | index dcabb2c..6118b08 100644 |
| 2940 | --- a/internal/syncprotocol/hll.go |
| 2941 | +++ b/internal/syncprotocol/hll.go |
| 2942 | @@ -25,6 +25,9 @@ func NewHLL(offset int) *HLL { |
| 2943 | return &HLL{offset: offset} |
| 2944 | } |
| 2945 | |
| 2946 | +// HLLFilterOffset derives the register byte offset from the single tag value |
| 2947 | +// used by the relay's count filter. Hashed values and address-style values are |
| 2948 | +// both accepted. |
| 2949 | func HLLFilterOffset(f event.Filter) (int, bool) { |
| 2950 | if len(f.Tags) != 1 { |
| 2951 | return 0, false |
| 2952 | @@ -51,6 +54,7 @@ func HLLFilterOffset(f event.Filter) (int, bool) { |
| 2953 | return 0, false |
| 2954 | } |
| 2955 | |
| 2956 | +// Add incorporates one 32-byte hexadecimal public key into the sketch. |
| 2957 | func (h *HLL) Add(pubkeyHex string) error { |
| 2958 | if len(pubkeyHex) != 64 || !isHexCharacters(pubkeyHex) { |
| 2959 | return fmt.Errorf("invalid pubkey: expected 32-byte hex") |
| 2960 | @@ -75,8 +79,10 @@ func (h *HLL) Add(pubkeyHex string) error { |
| 2961 | return nil |
| 2962 | } |
| 2963 | |
| 2964 | +// Hex returns the 256 registers in their wire hexadecimal form. |
| 2965 | func (h *HLL) Hex() string { return hex.EncodeToString(h.regs[:]) } |
| 2966 | |
| 2967 | +// MergeHex unions another 256-register sketch into h. |
| 2968 | func (h *HLL) MergeHex(encoded string) error { |
| 2969 | if len(encoded) != hllRegisters*2 { |
| 2970 | return fmt.Errorf("invalid HLL: expected %d hex characters", hllRegisters*2) |
| 2971 | diff --git a/internal/syncprotocol/negentropy.go b/internal/syncprotocol/negentropy.go |
| 2972 | index e7b1d13..e6789d3 100644 |
| 2973 | --- a/internal/syncprotocol/negentropy.go |
| 2974 | +++ b/internal/syncprotocol/negentropy.go |
| 2975 | @@ -8,11 +8,14 @@ import ( |
| 2976 | "fiatjaf.com/nostr/nip77/negentropy/storage/vector" |
| 2977 | ) |
| 2978 | |
| 2979 | +// Item identifies one event in a reconciliation index. |
| 2980 | type Item struct { |
| 2981 | ID string |
| 2982 | Timestamp int64 |
| 2983 | } |
| 2984 | |
| 2985 | +// Result contains one negentropy response and the IDs discovered by the |
| 2986 | +// initiator when reconciliation completes. |
| 2987 | type Result struct { |
| 2988 | Response string |
| 2989 | Have []string |
| 2990 | @@ -20,11 +23,16 @@ type Result struct { |
| 2991 | Done bool |
| 2992 | } |
| 2993 | |
| 2994 | +// Session holds one stateful negentropy exchange. A session is used by one |
| 2995 | +// initiator or responder for the lifetime of that exchange. |
| 2996 | type Session struct { |
| 2997 | neg *negentropy.Negentropy |
| 2998 | initiator bool |
| 2999 | } |
| 3000 | |
| 3001 | +// NewSession builds a sealed negentropy index from the relay's event IDs and |
| 3002 | +// timestamps. The initiator flag selects the side that starts the exchange |
| 3003 | +// and receives Have and Need IDs. |
| 3004 | func NewSession(items []Item, initiator bool) (*Session, error) { |
| 3005 | vec := vector.New() |
| 3006 | for _, item := range items { |
| 3007 | @@ -38,6 +46,7 @@ func NewSession(items []Item, initiator bool) (*Session, error) { |
| 3008 | return &Session{neg: negentropy.New(vec, 60_000, initiator, initiator), initiator: initiator}, nil |
| 3009 | } |
| 3010 | |
| 3011 | +// Start begins an initiator session and returns its protocol message. |
| 3012 | func (s *Session) Start() (string, error) { |
| 3013 | if !s.initiator { |
| 3014 | return "", fmt.Errorf("only the initiator can start a negentropy session") |
| 3015 | @@ -45,6 +54,8 @@ func (s *Session) Start() (string, error) { |
| 3016 | return s.neg.Start(), nil |
| 3017 | } |
| 3018 | |
| 3019 | +// Reconcile consumes one peer message and returns the next response. An |
| 3020 | +// initiator receives IDs present on each side once the exchange is complete. |
| 3021 | func (s *Session) Reconcile(message string) (Result, error) { |
| 3022 | if message == "" { |
| 3023 | return Result{}, fmt.Errorf("empty negentropy message") |
| 3024 | diff --git a/internal/telemetry/doc.go b/internal/telemetry/doc.go |
| 3025 | new file mode 100644 |
| 3026 | index 0000000..257e474 |
| 3027 | --- /dev/null |
| 3028 | +++ b/internal/telemetry/doc.go |
| 3029 | @@ -0,0 +1,9 @@ |
| 3030 | +// Package telemetry is the relay's process-local observability boundary. |
| 3031 | +// |
| 3032 | +// Telemetry owns an isolated Prometheus registry, OpenTelemetry tracer |
| 3033 | +// provider, structured logger and authenticated diagnostics handlers. Metrics |
| 3034 | +// use bounded operation and outcome labels. New configures sampling and an |
| 3035 | +// optional OTLP HTTP exporter; Close flushes and shuts down the tracer |
| 3036 | +// provider. HTTP exposes metrics and, when configured, token-protected |
| 3037 | +// diagnostics without registering process-wide handlers. |
| 3038 | +package telemetry |
| 3039 | diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go |
| 3040 | index 26c7a8f..54bcb0c 100644 |
| 3041 | --- a/internal/telemetry/telemetry.go |
| 3042 | +++ b/internal/telemetry/telemetry.go |
| 3043 | @@ -1,4 +1,3 @@ |
| 3044 | -// Package telemetry contains the relay's low-cardinality observability boundary. |
| 3045 | package telemetry |
| 3046 | |
| 3047 | import ( |
| 3048 | diff --git a/internal/templates/doc.go b/internal/templates/doc.go |
| 3049 | new file mode 100644 |
| 3050 | index 0000000..32540be |
| 3051 | --- /dev/null |
| 3052 | +++ b/internal/templates/doc.go |
| 3053 | @@ -0,0 +1,10 @@ |
| 3054 | +// Package templates contains the built-in relay policy and connection catalog. |
| 3055 | +// |
| 3056 | +// Templates describe a named starting policy, event kind limits, retention, |
| 3057 | +// source settings and recommended connections. Connections describe the app, |
| 3058 | +// visibility, inputs, links and copyable values shown by the relay UI. The |
| 3059 | +// catalog functions return copies so callers can customize results without |
| 3060 | +// changing the built-in definitions. ApplyTemplate starts with policy defaults |
| 3061 | +// for an owner, applies the template patch, then applies kind and retention |
| 3062 | +// limits. |
| 3063 | +package templates |
| 3064 | diff --git a/internal/templates/templates.go b/internal/templates/templates.go |
| 3065 | index e1d7365..7459e7c 100644 |
| 3066 | --- a/internal/templates/templates.go |
| 3067 | +++ b/internal/templates/templates.go |
| 3068 | @@ -1,4 +1,3 @@ |
| 3069 | -// Package templates contains the built-in declarative relay and connection catalog. |
| 3070 | package templates |
| 3071 | |
| 3072 | import ( |
| 3073 | @@ -8,6 +7,7 @@ import ( |
| 3074 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 3075 | ) |
| 3076 | |
| 3077 | +// Template describes a built-in relay policy and its recommended connections. |
| 3078 | type Template struct { |
| 3079 | Name string |
| 3080 | Title string |
| 3081 | @@ -21,6 +21,7 @@ type Template struct { |
| 3082 | Connections []string |
| 3083 | } |
| 3084 | |
| 3085 | +// Connection describes one client integration displayed for a relay. |
| 3086 | type Connection struct { |
| 3087 | Name string |
| 3088 | Title string |
| 3089 | @@ -35,7 +36,10 @@ type Connection struct { |
| 3090 | Links []Link |
| 3091 | } |
| 3092 | |
| 3093 | +// Input describes a value a connection asks the user to supply. |
| 3094 | type Input struct{ Name, Label, Placeholder, Default, Pattern string } |
| 3095 | + |
| 3096 | +// Link describes an external URL or copyable value for a connection. |
| 3097 | type Link struct{ Label, Href, Copy string } |
| 3098 | |
| 3099 | var templateCatalog = []Template{ |
| 3100 | @@ -78,6 +82,8 @@ func Names() []string { |
| 3101 | } |
| 3102 | return out |
| 3103 | } |
| 3104 | + |
| 3105 | +// Connections returns the built-in connection catalog in display order. |
| 3106 | func Connections() []Connection { |
| 3107 | out := make([]Connection, len(connectionCatalog)) |
| 3108 | for i, c := range connectionCatalog { |
| 3109 | @@ -85,6 +91,8 @@ func Connections() []Connection { |
| 3110 | } |
| 3111 | return out |
| 3112 | } |
| 3113 | + |
| 3114 | +// Find returns a named template and a boolean indicating whether it exists. |
| 3115 | func Find(name string) (Template, bool) { |
| 3116 | for _, t := range templateCatalog { |
| 3117 | if t.Name == name { |
| 3118 | diff --git a/internal/views/doc.go b/internal/views/doc.go |
| 3119 | new file mode 100644 |
| 3120 | index 0000000..29d1b6c |
| 3121 | --- /dev/null |
| 3122 | +++ b/internal/views/doc.go |
| 3123 | @@ -0,0 +1,9 @@ |
| 3124 | +// Package views parses fenced code blocks and connects them to custom view |
| 3125 | +// renderers. |
| 3126 | +// |
| 3127 | +// Blocks preserves source order and normalizes CRLF line endings. Hash names |
| 3128 | +// an artifact by its language and source, while Path gives its relay URL. |
| 3129 | +// Figure emits accessible markup with the rendered image and an inline source |
| 3130 | +// fallback. Renderer maps each lowercased language to the first registered |
| 3131 | +// view that supports it. |
| 3132 | +package views |
| 3133 | diff --git a/internal/views/views.go b/internal/views/views.go |
| 3134 | index f77ca8e..46e0e34 100644 |
| 3135 | --- a/internal/views/views.go |
| 3136 | +++ b/internal/views/views.go |
| 3137 | @@ -1,7 +1,3 @@ |
| 3138 | -// Package views holds the parts of custom views that the renderers and the |
| 3139 | -// relay share: finding fenced code blocks in text, naming a block by its |
| 3140 | -// hash and the markup that shows an artifact in place of a block. It knows |
| 3141 | -// nothing about any particular transform. |
| 3142 | package views |
| 3143 | |
| 3144 | import ( |
| 3145 | diff --git a/internal/webpush/doc.go b/internal/webpush/doc.go |
| 3146 | new file mode 100644 |
| 3147 | index 0000000..dcfb285 |
| 3148 | --- /dev/null |
| 3149 | +++ b/internal/webpush/doc.go |
| 3150 | @@ -0,0 +1,9 @@ |
| 3151 | +// Package webpush implements browser push delivery for the relay. |
| 3152 | +// |
| 3153 | +// Subscription validates an HTTPS endpoint, an uncompressed P-256 subscriber |
| 3154 | +// key and its 16-byte authentication secret. Keys stores the relay's P-256 |
| 3155 | +// VAPID key and serializes it as unpadded base64url PKCS#8. Encrypt implements |
| 3156 | +// RFC 8291 aes128gcm payload encryption. Send builds the RFC 8292 VAPID |
| 3157 | +// authorization, posts the encrypted record, and reports whether the service |
| 3158 | +// accepted or retired the subscription. |
| 3159 | +package webpush |
| 3160 | diff --git a/internal/webpush/webpush.go b/internal/webpush/webpush.go |
| 3161 | index 056bdd4..bafaa61 100644 |
| 3162 | --- a/internal/webpush/webpush.go |
| 3163 | +++ b/internal/webpush/webpush.go |
| 3164 | @@ -1,6 +1,3 @@ |
| 3165 | -// Package webpush sends Web Push messages: RFC 8291 payload encryption with |
| 3166 | -// the aes128gcm content encoding and RFC 8292 VAPID authorization. It uses |
| 3167 | -// only the standard library so the relay keeps its dependency footprint. |
| 3168 | package webpush |
| 3169 | |
| 3170 | import ( |
| 3171 | diff --git a/internal/webui/blossom-encryption.js b/internal/webui/blossom-encryption.js |
| 3172 | index 2f62933..6b389a6 100644 |
| 3173 | --- a/internal/webui/blossom-encryption.js |
| 3174 | +++ b/internal/webui/blossom-encryption.js |
| 3175 | @@ -1,8 +1,9 @@ |
| 3176 | -// BUD-15 draft (June 2026), BUD-15: Client-side CHK encrypted blobs. |
| 3177 | -// https://github.com/hzrd149/blossom/pull/104 |
| 3178 | -// Keys are client-only values. Callers must not include k in requests to a server. |
| 3179 | +// BUD-15 client-side CHK encrypted blobs. Keys remain in the browser page and |
| 3180 | +// are passed to the caller for sharing or later decryption. |
| 3181 | (() => { |
| 3182 | "use strict"; |
| 3183 | + // Browser encryption for Blossom content. The public API stores and checks |
| 3184 | + // content-addressed ciphertext while keeping keys in the caller's page. |
| 3185 | |
| 3186 | const encoder = new TextEncoder(); |
| 3187 | const zeroNonce = new Uint8Array(12); |
| 3188 | diff --git a/internal/webui/blossom-manifests.js b/internal/webui/blossom-manifests.js |
| 3189 | index 7496ce2..ea03065 100644 |
| 3190 | --- a/internal/webui/blossom-manifests.js |
| 3191 | +++ b/internal/webui/blossom-manifests.js |
| 3192 | @@ -1,7 +1,10 @@ |
| 3193 | -// BUD-16 and BUD-17 MessagePack manifests. This is maintained browser source, |
| 3194 | -// embedded directly by the Go templates; it is intentionally not bundled. |
| 3195 | +// BUD-16 and BUD-17 MessagePack manifests. Go templates embed this browser |
| 3196 | +// source directly so the same codec serves pages and file controls. |
| 3197 | (() => { |
| 3198 | "use strict"; |
| 3199 | + // Manifest codec and tree reader. The API encodes and validates file and |
| 3200 | + // directory manifests, builds chunked trees and reads them through caller |
| 3201 | + // supplied fetch functions. |
| 3202 | const CHUNK_SIZE = 2 * 1024 * 1024; |
| 3203 | const MAX_LINKS = 174; |
| 3204 | const MAX_DEPTH = 64; |
| 3205 | diff --git a/internal/webui/blossom-upload.js b/internal/webui/blossom-upload.js |
| 3206 | index ba1c180..20d5602 100644 |
| 3207 | --- a/internal/webui/blossom-upload.js |
| 3208 | +++ b/internal/webui/blossom-upload.js |
| 3209 | @@ -1,9 +1,9 @@ |
| 3210 | -// BUD-14 draft multipart uploads. |
| 3211 | -// https://github.com/hzrd149/blossom/pull/102 |
| 3212 | -// Upload state is deliberately held by the caller. It does not survive a page |
| 3213 | -// navigation because BUD-14 has no offset discovery protocol. |
| 3214 | +// BUD-14 multipart uploads. Upload state belongs to the caller and supports |
| 3215 | +// retry, progress and cancellation for the active page. |
| 3216 | (() => { |
| 3217 | "use strict"; |
| 3218 | + // Upload transport. plan describes signed requests for a payload; upload |
| 3219 | + // executes that plan with retry, progress and cancellation support. |
| 3220 | |
| 3221 | const {sha256, hex} = globalThis.tiny.util; |
| 3222 | const asBytes = async value => { |
| 3223 | diff --git a/internal/webui/bridge.js b/internal/webui/bridge.js |
| 3224 | index 225c3b5..09d0325 100644 |
| 3225 | --- a/internal/webui/bridge.js |
| 3226 | +++ b/internal/webui/bridge.js |
| 3227 | @@ -1,6 +1,7 @@ |
| 3228 | // Signer bridge: session sign-in and sign-out, NIP-07 and NIP-46 signers, |
| 3229 | -// Nostr Connect, and the NIP-98 signed fetch that every management call uses. |
| 3230 | -// The browser never receives a relay private key; it only asks the signer. |
| 3231 | +// Nostr Connect and the NIP-98 signed fetch used by authenticated calls. |
| 3232 | +// It exposes tiny.localPath, tiny.authorization and tiny.signedFetch, and |
| 3233 | +// announces a connected signer with tiny:signer. |
| 3234 | (() => { |
| 3235 | "use strict"; |
| 3236 | |
| 3237 | diff --git a/internal/webui/doc.go b/internal/webui/doc.go |
| 3238 | new file mode 100644 |
| 3239 | index 0000000..8623e9e |
| 3240 | --- /dev/null |
| 3241 | +++ b/internal/webui/doc.go |
| 3242 | @@ -0,0 +1,13 @@ |
| 3243 | +// Package webui renders the relay's HTML pages and browser support modules. |
| 3244 | +// |
| 3245 | +// App owns the HTTP surface, templates, page data and browser assets. It asks |
| 3246 | +// Backend for relay identity, policy and method results, and uses ActorResolver |
| 3247 | +// to associate a request with the authenticated public key. The daemon owns |
| 3248 | +// relay policy and relay writes. |
| 3249 | +// |
| 3250 | +// Pages use a shared shell in page.html. The shell supplies the navigation rail, |
| 3251 | +// content column, contextual panel and command footer. Each page template |
| 3252 | +// supplies one content section and remains readable when scripts are disabled. |
| 3253 | +// JavaScript modules progressively add signing, navigation, forms, components, |
| 3254 | +// room streams, file transfers and WebMCP controls. |
| 3255 | +package webui |
| 3256 | diff --git a/internal/webui/file-messages.js b/internal/webui/file-messages.js |
| 3257 | index d9b33e1..28eb6df 100644 |
| 3258 | --- a/internal/webui/file-messages.js |
| 3259 | +++ b/internal/webui/file-messages.js |
| 3260 | @@ -1,4 +1,5 @@ |
| 3261 | -/* NIP-17 file messages. This module never publishes to ordinary relays. */ |
| 3262 | +/* NIP-17 file messages. The module builds encrypted rumors and gift-wrap |
| 3263 | + events, discovers inbox relays and sends bounded deliveries. */ |
| 3264 | (() => { |
| 3265 | "use strict"; |
| 3266 | const {relayURL} = globalThis.tiny.util; |
| 3267 | diff --git a/internal/webui/file-workspace.js b/internal/webui/file-workspace.js |
| 3268 | index 06886fe..3af6ef4 100644 |
| 3269 | --- a/internal/webui/file-workspace.js |
| 3270 | +++ b/internal/webui/file-workspace.js |
| 3271 | @@ -1,4 +1,5 @@ |
| 3272 | -// Draft BUD-16/17 trees. Every object written here is encrypted with BUD-15. |
| 3273 | +// File workspace controls. Selected files and folders become encrypted |
| 3274 | +// Blossom chunks and manifests; references are read back in the browser. |
| 3275 | (() => { |
| 3276 | "use strict"; |
| 3277 | |
| 3278 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 3279 | index 5946017..56db928 100644 |
| 3280 | --- a/internal/webui/page.html |
| 3281 | +++ b/internal/webui/page.html |
| 3282 | @@ -1,3 +1,7 @@ |
| 3283 | +{{/* The page shell owns the rail, content column, contextual panel and |
| 3284 | +footer. Page templates render one section selected by .Tab. The body works |
| 3285 | +before scripts load; tiny.js and bridge.js establish shared browser services, |
| 3286 | +then feature modules are loaded for the current page. */}} |
| 3287 | {{define "page"}}<!doctype html> |
| 3288 | <html lang="en" data-js="no" data-scripts="{{scriptsVersion}}"> |
| 3289 | <head> |
| 3290 | diff --git a/internal/webui/private-services.js b/internal/webui/private-services.js |
| 3291 | index ef3b280..9a0e8e4 100644 |
| 3292 | --- a/internal/webui/private-services.js |
| 3293 | +++ b/internal/webui/private-services.js |
| 3294 | @@ -1,5 +1,5 @@ |
| 3295 | -// PrivateServices edits the encrypted GRASP-08 kind 10318 list. The relay |
| 3296 | -// only sees the signed event; NIP-44 encryption stays with the user's signer. |
| 3297 | +// PrivateServices edits the encrypted kind 10318 relay list. NIP-44 encryption |
| 3298 | +// and event signing stay with the user's signer. |
| 3299 | (() => { |
| 3300 | "use strict"; |
| 3301 | |
| 3302 | diff --git a/internal/webui/read_contracts.go b/internal/webui/read_contracts.go |
| 3303 | index afbe140..54a712d 100644 |
| 3304 | --- a/internal/webui/read_contracts.go |
| 3305 | +++ b/internal/webui/read_contracts.go |
| 3306 | @@ -8,20 +8,37 @@ import ( |
| 3307 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 3308 | ) |
| 3309 | |
| 3310 | -// RoomsReader is the presentation-owned read contract for room pages. The |
| 3311 | -// legacy Backend.Query method remains supported for adapters that have not |
| 3312 | -// adopted this interface yet. |
| 3313 | +// RoomsReader supplies the typed reads used by room pages. actor is the |
| 3314 | +// authenticated public key, or an empty string for an anonymous request. |
| 3315 | +// cursor is empty for the newest page and otherwise is the value returned in |
| 3316 | +// NextCursor. limit requests at most that many rows. The daemon adapter uses |
| 3317 | +// 100 when the requested limit falls outside 1 through 100. |
| 3318 | +// |
| 3319 | +// ListRooms returns visible room summaries ordered by room ID and a cursor for |
| 3320 | +// the next page. ReadRoom returns the room summary, visible members, newest |
| 3321 | +// room messages, message edits and a cursor for older messages. ReadThread |
| 3322 | +// returns the room summary, one root event, its replies, edits and a cursor for |
| 3323 | +// older replies. Reads enforce tenant and room access and return errors for |
| 3324 | +// missing rooms, invalid cursors or an inaccessible room. ReadThread also |
| 3325 | +// returns an error when rootID is not a lower-case hexadecimal event ID or does |
| 3326 | +// not identify a message in roomID. |
| 3327 | type RoomsReader interface { |
| 3328 | - ListRooms(context.Context, string, string, int) (RoomList, error) |
| 3329 | - ReadRoom(context.Context, string, string, string, int) (RoomPage, error) |
| 3330 | - ReadThread(context.Context, string, string, string, string, int) (RoomPage, error) |
| 3331 | + // ListRooms returns up to limit visible room summaries after cursor. |
| 3332 | + ListRooms(ctx context.Context, actor, cursor string, limit int) (RoomList, error) |
| 3333 | + // ReadRoom returns one visible room, its members, messages and edits after cursor. |
| 3334 | + ReadRoom(ctx context.Context, actor, roomID, cursor string, limit int) (RoomPage, error) |
| 3335 | + // ReadThread returns one room thread root, replies and edits after cursor. |
| 3336 | + ReadThread(ctx context.Context, actor, roomID, rootID, cursor string, limit int) (RoomPage, error) |
| 3337 | } |
| 3338 | |
| 3339 | type RoomList struct { |
| 3340 | - Rooms []RoomSummary `json:"rooms"` |
| 3341 | - NextCursor string `json:"next_cursor,omitempty"` |
| 3342 | + // Rooms contains visible room summaries in ascending ID order. |
| 3343 | + Rooms []RoomSummary `json:"rooms"` |
| 3344 | + // NextCursor is the last room ID when another page is available. |
| 3345 | + NextCursor string `json:"next_cursor,omitempty"` |
| 3346 | } |
| 3347 | |
| 3348 | +// RoomSummary is the room metadata shown in the rail and room header. |
| 3349 | type RoomSummary struct { |
| 3350 | ID string `json:"id"` |
| 3351 | Name string `json:"name,omitempty"` |
| 3352 | @@ -46,6 +63,9 @@ type RoomMember struct { |
| 3353 | |
| 3354 | type RoomMessage = event.Event |
| 3355 | |
| 3356 | +// RoomPage is the typed result for a room or thread read. A room read fills |
| 3357 | +// Members and Messages; a thread read fills Root and Replies. Edits contains |
| 3358 | +// the newest visible edit for the returned targets. |
| 3359 | type RoomPage struct { |
| 3360 | Room RoomSummary `json:"room"` |
| 3361 | Members []RoomMember `json:"members,omitempty"` |
| 3362 | @@ -56,9 +76,8 @@ type RoomPage struct { |
| 3363 | NextCursor string `json:"next_cursor,omitempty"` |
| 3364 | } |
| 3365 | |
| 3366 | -// rowsReader adapts the legacy method-and-JSON backend to the read shapes |
| 3367 | -// used by page renderers. It keeps wire compatibility while making list |
| 3368 | -// queries consistently return rows at the presentation boundary. |
| 3369 | +// rowsReader converts method-and-JSON query results into the row slices used |
| 3370 | +// by page renderers. |
| 3371 | type rowsReader struct{ backend Backend } |
| 3372 | |
| 3373 | func (r rowsReader) rows(ctx context.Context, method string, params []json.RawMessage, actor string) ([]any, error) { |
| 3374 | diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html |
| 3375 | index ebca938..6b45829 100644 |
| 3376 | --- a/internal/webui/rooms.html |
| 3377 | +++ b/internal/webui/rooms.html |
| 3378 | @@ -1,4 +1,6 @@ |
| 3379 | -{{/* Rooms: the list, one room's timeline with its compose bar, and one thread. */}} |
| 3380 | +{{/* Rooms renders the list, one room's timeline with its compose bar and one |
| 3381 | +thread. room-live receives the room stream after the server-rendered page |
| 3382 | +establishes the initial state. */}} |
| 3383 | {{define "rooms"}}<section id="rooms"> |
| 3384 | <h1>Rooms</h1> |
| 3385 | {{if .Error}}{{template "alert" .Error}} |
| 3386 | diff --git a/internal/webui/rooms.js b/internal/webui/rooms.js |
| 3387 | index 9e1fb9b..5eb86d3 100644 |
| 3388 | --- a/internal/webui/rooms.js |
| 3389 | +++ b/internal/webui/rooms.js |
| 3390 | @@ -2,6 +2,10 @@ |
| 3391 | // runs after components.js has published the shared UI helper contract. |
| 3392 | (() => { |
| 3393 | "use strict"; |
| 3394 | + // Room elements own composition, room management actions and live updates. |
| 3395 | + // room-compose signs kind 9 and 12 events, room-create signs room metadata, |
| 3396 | + // room-action handles membership controls, and room-live consumes the room |
| 3397 | + // event stream. |
| 3398 | const tiny = window.tiny; |
| 3399 | const {FormElement, el, isHex64} = tiny.ui; |
| 3400 | // Rooms: the compose bar, room creation, room administration and the live |
| 3401 | diff --git a/internal/webui/tiny.js b/internal/webui/tiny.js |
| 3402 | index 9b8e8b7..5a7def6 100644 |
| 3403 | --- a/internal/webui/tiny.js |
| 3404 | +++ b/internal/webui/tiny.js |
| 3405 | @@ -1,5 +1,7 @@ |
| 3406 | -// Shared browser helpers. This file is loaded before every feature module |
| 3407 | -// and the signer bridge, so each helper exists exactly once. |
| 3408 | +// Shared browser helpers. This file is loaded before every feature module and |
| 3409 | +// the signer bridge. It creates tiny.util for byte, URL, encoding and wiki |
| 3410 | +// name operations, tiny.signing for verified event signing and publication, |
| 3411 | +// and tiny.require for loading page-specific bundles once. |
| 3412 | (() => { |
| 3413 | "use strict"; |
| 3414 | const tiny = globalThis.tiny || {}; |
| 3415 | diff --git a/internal/webui/webmcp.js b/internal/webui/webmcp.js |
| 3416 | index 073817b..0e54821 100644 |
| 3417 | --- a/internal/webui/webmcp.js |
| 3418 | +++ b/internal/webui/webmcp.js |
| 3419 | @@ -1,5 +1,9 @@ |
| 3420 | (function () { |
| 3421 | "use strict"; |
| 3422 | + // WebMCP registers read, write and navigation tools for the browser model |
| 3423 | + // context. Read tools call the relay query surface, write tools use the |
| 3424 | + // signed management or event boundary, and navigation tools open relay |
| 3425 | + // pages in the current tab. |
| 3426 | |
| 3427 | const context = document.modelContext || navigator.modelContext; |
| 3428 | const state = window.tinyWebMCP = { |
| 3429 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 3430 | index ba16c70..c024b02 100644 |
| 3431 | --- a/internal/webui/webui.go |
| 3432 | +++ b/internal/webui/webui.go |
| 3433 | @@ -1,6 +1,3 @@ |
| 3434 | -// Package webui contains the deliberately plain HTML surface for a hosted |
| 3435 | -// relay. It owns presentation and form routing; relay policy and mutations |
| 3436 | -// remain in the daemon supplied Backend. |
| 3437 | package webui |
| 3438 | |
| 3439 | import ( |
| 3440 | @@ -34,8 +31,12 @@ var signerJS []byte |
| 3441 | //go:embed fixi.js |
| 3442 | var fixiJS []byte |
| 3443 | |
| 3444 | +// Backend supplies relay identity and the method calls used by page handlers. |
| 3445 | +// Query receives the request context, method name, JSON arguments and actor |
| 3446 | +// public key, and returns the method's JSON-shaped result. Policy, URL, slug |
| 3447 | +// and identity describe the relay rendered into each page. |
| 3448 | type Backend interface { |
| 3449 | - Query(context.Context, string, []json.RawMessage, string) (any, error) |
| 3450 | + Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) |
| 3451 | Policy() policy.Policy |
| 3452 | URL() string |
| 3453 | Slug() string |
| 3454 | @@ -48,8 +49,12 @@ type CustomViewSource interface { |
| 3455 | CustomViews() []views.View |
| 3456 | } |
| 3457 | |
| 3458 | -type ActorResolver func(*http.Request) (string, error) |
| 3459 | -type QRProvider func(string) ([]byte, error) |
| 3460 | +// ActorResolver returns the authenticated public key associated with request. |
| 3461 | +// An empty key represents an anonymous browser request. |
| 3462 | +type ActorResolver func(request *http.Request) (string, error) |
| 3463 | + |
| 3464 | +// QRProvider encodes text as an image for pages that show a connection QR. |
| 3465 | +type QRProvider func(text string) ([]byte, error) |
| 3466 | |
| 3467 | type App struct { |
| 3468 | backend Backend |
| 3469 | diff --git a/internal/wiki/doc.go b/internal/wiki/doc.go |
| 3470 | new file mode 100644 |
| 3471 | index 0000000..aca8864 |
| 3472 | --- /dev/null |
| 3473 | +++ b/internal/wiki/doc.go |
| 3474 | @@ -0,0 +1,8 @@ |
| 3475 | +// Package wiki implements storage-independent NIP-54 article behavior. |
| 3476 | +// |
| 3477 | +// Normalize converts a title to an article d tag. Links discovers wikilinks, |
| 3478 | +// reference links and nostr: targets in Djot content. RenderHTML supports the |
| 3479 | +// article syntax used by the relay, escapes text and unsafe link targets, and |
| 3480 | +// returns template HTML for server-side insertion. RenderHTMLWith accepts a |
| 3481 | +// fenced-block callback so custom views can replace selected code blocks. |
| 3482 | +package wiki |
| 3483 | diff --git a/internal/wiki/wiki.go b/internal/wiki/wiki.go |
| 3484 | index 5c6b692..68a9011 100644 |
| 3485 | --- a/internal/wiki/wiki.go |
| 3486 | +++ b/internal/wiki/wiki.go |
| 3487 | @@ -1,6 +1,3 @@ |
| 3488 | -// Package wiki implements the NIP-54 rules that do not depend on storage: |
| 3489 | -// article name normalization, link discovery and a conservative Djot |
| 3490 | -// renderer for article content. |
| 3491 | package wiki |
| 3492 | |
| 3493 | import ( |
| 3494 | diff --git a/internal/work/doc.go b/internal/work/doc.go |
| 3495 | new file mode 100644 |
| 3496 | index 0000000..c631c5b |
| 3497 | --- /dev/null |
| 3498 | +++ b/internal/work/doc.go |
| 3499 | @@ -0,0 +1,15 @@ |
| 3500 | +// Package work provides durable, fenced work intents backed by a storage.Store. |
| 3501 | +// |
| 3502 | +// Queue.Enqueue is idempotent by intent ID. Claim selects the oldest due |
| 3503 | +// pending intent, or a running intent whose lease expired, and assigns a |
| 3504 | +// random claim token. Renew, Complete, Retry and CancelClaim update an intent |
| 3505 | +// only while that token owns the claim, fencing updates from earlier attempts. |
| 3506 | +// |
| 3507 | +// Worker invokes the handler for a claim while renewing its lease. A nil or |
| 3508 | +// missing handler is an ordinary failure and is retried. Handler errors are |
| 3509 | +// retried with deterministic jittered exponential backoff; Stop marks the |
| 3510 | +// intent cancelled and records the underlying error. If lease renewal fails, |
| 3511 | +// the handler context is cancelled and its result is discarded as fenced. |
| 3512 | +// RunPool starts the requested number of workers, defaults to four, and stops |
| 3513 | +// the pool when its context is cancelled or a queue operation fails. |
| 3514 | +package work |
| 3515 | diff --git a/internal/work/work.go b/internal/work/work.go |
| 3516 | index f1dc58a..60ed70f 100644 |
| 3517 | --- a/internal/work/work.go |
| 3518 | +++ b/internal/work/work.go |
| 3519 | @@ -1,4 +1,3 @@ |
| 3520 | -// Package work provides durable, fenced work intents backed by SQLite. |
| 3521 | package work |
| 3522 | |
| 3523 | import ( |
| 3524 | @@ -20,6 +19,7 @@ import ( |
| 3525 | |
| 3526 | const defaultLease = 30 * time.Second |
| 3527 | |
| 3528 | +// ErrClaimLost denotes loss of worker lease ownership. |
| 3529 | var ErrClaimLost = errors.New("work: claim lost") |
| 3530 | |
| 3531 | type stopError struct{ err error } |
| 3532 | @@ -37,7 +37,9 @@ func (e *stopError) Unwrap() error { return e.err } |
| 3533 | // instead of retrying it, while retaining the underlying error for operators. |
| 3534 | func Stop(err error) error { return &stopError{err: err} } |
| 3535 | |
| 3536 | -// Intent is a durable unit of work. ClaimToken is only valid until ClaimUntil. |
| 3537 | +// Intent is a durable unit of work. ClaimToken identifies the current claim; |
| 3538 | +// ClaimUntil determines when another worker can claim an expired lease. |
| 3539 | +// State is pending, running, completed or cancelled. |
| 3540 | type Intent struct { |
| 3541 | ID string |
| 3542 | Kind string |
| 3543 | @@ -322,17 +324,24 @@ func metricKind(kind string) string { |
| 3544 | return "other" |
| 3545 | } |
| 3546 | |
| 3547 | -// Handler processes one claimed intent. |
| 3548 | -type Handler func(context.Context, Intent) error |
| 3549 | +// Handler processes one claimed intent while the worker renews its lease. |
| 3550 | +// A nil error completes the claim; an error schedules a retry. Stop marks a |
| 3551 | +// terminal failure. The handler observes ctx cancellation so the worker can |
| 3552 | +// finish shutdown or release an attempt whose claim was lost. A retry can |
| 3553 | +// repeat an external effect after an interrupted acknowledgment; handlers own |
| 3554 | +// deduplication of those effects. |
| 3555 | +type Handler func(ctx context.Context, intent Intent) error |
| 3556 | |
| 3557 | // WorkerOptions controls claim leases and optional handler timing observation. |
| 3558 | +// Lease and Renew default to 30 seconds and one third of the lease. |
| 3559 | type WorkerOptions struct { |
| 3560 | Lease time.Duration |
| 3561 | Renew time.Duration |
| 3562 | Observe func(kind, outcome string, duration time.Duration) |
| 3563 | } |
| 3564 | |
| 3565 | -// PoolOptions controls a pool of durable workers. |
| 3566 | +// PoolOptions controls a pool of durable workers. Workers less than or equal |
| 3567 | +// to zero use the default of four. |
| 3568 | type PoolOptions struct { |
| 3569 | Workers int |
| 3570 | Worker WorkerOptions |