clone

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

docs: describe package contracts and service lifecycles

Dami committed 5651305
Document all internal packages and the tiny command with source overviews.
Clarify service ownership, transaction hooks, callback arguments, worker
lifetimes and error behavior at their declarations.
Update the architecture map and source navigation guidance for the current
implementation, with browser module and template ownership comments.

Validation: make verify, go build -mod=readonly ./..., Go declaration and
JavaScript output comparisons, template content checks and go doc review.
commit
5651305803654c1ad0b7a74247cf0425f6067cdc
parent
681b50f87af0a59de9583676ed7bedece43b07f0
author
Dami <dami.etoile@gmail.com>
101 files changed, +1094 -261
MREADME.md16++++++-
Acmd/tiny/doc.go9++++
Mcmd/tiny/main.go7+++
Mdocs/architecture.md48++++++++++++++-------
Minternal/auth/auth.go19++++++--
Ainternal/auth/doc.go6+++
Minternal/auth/grasp08.go17+++----
Minternal/blob/doc.go26+++++++++++-
Minternal/blob/service.go31++++++++++---
Minternal/catalog/catalog.go20+++++++--
Ainternal/catalog/doc.go11+++++
Minternal/community/community.go1-
Minternal/community/config.go6++-
Ainternal/community/doc.go16+++++++
Minternal/community/membership.go14+++---
Minternal/community/records_reader.go2+-
Minternal/communityread/communityread.go11+++--
Ainternal/communityread/doc.go6+++
Minternal/configport/configport.go14++++++-
Ainternal/configport/doc.go10++++
Minternal/daemon/callbacks.go9++--
Minternal/daemon/callbacks_service.go6++-
Minternal/daemon/custom_views.go5+-
Minternal/daemon/daemon.go23+++++++++-
Ainternal/daemon/doc.go52+++++++++++++++++++++++
Minternal/daemon/event_followups.go33+++++++++++---
Minternal/daemon/http.go4++
Minternal/daemon/lifecycle.go9+++-
Minternal/daemon/maintenance.go10++++
Minternal/daemon/private_grasp.go16+++----
Minternal/daemon/services.go17++++++-
Minternal/daemon/tenant.go35++++++++++++++-
Ainternal/domains/doc.go11+++++
Minternal/domains/domains.go20+++++---
Ainternal/event/doc.go5++
Minternal/event/event.go24++++++++++-
Minternal/event/filter.go13++++++
Minternal/event/kinds.go12++++-
Ainternal/gates/doc.go4++
Minternal/gates/gates.go18++++++--
Minternal/gitrelay/backup_import.go4++
Ainternal/gitrelay/doc.go24++++++++++
Minternal/gitrelay/gitrelay.go14+++---
Ainternal/mcp/doc.go11+++++
Minternal/mcp/mcp.go8+---
Minternal/mcp/server.go2-
Minternal/policy/access.go4++
Ainternal/policy/doc.go4++
Minternal/policy/policy.go22+++++++---
Minternal/policy/privacy.go8+--
Ainternal/records/doc.go15+++++++
Minternal/records/records.go34++++++++-------
Ainternal/relay/doc.go10++++
Minternal/relay/relay.go69++++++++++++++++++++----------
Minternal/replication/backup.go18++++++--
Ainternal/replication/doc.go19++++++++
Minternal/replication/jobs.go5++
Minternal/replication/replication.go21++++++---
Minternal/replication/service.go8+++
Minternal/replication/sync.go5++
Minternal/replication/websocket.go21+++++++--
Ainternal/sites/doc.go21+++++++++
Minternal/sites/service.go27+++++++++---
Minternal/sites/sites.go8+++
Ainternal/storage/doc.go22++++++++++
Minternal/storage/history.go11+++++
Minternal/storage/maintenance.go10++++
Minternal/storage/query.go9++++
Minternal/storage/retention.go8+++
Minternal/storage/save.go15+++++++
Minternal/storage/store.go31++++++++++---
Ainternal/syncprotocol/doc.go4++
Minternal/syncprotocol/hll.go6+++
Minternal/syncprotocol/negentropy.go11+++++
Ainternal/telemetry/doc.go9++++
Minternal/telemetry/telemetry.go1-
Ainternal/templates/doc.go10++++
Minternal/templates/templates.go10++++-
Ainternal/views/doc.go9++++
Minternal/views/views.go4--
Ainternal/webpush/doc.go9++++
Minternal/webpush/webpush.go3-
Minternal/webui/blossom-encryption.js7++-
Minternal/webui/blossom-manifests.js7++-
Minternal/webui/blossom-upload.js8++--
Minternal/webui/bridge.js5+-
Ainternal/webui/doc.go13++++++
Minternal/webui/file-messages.js3+-
Minternal/webui/file-workspace.js3+-
Minternal/webui/page.html4++
Minternal/webui/private-services.js4+-
Minternal/webui/read_contracts.go41+++++++++++++-----
Minternal/webui/rooms.html4+-
Minternal/webui/rooms.js4++
Minternal/webui/tiny.js6++-
Minternal/webui/webmcp.js4++
Minternal/webui/webui.go17+++++---
Ainternal/wiki/doc.go8+++
Minternal/wiki/wiki.go3-
Ainternal/work/doc.go15+++++++
Minternal/work/work.go19++++++--
M README.md +14 -2
@@ -79,8 +79,6 @@ See [Wiki](docs/wiki.md) for pages, versions and forks, merge requests and redir
7979 8080 See [Custom views](docs/views.md) for rendering fenced code blocks through a transform you run and keeping the result as signed artifacts.8181 82-See [Architecture](docs/architecture.md) for service responsibilities, event acceptance and dependency checks.83-8482 **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.8583 8684 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:
@@ -130,6 +128,20 @@ flowchart LR
130128 131129 See [Personal relay deployment](docs/personal-relay.md) for the reference installation.132130 131+## Explore the code132+133+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.134+135+Use editor symbol help or read the same comments from the command line:136+137+```sh138+go doc ./internal/daemon139+go doc ./internal/storage SaveOptions140+go doc ./internal/work Handler141+```142+143+See [Architecture](docs/architecture.md) for the package map, event acceptance and dependency checks.144+133145 ## Test and diagnose134146 135147 ```sh
A cmd/tiny/doc.go +9 -0
M cmd/tiny/main.go +7 -0
@@ -33,6 +33,8 @@ func main() {
3333 }3434 }3535 36+// run dispatches arguments after the executable name and writes command output37+// to out. Returning errors to main keeps process exit at the command boundary.3638 func run(ctx context.Context, args []string, out io.Writer) error {3739 if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {3840 _, 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")
@@ -215,6 +217,8 @@ func hexOwner(value string) bool {
215217 return true216218 }217219 220+// serve owns listeners and peer monitoring around the App lifecycle. Shutdown221+// drains the servers and joins peer monitoring before deferred App cleanup.218222 func serve(ctx context.Context, args []string, out io.Writer) error {219223 opts, err := parseServe(args)220224 if err != nil {
@@ -297,6 +301,9 @@ func serve(ctx context.Context, args []string, out io.Writer) error {
297301 return serverErr298302 }299303 304+// awaitServers starts shutdown when ctx ends or a server returns. Each server305+// must report once to results; all reports are drained before returning the306+// joined shutdown and serving errors.300307 func awaitServers(ctx context.Context, servers []*http.Server, results <-chan error) error {301308 var first error302309 received := 0
M docs/architecture.md +32 -16
@@ -1,31 +1,47 @@
11 # Architecture22 3-Each tenant has a durable store and a set of services. The daemon constructs those services, admits operations and manages their lifetimes.3+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.4+5+## Find your starting point6+7+[`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.48 59 ## Responsibilities610 7-| Area | Responsibility |11+| Packages | Responsibility |812 | --- | --- |9-| `event` and `policy` | Event formats and shared policy decisions. |10-| `storage` | Event persistence, queries and transaction boundaries. |11-| `community` | Membership, moderation, rooms and their projections. |12-| `records` | Relay identity, signed records and notification records. Community data arrives through a required read interface. |13-| `blob` | File storage, upload rules and file HTTP endpoints. |14-| `gitrelay` | Repository admission, storage, synchronization, repair and Git HTTP endpoints. |15-| `replication` | Replication policy, jobs and delivery handlers. |16-| `work` | Durable queue claims, retries and worker lifetimes. The daemon combines handlers from the services. |17-| `webui` | Pages, typed room reads and browser interactions. |18-19-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.13+| [`event`](../internal/event/doc.go), [`policy`](../internal/policy/doc.go) | Event and filter values, signing, validation and shared policy decisions. |14+| [`auth`](../internal/auth/doc.go), [`gates`](../internal/gates/doc.go) | Signed request proofs, event admission and visibility checks. |15+| [`storage`](../internal/storage/doc.go), [`work`](../internal/work/doc.go) | Event persistence, queries, transaction hooks and durable queue claims. |16+| [`community`](../internal/community/doc.go), [`communityread`](../internal/communityread/doc.go) | Membership, moderation, rooms, agents and the read models consumed by records. |17+| [`records`](../internal/records/doc.go) | Relay identity, signed protocol records, projections and notifications. |18+| [`blob`](../internal/blob/doc.go), [`sites`](../internal/sites/doc.go) | Content-addressed files, upload rules and sites backed by signed manifests. |19+| [`gitrelay`](../internal/gitrelay/doc.go) | Repository admission, object storage, synchronization, repair and Git HTTP endpoints. |20+| [`replication`](../internal/replication/doc.go), [`syncprotocol`](../internal/syncprotocol/doc.go) | Relay synchronization plans and transports, count sketches and reconciliation sessions. |21+| [`relay`](../internal/relay/doc.go), [`mcp`](../internal/mcp/doc.go), [`webui`](../internal/webui/doc.go) | WebSocket sessions, MCP requests, HTML pages and browser interactions. |22+| [`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. |23+| [`catalog`](../internal/catalog/doc.go), [`domains`](../internal/domains/doc.go) | Tenant lifecycle records, filesystem paths and host mappings. |24+| [`templates`](../internal/templates/doc.go), [`configport`](../internal/configport/doc.go) | Built-in policy templates and configuration import, export and application. |25+| [`telemetry`](../internal/telemetry/doc.go), [`daemon`](../internal/daemon/doc.go) | Observability, service assembly, request routing and worker ownership. |26+27+## Ownership and contracts28+29+`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.30+31+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.32+33+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`.2034 2135 ## Event acceptance2236 23-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.37+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.2438 25-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).39+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).2640 2741 ## Browser boundaries2842 29-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.43+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.44+45+## Dependencies and checks3046 3147 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.
M internal/auth/auth.go +14 -5
@@ -1,5 +1,3 @@
1-// Package auth identifies Nostr callers at HTTP and websocket boundaries.2-// Access policy belongs to the caller; this package only verifies proofs.31 package auth42 53 import (
@@ -27,6 +25,8 @@ type Validator struct {
2725 seen map[string]time.Time2826 }2927 28+// NewValidator uses now to check proof freshness and track replay windows.29+// A nil clock selects the system clock.3030 func NewValidator(now func() time.Time) *Validator {3131 if now == nil {3232 now = time.Now
@@ -34,6 +34,9 @@ func NewValidator(now func() time.Time) *Validator {
3434 return &Validator{now: now, seen: make(map[string]time.Time)}3535 }3636 37+// VerifyNIP98 validates a kind 27235 request proof against the exact URL,38+// method and body supplied by the caller. Successfully verified event IDs are39+// retained for the one-minute replay window.3740 func (v *Validator) VerifyNIP98(header, rawURL, method, body string) (event.Event, error) {3841 e, err := decodeToken(header, "NIP-98")3942 if err != nil {
@@ -64,13 +67,15 @@ func (v *Validator) VerifyNIP98(header, rawURL, method, body string) (event.Even
6467 return e, nil6568 }6669 70+// VerifyBlossom validates a BUD-11 authorization for action. It accepts the71+// protocol's reusable authorization form and checks its action and expiry.6772 func (v *Validator) VerifyBlossom(header, action string) (event.Event, error) {6873 return v.verifyBlossom(header, action, "", "", false)6974 }7075 71-// VerifyBlossomRequest verifies a BUD-11 request, including optional server72-// domain and blob-hash scope. It is separate from VerifyBlossom to preserve73-// callers that use the older, server-agnostic helper.76+// VerifyBlossomRequest validates a BUD-11 authorization with optional server77+// and blob-hash scope. Upload, delete, mirror and media actions require an x78+// tag when this scoped form is used.7479 func (v *Validator) VerifyBlossomRequest(header, action, server, blobHash string) (event.Event, error) {7580 return v.verifyBlossom(header, action, server, blobHash, true)7681 }
@@ -215,6 +220,8 @@ type ChallengeManager struct {
215220 active map[string]time.Time216221 }217222 223+// NewChallengeManager creates a NIP-42 challenge store for relayURL. Issued224+// challenges are single-use and expire with the validator replay window.218225 func NewChallengeManager(relayURL string, now func() time.Time) *ChallengeManager {219226 if now == nil {220227 now = time.Now
@@ -222,6 +229,7 @@ func NewChallengeManager(relayURL string, now func() time.Time) *ChallengeManage
222229 return &ChallengeManager{relayURL: relayURL, now: now, active: make(map[string]time.Time)}223230 }224231 232+// Issue creates and stores a cryptographically random NIP-42 challenge.225233 func (m *ChallengeManager) Issue() (string, error) {226234 raw := make([]byte, 32)227235 if _, err := rand.Read(raw); err != nil {
@@ -236,6 +244,7 @@ func (m *ChallengeManager) Issue() (string, error) {
236244 return challenge, nil237245 }238246 247+// Verify validates and consumes a NIP-42 AUTH event for the configured relay.239248 func (m *ChallengeManager) Verify(e event.Event) error {240249 if err := event.Validate(e); err != nil {241250 return authError(err.Error())
A internal/auth/doc.go +6 -0
M internal/auth/grasp08.go +7 -10
@@ -8,16 +8,13 @@ import (
88 "github.com/FelineStateMachine/tinyrelay/internal/event"99 )1010 11-// ErrGRASP08Unauthorized is intentionally detail-free. GRASP-08 uses an12-// empty 401 challenge so a caller cannot distinguish an unknown repository13-// from an unauthorized one.11+// ErrGRASP08Unauthorized is the detail-free GRASP-08 authentication error12+// returned for every failed repository proof.1413 var ErrGRASP08Unauthorized = errors.New("auth-required:")1514 16-// VerifyGRASP08 verifies the reusable repository-root proof used by the17-// GRASP-08 private-service profile. The proof is a kind 27235 event naming18-// the repository root and GET; it is valid for 60 seconds and is reusable19-// across the repository's Smart HTTP requests. Unlike NIP-98, payload tags20-// are intentionally ignored and the proof is not replay-tracked.15+// VerifyGRASP08 validates a reusable kind 27235 GET proof for the repository16+// root named by a GRASP-08 Smart HTTP request. The proof remains valid for17+// one minute across the repository's supported Git requests.2118 func (v *Validator) VerifyGRASP08(header, rawURL string) (event.Event, error) {2219 e, err := decodeToken(header, "GRASP-08")2320 if err != nil {
@@ -40,8 +37,8 @@ func (v *Validator) VerifyGRASP08(header, rawURL string) (event.Event, error) {
4037 return e, nil4138 }4239 43-// GRASP08RepositoryRoot returns the canonical repository URL for a Smart HTTP44-// request beneath a .git path.40+// GRASP08RepositoryRoot returns the canonical repository URL for a supported41+// Smart HTTP request beneath a .git path.4542 func GRASP08RepositoryRoot(raw string) (string, bool) {4643 u, err := url.Parse(raw)4744 if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil {
M internal/blob/doc.go +25 -1
@@ -1,2 +1,26 @@
1-// Package blob implements the Blossom and NIP-96 file doors for a local relay.1+// Package blob implements the local relay's Blossom and NIP-96 file endpoints.2+//3+// Service stores immutable blob bytes under Config.Root/blobs, addressed by4+// their lowercase SHA-256 digest. Metadata, uploader claims, moderation5+// state, tombstones and multipart ranges live in the supplied storage.Store.6+// New creates the metadata tables, reconciles files left by an interrupted7+// upload and removes stale multipart state before returning a ready service.8+// The caller drives service operations and mounts its Handler in the HTTP9+// router. Startup and request work run within those calls.10+//11+// Put validates and hashes the input before making a new object visible. Its12+// optional Commit callback runs in the metadata transaction, which lets a13+// caller record related ownership or application state atomically with the14+// blob claim. Get returns an open file only after metadata and moderation15+// checks pass. Delete removes metadata transactionally and then removes the16+// content file; DeleteForUploader removes one claim and removes shared bytes17+// only when the final claim is gone.18+//19+// HTTP authorization is supplied by Config.Authorize. The callback returns20+// the authenticated pubkey used for ownership and quota checks. Config's21+// CanRead, IsOwner, ValidateUpload and RecordReport hooks provide the host22+// policy and moderation boundaries without making this package own identity23+// or event storage. Multipart uploads are reserved in the metadata database,24+// staged under the blob root and finalized only after all declared ranges25+// are present.226 package blob
M internal/blob/service.go +24 -7
@@ -16,6 +16,7 @@ import (
1616 "sync"1717 )1818 19+// Action identifies the file-door operation being authorized.1920 type Action string2021 2122 const (
@@ -27,11 +28,22 @@ const (
2728 ActionReport Action = "report"2829 )2930 30-type Authorize func(*http.Request, Action) (string, error)31-type CanRead func(context.Context, string, []string) bool32-type ResolveIP func(context.Context, string) ([]net.IP, error)33-type ReportRecorder func(context.Context, *sql.Tx, string, string, string, string, string) error31+// Authorize authenticates an HTTP request for an action and returns the32+// caller's pubkey.33+type Authorize func(request *http.Request, action Action) (pubkey string, err error)3434 35+// CanRead reports whether any supplied reader pubkey may read the blob.36+type CanRead func(ctx context.Context, blobSHA string, readerPubkeys []string) bool37+38+// ResolveIP resolves a host for mirror URL policy checks.39+type ResolveIP func(ctx context.Context, host string) ([]net.IP, error)40+41+// ReportRecorder records a report in the host moderation transaction. The42+// targetKind and reportType values describe the reported object and report;43+// blob reports currently pass "blob" for both.44+type ReportRecorder func(ctx context.Context, tx *sql.Tx, reporter string, targetBlob string, targetKind string, reportType string, content string) error45+46+// Config supplies durable storage, HTTP policy and host integration hooks.3547 type Config struct {3648 Root string3749 PublicURL string
@@ -57,6 +69,7 @@ type Config struct {
5769 UploadTerms func(context.Context, string) (UploadTerms, error)5870 }5971 72+// Limits are applied when each upload starts. Zero leaves that limit open.6073 type Limits struct {6174 MaxFileBytes int646275 UserStorageBytes int64
@@ -76,6 +89,7 @@ type UploadTerms struct {
7689 // image, video, audio or document is plain.7790 var encryptedTypes = map[string]bool{"application/octet-stream": true, "application/vnd.blossom.directory+msgpack": true}7891 92+// Blob is the metadata for one content-addressed object.7993 type Blob struct {8094 SHA256 string `json:"sha256"`8195 Size int64 `json:"size"`
@@ -84,17 +98,20 @@ type Blob struct {
8498 Uploaded int64 `json:"uploaded"`8599 }86100 101+// PutOptions describes a blob upload and its optional metadata transaction.87102 type PutOptions struct {88103 Reader io.Reader89104 Type string90105 Uploader string91106 Hash string92- // Commit records ownership metadata in the blob transaction. The final93- // argument reports whether the blob is new. An error rolls back all claims.107+ // Commit records ownership metadata in the blob transaction. Its isNew108+ // argument reports whether the blob was newly installed. An error rolls109+ // back all claims.94110 // New bytes remain unavailable until their ownership metadata is durable.95- Commit func(context.Context, *sql.Tx, Blob, bool) error111+ Commit func(ctx context.Context, tx *sql.Tx, blob Blob, isNew bool) error96112 }97113 114+// Service owns blob files and their metadata operations.98115 type Service struct {99116 config Config100117 root string
M internal/catalog/catalog.go +15 -5
A internal/catalog/doc.go +11 -0
M internal/community/community.go +0 -1
M internal/community/config.go +4 -2
A internal/community/doc.go +16 -0
M internal/community/membership.go +6 -8
@@ -71,11 +71,10 @@ type MembershipResult struct {
7171 NewRequest bool7272 }7373 74-// HandleMembershipEventTx applies join/leave admission and invokes persist in75-// the same SQLite transaction. The callback must store the accepted event (or76-// return an error); membership and projection work then roll back together.77-// Management events that need records-owned state should use the root's78-// transaction hooks and EnqueueProjectionTx directly.74+// HandleMembershipEventTx applies join/leave admission in a transaction owned75+// by the service and invokes persist with that transaction. persist owns76+// accepted-event storage; returning an error rolls back membership, the77+// accepted event and queued projection work together.7978 func (s *Service) HandleMembershipEventTx(ctx context.Context, ev event.Event, persist func(*sql.Tx) error) (MembershipResult, error) {8079 if persist == nil {8180 return MembershipResult{}, errors.New("community: nil event persistence callback")
@@ -281,9 +280,8 @@ func (s *Service) HandleModerationEventTx(ctx context.Context, ev event.Event, p
281280 return out, err282281 }283282 284-// OnMembershipApplied is retained as a source compatibility shim. Projection285-// refresh is now driven by durable records-projection work intents; invoking a286-// relay callback here can deadlock a publish fence.283+// OnMembershipApplied ignores fn. Projection refresh uses durable284+// records-projection work intents.287285 func (s *Service) OnMembershipApplied(fn func(event.Event)) {288286 _ = fn289287 }
M internal/community/records_reader.go +1 -1
M internal/communityread/communityread.go +7 -4
A internal/communityread/doc.go +6 -0
M internal/configport/configport.go +13 -1
@@ -1,4 +1,3 @@
1-// Package configport imports, exports, plans and applies bind.ws relay files.21 package configport32 43 import (
@@ -21,16 +20,23 @@ import (
2120 2221 const Format = "bind.ws/relay-config/2"2322 23+// Member is a community member in an exported configuration.2424 type Member struct {2525 PubKey string `json:"pubkey"`2626 Name string `json:"name,omitempty"`2727 Note string `json:"note,omitempty"`2828 Role string `json:"role"`2929 }30+31+// Rule is a retention rule. A nil Kind applies to the tenant default.3032 type Rule struct {3133 Kind *int `json:"kind"`3234 Days int `json:"days"`3335 }36+37+// Config is the JSON representation accepted by Parse and produced by38+// Export. Sections records which keys were present in the input so Apply can39+// distinguish omission from an explicit empty replacement.3440 type Config struct {3541 Format string `json:"format"`3642 ExportedAt int64 `json:"exported_at,omitempty"`
@@ -51,6 +57,8 @@ type Config struct {
5157 Sections []string `json:"-"`5258 Warnings []string `json:"-"`5359 }60+61+// Changes describes the policy and community sections changed by an apply.5462 type Changes struct {5563 Policy []string `json:"policy,omitempty"`5664 Members []string `json:"members,omitempty"`
@@ -58,6 +66,9 @@ type Changes struct {
5866 Warnings []string `json:"warnings,omitempty"`5967 FinalPolicy policy.Policy `json:"-"`6068 }69+70+// ConfigStore coordinates configuration with the tenant store, community71+// service and policy callbacks.6172 type ConfigStore struct {6273 Store *storage.Store6374 Community *community.Service
@@ -70,6 +81,7 @@ type ConfigStore struct {
7081 ValidatePolicy func(policy.Policy) error7182 }7283 84+// ApplyOptions controls an import or preset application.7385 type ApplyOptions struct {7486 DryRun bool7587 MigrationOwner string
A internal/configport/doc.go +10 -0
M internal/daemon/callbacks.go +5 -4
M internal/daemon/callbacks_service.go +5 -1
M internal/daemon/custom_views.go +3 -2
M internal/daemon/daemon.go +20 -3
@@ -1,4 +1,3 @@
1-// Package daemon composes the self-hosted services and owns their lifetimes.21 package daemon32 43 import (
@@ -21,6 +20,10 @@ import (
2120 "github.com/FelineStateMachine/tinyrelay/internal/templates"2221 )2322 23+// Config supplies process-level paths, public addresses, transport limits and24+// operator permissions. Tenant policy supplies each relay's access rules.25+// DataDir is required; DefaultTenant defaults to "main". PublicURL, when set,26+// is an absolute HTTP or HTTPS address used for externally visible URLs.2427 type Config struct {2528 DataDir string2629 PublicURL string
@@ -39,13 +42,15 @@ type Config struct {
3942 // host-wide push registrations. Tenant policy PushCallbacks is a separate4043 // owner-controlled allowlist; both approvals are required.4144 PushCallbackOrigins []string42- // PeerMonitor configures optional NIP-66 liveness probes for explicitly43- // configured peers. It never discovers or crawls public relays.45+ // PeerMonitor configures NIP-66 liveness probes for its listed peers.4446 PeerMonitor *PeerMonitorConfig4547 }4648 4749 type CreateOptions = catalog.CreateOptions4850 51+// App owns the catalog, active tenants and process-wide service lifecycle.52+// New constructs it, Start starts serving work, and Close releases resources.53+// An HTTP server uses App as its handler.4954 type App struct {5055 cfg Config5156 catalog *catalog.Catalog
@@ -57,6 +62,9 @@ type App struct {
5762 lifecycle lifecycleState5863 }5964 65+// New opens catalog and telemetry resources and attempts to resume incomplete66+// tenant creation. The caller owns the returned App and calls Close to release67+// it. Start begins the serving lifecycle.6068 func New(ctx context.Context, cfg Config) (*App, error) {6169 if cfg.DataDir == "" {6270 return nil, errors.New("data directory is required")
@@ -143,6 +151,10 @@ func (a *App) PeerMonitorRun(ctx context.Context) error {
143151 return a.peerMonitor.Run(ctx)144152 }145153 154+// Create provisions a tenant from a template, initializes its durable state155+// and marks it ready. A running App also starts its services. The returned156+// metadata identifies the creation record even if a later setup step fails;157+// RecoverCreating resumes incomplete creation from that durable record.146158 func (a *App) Create(ctx context.Context, opts CreateOptions) (catalog.Tenant, error) {147159 if template, ok := templates.Find(opts.Template); ok && template.Source == "required" && strings.TrimSpace(opts.Source) == "" {148160 return catalog.Tenant{}, fmt.Errorf("template %q requires a source relay", opts.Template)
@@ -271,6 +283,9 @@ func (a *App) tenant(ctx context.Context, meta catalog.Tenant, publicURL string)
271283 return t, nil272284 }273285 286+// Close stops catalog reconciliation and tenant services, then closes shared287+// resources and releases the process lock. It joins cleanup errors and returns288+// nil on subsequent calls. The caller shuts down its HTTP servers separately.274289 func (a *App) Close(ctx context.Context) error {275290 a.mu.Lock()276291 if a.closed {
@@ -294,6 +309,8 @@ func (a *App) Close(ctx context.Context) error {
294309 return errors.Join(errs...)295310 }296311 312+// ServeHTTP resolves the request's tenant or hosted site and delegates to its313+// handler. Health and relay discovery routes are served at the process level.297314 func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {298315 if r.URL.Path == "/relays" && a.ServeLanding(w, r) {299316 return
A internal/daemon/doc.go +52 -0
@@ -0,0 +1,52 @@
1+// Package daemon assembles the services of a multitenant Nostr relay.2+// App owns the tenant catalog, telemetry and active Tenant instances. Each3+// Tenant owns one store, its policy snapshot, protocol handlers and background4+// services. HTTP, WebSocket and management requests reach domain services5+// through these adapters.6+//7+// # Process and tenant lifetimes8+//9+// New opens the catalog and telemetry and attempts to resume incomplete tenant10+// creation.11+// Start acquires the serving process lock, opens ready tenants and starts12+// catalog reconciliation. The caller serves App as an HTTP handler and calls13+// Close after stopping its HTTP listeners. Close stops reconciliation, closes14+// tenants, releases shared resources and releases the process lock. The15+// optional peer monitor runs under the context passed to PeerMonitorRun.16+//17+// A tenant initializes community state and its admission gate before building18+// the protocol and feature services. Custom-view storage is ready when Git19+// initialization can promote repository events; the Git service is then bound20+// to the view service. Handler registration completes before worker goroutines21+// start. Tenant.Close stops new operations, cancels and joins background work,22+// closes live connections and drains active operations before closing storage.23+//24+// # Admission and persistence25+//26+// Tenant implements the relay backend and the typed room reader used by the27+// web UI. Execute dispatches management methods under the supplied actor's28+// current authority. HTTP and WebSocket adapters establish that actor through29+// request authentication. Policy returns a read-only snapshot; policy changes30+// are serialized and replace the snapshot after persistence succeeds.31+//32+// Client publication and replication ingestion use eventCommitter to compose33+// event storage, domain projections and durable intents. Client entry points34+// also process membership, moderation and room actions. The relay router owns35+// live event delivery. A repository state stays pending until Git promotion36+// makes its referenced objects available.37+//38+// # Optional work and maintenance39+//40+// callbackService owns webhook registrations and delivery. customViewService41+// owns transform definitions, requests and signed artifacts. eventFollowups42+// captures matching registrations and persists planning work with accepted43+// events. Planning checks current sources and registrations, uses stable work44+// identities and retries within bounded attempts and age. Each feature's45+// handler owns its remote delivery or transform policy.46+//47+// The maintenance gate admits ordinary operations while exclusive maintenance48+// is idle. A maintenance operation blocks new admissions and drains active49+// operations. Nested calls carry the admission in their context, and callers50+// release it with the returned completion function. Workers use the same gate51+// while operating on the tenant's services and store.52+package daemon
M internal/daemon/event_followups.go +25 -8
@@ -20,9 +20,9 @@ const (
2020 followupMaxAttempts = 32121 )2222 23-// eventFollowups plans optional work without depending on a remote service.24-// Its outbox record commits with the source event; delivery and transforms25-// retain their own bounded retry policies and deterministic intent IDs.23+// eventFollowups turns accepted events into optional callback and transform24+// intents using local state. Its planning record commits with the source25+// event; feature handlers perform remote work using their own retry policies.2626 type eventFollowups struct {2727 store *storage.Store2828 callbacks *callbackService
@@ -31,6 +31,10 @@ type eventFollowups struct {
3131 push func(context.Context, replicationPushFollowupPayload) ([]storage.Intent, error)3232 }3333 34+// eventFollowupPayload captures the signed event and matching registrations.35+// Discovery flags retain failed local lookups for retry. Released identifies36+// repository state whose objects are available, and RecoverPush requests the37+// client registration snapshot saved before Git promotion.3438 type eventFollowupPayload struct {3539 Event event.Event `json:"event"`3640 AcceptedAt int64 `json:"accepted_at"`
@@ -47,6 +51,10 @@ func (f *eventFollowups) prepare(ctx context.Context, e event.Event, now int64,
4751 return f.prepareWithPush(ctx, e, now, released, nil)4852 }4953 54+// prepareWithPush captures eligible targets or lookup failures in an intent55+// for the event transaction. It returns nil for ephemeral events and events56+// with no eligible work. Repository webhook and view discovery starts when57+// released is true.5058 func (f *eventFollowups) prepareWithPush(ctx context.Context, e event.Event, now int64, released bool, push *replicationPushFollowupPayload) *storage.Intent {5159 if event.IsEphemeral(e.Kind) {5260 return nil
@@ -80,9 +88,9 @@ func (f *eventFollowups) prepareWithPush(ctx context.Context, e event.Event, now
8088 return &storage.Intent{Kind: eventFollowupKind, EventID: e.ID, Target: target, Payload: string(raw)}8189 }8290 83-// try runs only local planning after commit. A failure leaves the outbox84-// pending for the worker; success marks planning complete with its derived85-// intents, so a later retry never replays already planned remote work.91+// try runs local planning after commit. A planning failure leaves the parent92+// pending for the worker. Success commits its derived intents and parent93+// completion together. Stable child IDs preserve existing job states on retry.8694 func (f *eventFollowups) try(ctx context.Context, intent *storage.Intent) {8795 if intent == nil {8896 return
@@ -93,6 +101,8 @@ func (f *eventFollowups) try(ctx context.Context, intent *storage.Intent) {
93101 }94102 }95103 104+// handle is the queue adapter. It makes exhausted planning failures terminal;105+// the worker owns completion, retry scheduling and claim-token checks.96106 func (f *eventFollowups) handle(ctx context.Context, job work.Intent) error {97107 err := f.plan(ctx, job, false)98108 if err != nil && job.Attempts >= followupMaxAttempts {
@@ -101,6 +111,10 @@ func (f *eventFollowups) handle(ctx context.Context, job work.Intent) error {
101111 return err102112 }103113 114+// plan rechecks the source, then persists the work each available feature can115+// prepare. A feature error keeps the parent retryable while successful child116+// intents retain their identity. pending allows the synchronous path to mark117+// an unclaimed parent complete; a running worker completes its own claim.104118 func (f *eventFollowups) plan(ctx context.Context, job work.Intent, pending bool) error {105119 var payload eventFollowupPayload106120 if err := json.Unmarshal([]byte(job.Payload), &payload); err != nil || payload.Event.ID != job.EventID {
@@ -171,8 +185,9 @@ func (f *eventFollowups) releasedPush(ctx context.Context, eventID string) (*rep
171185 return payload.Push, nil172186 }173187 174-// Discovery is retried only when the original local lookup failed. The175-// acceptance cutoff prevents a new registration from receiving old events.188+// callbackIntents rechecks captured registrations and retries failed discovery.189+// Recovery requires a creation time strictly before acceptance. Both times190+// have second precision, so uncertain same-second registrations are skipped.176191 func (f *eventFollowups) callbackIntents(ctx context.Context, payload eventFollowupPayload) ([]storage.Intent, error) {177192 if payload.DiscoverCallbacks {178193 ids, err := f.callbacks.CandidateIDs(ctx, payload.Event)
@@ -192,6 +207,8 @@ func (f *eventFollowups) callbackIntents(ctx context.Context, payload eventFollo
192207 return f.callbacks.PrepareFor(ctx, payload.Event, payload.Callbacks)193208 }194209 210+// viewIntents rechecks registration generations and source relevance. Failed211+// discovery uses the same strict acceptance cutoff as callbackIntents.195212 func (f *eventFollowups) viewIntents(ctx context.Context, payload eventFollowupPayload) ([]storage.Intent, error) {196213 if payload.DiscoverViews {197214 targets, err := f.views.CandidateTargets(ctx, payload.Event)
M internal/daemon/http.go +4 -0
M internal/daemon/lifecycle.go +7 -2
M internal/daemon/maintenance.go +10 -0
M internal/daemon/private_grasp.go +7 -9
@@ -23,10 +23,9 @@ func privateGitProof(r *http.Request) (event.Event, bool) {
2323 return proof, ok2424 }2525 26-// PrivateServiceProfile is the protocol-facing description of a GRASP-0827-// tenant. It intentionally contains no tenant name, description, tags, or28-// repository coordinates: those are private data until the caller has29-// authenticated and passed the membership check.26+// PrivateServiceProfile describes a GRASP-08 tenant's configured protocol,27+// authentication methods, read policy and owner identity. Protocol adapters28+// choose the fields appropriate to the request's authenticated audience.3029 type PrivateServiceProfile struct {3130 Enabled bool3231 Protocol string
@@ -35,9 +34,8 @@ type PrivateServiceProfile struct {
3534 Owner string3635 }3736 38-// PrivateAccess is evaluated against the durable community table for every39-// request. Repository maintainers are deliberately absent from this type;40-// maintaining a repository does not grant tenant membership.37+// PrivateAccess records tenant-level authority resolved from current community38+// membership. Owner, member and moderator roles grant private-service access.4139 type PrivateAccess struct {4240 PubKey string4341 Role string
@@ -68,8 +66,8 @@ func (t *Tenant) PrivateProfile() PrivateServiceProfile {
6866 }6967 }7068 71-// PrivateAccess resolves current membership. It deliberately does not cache72-// the result, so removals and bans take effect on the next request.69+// PrivateAccess reads current membership and bans for each request, so access70+// changes apply to the next authorization check.7371 func (t *Tenant) PrivateAccess(ctx context.Context, pubkey string) (PrivateAccess, error) {7472 access := PrivateAccess{PubKey: pubkey}7573 if t == nil || !t.PrivateServiceEnabled() {
M internal/daemon/services.go +14 -3
@@ -33,9 +33,9 @@ import (
3333 "github.com/FelineStateMachine/tinyrelay/internal/work"3434 )3535 36-// initServices constructs the doors around a tenant's single durable store.37-// Each service receives the same policy snapshot function so policy changes38-// take effect without reopening the tenant.36+// initServices constructs feature services around the tenant store and policy37+// reader. Custom-view storage is initialized before Git can promote events;38+// the resulting Git service is bound before handlers and workers start.3939 func (t *Tenant) initServices(ctx context.Context) error {4040 var err error4141 if err := t.initSessions(ctx); err != nil {
@@ -224,6 +224,9 @@ func (b backend) ReadAllowed(ctx context.Context, actor string) error {
224224 return b.tenant.requirePrivateAccess(ctx, actor)225225 }226226 227+// closeServices cancels and joins the tenant's worker, scheduler and Git228+// loops, then returns the worker's terminal queue error, if any. Store and229+// relay connection cleanup belong to Tenant.Close.227230 func (t *Tenant) closeServices(ctx context.Context) error {228231 if t.workCancel != nil {229232 t.workCancel()
@@ -614,6 +617,10 @@ func (t *Tenant) workHandlers() map[string]work.Handler {
614617 return handlers615618 }616619 620+// releaseGit publishes a repository state after Git verifies its objects. It621+// removes the pending marker and persists optional planning in one transaction622+// under the router's publication lock. Push planning retains the registrations623+// captured at client acceptance; webhook and view matching occurs at release.617624 func (t *Tenant) releaseGit(ctx context.Context, id string) error {618625 var followup *storage.Intent619626 if t.followups != nil {
@@ -741,6 +748,10 @@ func (t *Tenant) ingest(ctx context.Context, e event.Event, _ replication.Origin
741748 return err742749 }743750 751+// commitImported applies import admission and commits the shared projections752+// and optional planning for an event. It returns persistence errors to the753+// caller, which decides how to handle duplicate and superseded imports.754+// origin supplies the replication source for planning.744755 func (t *Tenant) commitImported(ctx context.Context, e event.Event, origin replication.Origin) error {745756 if err := t.gate.Import(ctx, e, time.Now().Unix()); err != nil {746757 return err
M internal/daemon/tenant.go +33 -2
@@ -40,6 +40,9 @@ type tenantConfig struct {
4040 publicURL string4141 }4242 43+// Tenant composes one relay's policy, storage, protocol adapters and services.44+// App creates and owns tenants. Request handlers and workers enter the tenant's45+// maintenance gate before accessing shared services.4346 type Tenant struct {4447 app *App4548 meta catalog.Tenant
@@ -146,6 +149,9 @@ func (t *Tenant) authenticated(ctx context.Context, s relay.Session) error {
146149 return nil147150 }148151 152+// Policy returns the current read-only policy snapshot. Its maps and slices153+// share the snapshot's backing data; callers preserve them and submit changes154+// through the policy management path.149155 func (t *Tenant) Policy() policy.Policy {150156 t.mu.RLock()151157 defer t.mu.RUnlock()
@@ -153,6 +159,10 @@ func (t *Tenant) Policy() policy.Policy {
153159 return t.policy154160 }155161 func (t *Tenant) RelayURL() string { return strings.Replace(t.publicURL, "http", "ws", 1) }162+163+// Close stops new operations, joins background services, closes live relay164+// connections and waits for active operations before closing the store. If165+// that wait exceeds ctx, the store stays open for the remaining operations.156166 func (t *Tenant) Close(ctx context.Context) error {157167 t.maintenance.markClosing()158168 servicesErr := t.closeServices(ctx)
@@ -163,6 +173,12 @@ func (t *Tenant) Close(ctx context.Context) error {
163173 return errors.Join(servicesErr, routerErr, t.store.Close())164174 }165175 176+// Publish applies write admission and the event kind's persistence or control177+// action for session s. Its string result is the relay OK message; duplicate178+// events produce a duplicate message with a nil error. Durable event writes179+// include projections and follow-up planning intents in their transaction.180+// Git metadata staging runs after that commit and before a successful reply.181+// The relay router handles live fan-out of accepted events.166182 func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (string, error) {167183 ctx, done, admissionErr := t.beginOperation(ctx)168184 if admissionErr != nil {
@@ -234,8 +250,8 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
234250 return reason, nil235251 }236252 237-// roomScope names the room an event is addressed to when that room is not238-// the tenant's own group, which keeps its established handling.253+// roomScope returns the named room for a room event. An empty result selects254+// the tenant-wide group path, including Marmot group events.239255 func (t *Tenant) roomScope(e event.Event) string {240256 if e.Kind == event.KIND_MARMOT_GROUP {241257 return ""
@@ -289,6 +305,10 @@ func hexLower(value string) bool {
289305 return true290306 }291307 308+// Query returns the authorized union of the filters for session s. It applies309+// per-filter limits, removes duplicate IDs and sorts by descending creation310+// time, then ascending ID. Matching NIP-43 invitations are generated for the311+// requesting session alongside the stored results.292312 func (t *Tenant) Query(ctx context.Context, filters []event.Filter, s relay.Session) ([]event.Event, error) {293313 ctx, done, admissionErr := t.beginOperation(ctx)294314 if admissionErr != nil {
@@ -333,6 +353,9 @@ func (t *Tenant) Query(ctx context.Context, filters []event.Filter, s relay.Sess
333353 return result, nil334354 }335355 356+// Count returns a NIP-45 result with the size of Query's authorized union,357+// using filters with their limits cleared. A supported single-filter request358+// also receives an HLL sketch. The tenant's count feature must be enabled.336359 func (t *Tenant) Count(ctx context.Context, filters []event.Filter, s relay.Session) (any, error) {337360 ctx, done, admissionErr := t.beginOperation(ctx)338361 if admissionErr != nil {
@@ -367,6 +390,8 @@ func (t *Tenant) Count(ctx context.Context, filters []event.Filter, s relay.Sess
367390 return result, nil368391 }369392 393+// Sync supplies visible event IDs and timestamps for a reconciliation session.394+// It clears the filter limit and requires the tenant's sync feature.370395 func (t *Tenant) Sync(ctx context.Context, f event.Filter, s relay.Session) ([]syncprotocol.Item, error) {371396 ctx, done, admissionErr := t.beginOperation(ctx)372397 if admissionErr != nil {
@@ -388,14 +413,20 @@ func (t *Tenant) Sync(ctx context.Context, f event.Filter, s relay.Session) ([]s
388413 return items, nil389414 }390415 416+// CanRead checks an event against current policy and session authority for417+// live delivery, including pending state, moderation and room visibility.391418 func (t *Tenant) CanRead(e event.Event, s relay.Session) bool {392419 return t.gate.CanSee(context.Background(), e, s, nil)393420 }394421 422+// CanReadFilter checks live visibility with the subscription filter's context,423+// including addressed signer messages available during authentication.395424 func (t *Tenant) CanReadFilter(e event.Event, s relay.Session, f *event.Filter) bool {396425 return t.gate.CanSee(context.Background(), e, s, f)397426 }398427 428+// QueryHints returns Query's events and relay completion hints: "finish" or429+// "more", plus "auth" when authentication can affect the query's visibility.399430 func (t *Tenant) QueryHints(ctx context.Context, filters []event.Filter, s relay.Session) ([]event.Event, []string, error) {400431 ctx, done, admissionErr := t.beginOperation(ctx)401432 if admissionErr != nil {
A internal/domains/doc.go +11 -0
M internal/domains/domains.go +12 -8
A internal/event/doc.go +5 -0
M internal/event/event.go +23 -1
@@ -27,6 +27,8 @@ type Event struct {
2727 Sig string `json:"sig"`2828 }2929 30+// Validate checks the wire shape, canonical ID and Schnorr signature of e.31+// Kind-specific checks are applied by the protocol gate that owns the kind.3032 func Validate(e Event) error {3133 if !hex64(e.ID) {3234 return errors.New("invalid: bad id")
@@ -62,6 +64,7 @@ func Validate(e Event) error {
6264 return nil6365 }6466 67+// Parse decodes and validates one complete JSON event object.6568 func Parse(raw []byte) (Event, error) {6669 if err := requireFields(raw); err != nil {6770 return Event{}, err
@@ -76,6 +79,7 @@ func Parse(raw []byte) (Event, error) {
7679 return e, nil7780 }7881 82+// Canonical encodes e as the JSON representation used on relay boundaries.7983 func Canonical(e Event) ([]byte, error) {8084 var out bytes.Buffer8185 encoder := json.NewEncoder(&out)
@@ -86,6 +90,8 @@ func Canonical(e Event) ([]byte, error) {
8690 return bytes.TrimSuffix(out.Bytes(), []byte{'\n'}), nil8791 }8892 93+// Tag returns the first value for name, or an empty string when the tag is94+// absent or has no value.8995 func Tag(e Event, name string) string {9096 for _, tag := range e.Tags {9197 if len(tag) > 0 && tag[0] == name && len(tag) > 1 {
@@ -95,6 +101,7 @@ func Tag(e Event, name string) string {
95101 return ""96102 }97103 104+// TagValues returns every first value belonging to tags named name.98105 func TagValues(e Event, name string) []string {99106 values := make([]string, 0)100107 for _, tag := range e.Tags {
@@ -105,6 +112,8 @@ func TagValues(e Event, name string) []string {
105112 return values106113 }107114 115+// Expiration returns the positive Unix timestamp in the expiration tag.116+// Malformed, missing and nonpositive values return zero.108117 func Expiration(e Event) int64 {109118 v, err := strconv.ParseInt(Tag(e, "expiration"), 10, 64)110119 if err != nil || v <= 0 {
@@ -113,6 +122,7 @@ func Expiration(e Event) int64 {
113122 return v114123 }115124 125+// Difficulty returns the NIP-13 proof-of-work difficulty encoded by id.116126 func Difficulty(id string) int {117127 parsed, err := nostr.IDFromHex(id)118128 if err != nil {
@@ -121,6 +131,7 @@ func Difficulty(id string) int {
121131 return nip13.Difficulty(parsed)122132 }123133 134+// CommittedDifficulty returns the target in a nonce tag's third field.124135 func CommittedDifficulty(e Event) int {125136 for _, tag := range e.Tags {126137 if len(tag) > 2 && tag[0] == "nonce" {
@@ -131,6 +142,8 @@ func CommittedDifficulty(e Event) int {
131142 return 0132143 }133144 145+// Sign derives the public key, ID and Schnorr signature for e from secretHex.146+// A nil tag slice is encoded as an empty tag list before signing.134147 func Sign(e *Event, secretHex string) error {135148 secret, err := decodeSecret(secretHex)136149 if err != nil {
@@ -155,6 +168,7 @@ func Sign(e *Event, secretHex string) error {
155168 return nil156169 }157170 171+// GenerateKey creates a random nonzero 32-byte private key in hexadecimal.158172 func GenerateKey() (string, error) {159173 for {160174 key := make([]byte, 32)
@@ -167,6 +181,7 @@ func GenerateKey() (string, error) {
167181 }168182 }169183 184+// PublicKey derives the x-only Schnorr public key for secretHex.170185 func PublicKey(secretHex string) (string, error) {171186 secret, err := decodeSecret(secretHex)172187 if err != nil {
@@ -176,9 +191,16 @@ func PublicKey(secretHex string) (string, error) {
176191 return hex.EncodeToString(schnorr.SerializePubKey(pubkey)), nil177192 }178193 179-func IsEphemeral(k int) bool { return k >= 20000 && k < 30000 }194+// IsEphemeral reports whether k is in Nostr's ephemeral kind range.195+func IsEphemeral(k int) bool { return k >= 20000 && k < 30000 }196+197+// IsReplaceable reports whether k is a replaceable event kind.180198 func IsReplaceable(k int) bool { return k == 0 || k == 3 || k >= 10000 && k < 20000 }199+200+// IsAddressable reports whether k is an addressable event kind.181201 func IsAddressable(k int) bool { return k >= 30000 && k < 40000 }202+203+// IsPrivate reports whether k carries recipient-scoped content.182204 func IsPrivate(k int) bool {183205 return k == 4 || k == 13 || k == KIND_WRAP || k == 21059 || k == KIND_NOSTR_CONNECT || k == KIND_PUSH_REGISTRATION184206 }
M internal/event/filter.go +13 -0
@@ -6,6 +6,12 @@ import (
66 "strings"77 )88 9+// Filter describes the event fields and tags a subscription or query accepts.10+// Fields combine with AND; values within a list are alternatives. Nil IDs,11+// Authors and Kinds leave that field unrestricted, while an empty non-nil list12+// matches nothing. Tags uses names without the wire prefix #. Since and Until13+// are inclusive Unix timestamps. Limit controls a historical result batch;14+// Matches evaluates each event independently of that limit.915 type Filter struct {1016 IDs []string `json:"ids,omitempty"`1117 Authors []string `json:"authors,omitempty"`
@@ -17,6 +23,8 @@ type Filter struct {
1723 Search string `json:"search,omitempty"`1824 }1925 26+// ParseFilter decodes supported Nostr filter fields, including #tag keys, and27+// checks their JSON types.2028 func ParseFilter(raw []byte) (Filter, error) {2129 var values map[string]json.RawMessage2230 if err := json.Unmarshal(raw, &values); err != nil {
@@ -79,6 +87,7 @@ func ParseFilter(raw []byte) (Filter, error) {
7987 return f, nil8088 }8189 90+// MarshalJSON encodes tag constraints with their Nostr #name wire keys.8291 func (f Filter) MarshalJSON() ([]byte, error) {8392 values := make(map[string]json.RawMessage, 7+len(f.Tags))8493 if f.IDs != nil {
@@ -108,6 +117,8 @@ func (f Filter) MarshalJSON() ([]byte, error) {
108117 return json.Marshal(values)109118 }110119 120+// Matches reports whether e satisfies every populated constraint in f.121+// Search terms are matched against public content and exclude private kinds.111122 func Matches(f Filter, e Event) bool {112123 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) {113124 return false
@@ -136,6 +147,8 @@ func Matches(f Filter, e Event) bool {
136147 return true137148 }138149 150+// SearchTerms splits a search query into terms and drops protocol-style151+// name:value tokens.139152 func SearchTerms(query string) []string { return slicesFilter(strings.Fields(query)) }140153 141154 func slicesFilter(words []string) []string {
M internal/event/kinds.go +9 -3
@@ -80,16 +80,19 @@ var JobFeedbackStatuses = []string{"payment-required", "processing", "error", "s
8080 // JobInputTypes is the NIP-90 vocabulary of the i tag's input type.8181 var JobInputTypes = []string{"url", "event", "job", "text"}8282 83+// IsJobRequest reports whether kind is a NIP-90 request kind.8384 func IsJobRequest(kind int) bool { return kind >= KIND_JOB_REQUEST_MIN && kind <= KIND_JOB_REQUEST_MAX }84-func IsJobResult(kind int) bool { return kind >= KIND_JOB_RESULT_MIN && kind <= KIND_JOB_RESULT_MAX }85+86+// IsJobResult reports whether kind is a NIP-90 result kind.87+func IsJobResult(kind int) bool { return kind >= KIND_JOB_RESULT_MIN && kind <= KIND_JOB_RESULT_MAX }8588 8689 // IsJobKind reports whether the kind is a job request, result or feedback.8790 func IsJobKind(kind int) bool {8891 return IsJobRequest(kind) || IsJobResult(kind) || kind == KIND_JOB_FEEDBACK8992 }9093 91-// JobResultKind returns the result kind for a request kind, or 0 when the92-// kind is not a job request.94+// JobResultKind returns the result kind for a request kind, or 0 for other95+// kinds.9396 func JobResultKind(request int) int {9497 if !IsJobRequest(request) {9598 return 0
@@ -97,6 +100,8 @@ func JobResultKind(request int) int {
97100 return request + 100098101 }99102 103+// IsJobFeedbackStatus reports whether status is in the NIP-90 feedback104+// vocabulary.100105 func IsJobFeedbackStatus(status string) bool {101106 for _, known := range JobFeedbackStatuses {102107 if known == status {
@@ -106,6 +111,7 @@ func IsJobFeedbackStatus(status string) bool {
106111 return false107112 }108113 114+// IsJobInputType reports whether kind is a supported NIP-90 input type.109115 func IsJobInputType(kind string) bool {110116 for _, known := range JobInputTypes {111117 if known == kind {
A internal/gates/doc.go +4 -0
M internal/gates/gates.go +14 -4
@@ -1,4 +1,3 @@
1-// Package gates implements policy checks shared by ingest, query and live fan-out.21 package gates32 43 import (
@@ -16,6 +15,8 @@ import (
1615 "github.com/dlclark/regexp2"1716 )1817 18+// Config supplies the policy and optional durable services used by Gate.19+// Store and Community enable checks that depend on persisted relay state.1920 type Config struct {2021 Store *storage.Store2122 Community *community.Service
@@ -23,6 +24,7 @@ type Config struct {
2324 Slug string2425 }2526 27+// Gate evaluates event admission and visibility for one tenant configuration.2628 type Gate struct {2729 cfg Config2830 agents agentLimiter
@@ -116,6 +118,8 @@ func New(cfg Config) (*Gate, error) {
116118 return &Gate{cfg: cfg}, nil117119 }118120 121+// Write validates a client-published event against protocol shape, tenant122+// policy, membership, bans, room access, retention and proof-of-work rules.119123 func (g *Gate) Write(ctx context.Context, e event.Event, s relay.Session, now int64) error {120124 p := g.cfg.Policy()121125 if e.Kind == event.KIND_AUTH {
@@ -286,9 +290,9 @@ func inboxAdmission(p policy.Policy, e event.Event) bool {
286290 return false287291 }288292 289-// Import applies only the common event gate used by host-side pulls. It does290-// not apply client admission rules, kind allowlists, retention, PoW, or create291-// delivery fan-out; the imported event's origin must be recorded by storage.293+// Import validates an event received from another host using protocol shape,294+// safety, ban, agent and room existence checks. The caller records its origin295+// in storage after this common gate succeeds.292296 func (g *Gate) Import(ctx context.Context, e event.Event, now int64) error {293297 p := g.cfg.Policy()294298 if err := event.Validate(e); err != nil {
@@ -466,6 +470,8 @@ func (g *Gate) kindRules(ctx context.Context, p policy.Policy, kind int) (bool,
466470 return allowedAny, blocked, nil467471 }468472 473+// RetentionDays returns the configured retention period for kind, using the474+// wildcard rule when no exact rule exists.469475 func (g *Gate) RetentionDays(ctx context.Context, kind int) int {470476 if g.cfg.Community == nil {471477 wildcard := 0
@@ -606,6 +612,8 @@ func (g *Gate) guestPass(ctx context.Context, p policy.Policy, e event.Event, a
606612 return false607613 }608614 615+// Read validates a subscription request and returns an AUTH hint when a616+// subscriber may receive private or members-only results after authenticating.609617 func (g *Gate) Read(ctx context.Context, filters []event.Filter, s relay.Session) (bool, error) {610618 p := g.cfg.Policy()611619 if p.Features.Signer && len(filters) > 0 {
@@ -662,6 +670,8 @@ func (g *Gate) Read(ctx context.Context, filters []event.Filter, s relay.Session
662670 return authHint, nil663671 }664672 673+// CanSee reports whether session s may receive e. It applies hidden-event,674+// ban, repository privacy, policy, room and recipient checks.665675 func (g *Gate) CanSee(ctx context.Context, e event.Event, s relay.Session, f *event.Filter) bool {666676 p := g.cfg.Policy()667677 if g.cfg.Store != nil {
M internal/gitrelay/backup_import.go +4 -0
A internal/gitrelay/doc.go +24 -0
M internal/gitrelay/gitrelay.go +7 -7
@@ -1,6 +1,3 @@
1-// Package gitrelay provides the self-hosted GRASP smart-HTTP boundary. Git2-// own receive-pack/upload-pack remain the object and pack implementation;3-// signed Nostr repository events remain the authority for refs.41 package gitrelay52 63 import (
@@ -17,6 +14,7 @@ import (
1714 "github.com/FelineStateMachine/tinyrelay/internal/storage"1815 )1916 17+// Repository is the resolved authority and Git state for one hosted project.2018 type Repository struct {2119 Owner string2220 Identifier string
@@ -48,6 +46,7 @@ type MaintainerSource interface {
4846 IsMaintainer(ctx context.Context, r Repository, pubkey string) bool4947 }5048 49+// Config supplies the event store, Git root, authorization and transport hooks.5150 type Config struct {5251 Store *storage.Store5352 Root string
@@ -63,15 +62,15 @@ type Config struct {
6362 // advertised clone URLs. It is optional for an embedded relay.6463 ServiceURL string6564 // PublicURL is the canonical HTTPS URL used in repository advertisements;66- // ServiceURL remains accepted as a compatibility alias.65+ // ServiceURL is used when PublicURL is empty.6766 PublicURL string6867 // EnableGRASP06 enables the alternative PR repository surface. It is kept6968 // opt-in because publishing a capability creates an interoperability7069 // promise beyond the base GRASP-01 HTTP endpoint.7170 EnableGRASP06 bool72- // AllowMissingObjects is retained for configuration compatibility. Missing73- // objects always enter the durable pending path; this flag no longer makes74- // incomplete state visible.71+ // AllowMissingObjects is ignored.72+ // Missing objects enter the durable pending path and stay hidden until73+ // receive-pack or repair supplies every advertised tip.7574 AllowMissingObjects bool7675 // AllowPrivateRelays permits operator-controlled private DNS targets for7776 // self-hosted networks. Public deployments should leave it disabled.
@@ -92,6 +91,7 @@ type Config struct {
9291 OnPromote func(context.Context, string, Repository) error9392 }9493 94+// GitRelay coordinates signed repository events with native bare repositories.9595 type GitRelay struct {9696 store *storage.Store9797 root string
A internal/mcp/doc.go +11 -0
M internal/mcp/mcp.go +1 -7
M internal/mcp/server.go +0 -2
M internal/policy/access.go +4 -0
A internal/policy/doc.go +4 -0
M internal/policy/policy.go +16 -6
@@ -1,4 +1,3 @@
1-// Package policy contains the durable, host-independent policy for a relay.21 package policy32 43 import (
@@ -10,11 +9,13 @@ import (
109 "strings"1110 )1211 12+// Succession describes the account handoff after a period of inactivity.1313 type Succession struct {1414 Heir string `json:"heir"`1515 AfterDays int `json:"afterDays"`1616 }1717 18+// Notify selects tenant notification categories.1819 type Notify struct {1920 Reports bool `json:"reports"`2021 Jobs bool `json:"jobs"`
@@ -22,16 +23,19 @@ type Notify struct {
2223 Digest bool `json:"digest"`2324 }2425 26+// MemberInvites controls invitation depth and quota.2527 type MemberInvites struct {2628 Depth int `json:"depth"`2729 Quota int `json:"quota"`2830 }2931 32+// Sites controls hosted site publication and mirroring.3033 type Sites struct {3134 Enabled bool `json:"enabled"`3235 Mirror bool `json:"mirror"`3336 }3437 38+// Features selects relay protocols and tenant capabilities.3539 type Features struct {3640 Search string `json:"search"`3741 Sync bool `json:"sync"`
@@ -57,10 +61,12 @@ type Features struct {
5761 Jobs bool `json:"jobs"`5862 }5963 64+// Delivery controls event delivery services.6065 type Delivery struct {6166 Enabled bool `json:"enabled"`6267 }6368 69+// Inbox controls targeted inbox admission.6470 type Inbox struct {6571 Targeted bool `json:"targeted"`6672 }
@@ -71,6 +77,7 @@ type FileLimits struct {
7177 UserStorageBytes int64 `json:"userStorageBytes"`7278 }7379 80+// CustomHost records a tenant-managed host and its provisioning state.7481 type CustomHost struct {7582 Host string `json:"host"`7683 ID string `json:"id"`
@@ -80,13 +87,15 @@ type CustomHost struct {
8087 SSLStatus string `json:"sslStatus"`8188 }8289 90+// RetentionRule assigns a retention period to one kind; negative kinds are91+// used as the wildcard rule by the gate.8392 type RetentionRule struct {8493 Kind int `json:"kind"`8594 Days int `json:"days"`8695 }8796 88-// Policy is the tenant policy. Host capacity and scheduling controls are89-// intentionally outside this type; they are not tenant entitlements.97+// Policy is the durable policy for one tenant. It combines identity and98+// presentation settings, access rules, feature switches and storage limits.9099 type Policy struct {91100 Owner string `json:"owner"`92101 CustomHosts []CustomHost `json:"customHosts"`
@@ -142,9 +151,8 @@ const DefaultRooms = 64
142151 // name one.143152 const DefaultCallbacks = 4144153 145-// UnmarshalJSON keeps the room and callback allowances and the long task146-// switch at their defaults when a stored policy predates the fields, so147-// existing tenants do not lose room creation, callbacks or job traffic.154+// UnmarshalJSON applies the current defaults for room, callback and job155+// settings before decoding a stored policy.148156 func (p *Policy) UnmarshalJSON(data []byte) error {149157 type plain Policy150158 decoded := plain{Rooms: DefaultRooms, Callbacks: DefaultCallbacks}
@@ -162,6 +170,8 @@ func Defaults(owner string) Policy {
162170 Notify: Notify{}, Views: map[string]string{}, Tags: []string{}, LanguageTags: []string{}, RelayCountries: []string{}, OpenKinds: []int{}, BlockedWords: []string{}, PushCallbacks: []string{}, Delivery: Delivery{}}163171 }164172 173+// Validate checks policy enum values, URL fields, feature dependencies and174+// numeric bounds.165175 func Validate(p Policy) error {166176 if p.FileLimits.MaxFileBytes < 0 || p.FileLimits.UserStorageBytes < 0 {167177 return errors.New("fileLimits: byte allowances cannot be negative")
M internal/policy/privacy.go +3 -5
A internal/records/doc.go +15 -0
M internal/records/records.go +18 -16
@@ -1,6 +1,3 @@
1-// Package records owns the durable records that a relay signs about itself.2-// It deliberately keeps the relay secret inside the tenant settings table and3-// exposes only public keys and generated events to callers.41 package records52 63 import (
@@ -18,21 +15,26 @@ import (
1815 )1916 2017 type Config struct {21- Store *storage.Store22- Policy func() policy.Policy23- SetPolicy func(policy.Policy) error24- RelayURL string25- GroupID string26- OnGenerated func(context.Context, event.Event) error18+ // Store holds the tenant database and records settings.19+ Store *storage.Store20+ // Policy returns the current durable tenant policy when records evaluates21+ // access or builds a projection.22+ Policy func() policy.Policy23+ // SetPolicy persists a policy change requested by records management.24+ SetPolicy func(policy.Policy) error25+ RelayURL string26+ GroupID string27+ // OnGenerated receives a signed event after its records clock transaction28+ // commits. The callback commonly persists, publishes or queues the event.29+ OnGenerated func(context.Context, event.Event) error30+ // DeliverNotification delivers a sealed notification event to its target.2731 DeliverNotification func(context.Context, event.Event, string) error2832 // PushNotification receives a plaintext summary of each notification for2933 // device delivery. The gift wrap itself is opaque to the relay once sealed.3034 PushNotification func(context.Context, string, string, string, string) error3135 OnTransfer func(context.Context, string, string) error32- // Community supplies membership and moderation projections. Keeping these33- // reads behind callbacks prevents records from depending on community's34- // table layout. A nil reader is reserved for isolated records use; hosts35- // serving community projections must provide it.36+ // Community supplies membership and moderation projections through the37+ // CommunityReader boundary.3638 Community CommunityReader3739 }3840
@@ -40,9 +42,9 @@ type Config struct {
4042 // Implementations own the community schema and may use a transaction-backed4143 // snapshot when the host is publishing a projection.4244 type CommunityReader interface {43- Members(context.Context) ([]communityread.Member, error)44- ModerationCounts(context.Context, int64) (communityread.ModerationCounts, error)45- MemberStatus(context.Context, string) (member bool, memberCount int, err error)45+ Members(ctx context.Context) ([]communityread.Member, error)46+ ModerationCounts(ctx context.Context, since int64) (communityread.ModerationCounts, error)47+ MemberStatus(ctx context.Context, pubkey string) (member bool, memberCount int, err error)4648 }4749 4850 type Member = communityread.Member
A internal/relay/doc.go +10 -0
M internal/relay/relay.go +45 -24
@@ -1,6 +1,3 @@
1-// Package relay owns the websocket protocol boundary. It deliberately keeps2-// storage and policy behind Backend so the wire protocol cannot accidentally3-// become the tenant's data model.41 package relay52 63 import (
@@ -23,56 +20,80 @@ import (
2320 )2421 2522 type Session struct {26- PubKeys []string23+ // PubKeys contains public keys established by the connection's AUTH flow.24+ PubKeys []string25+ // RelayURL is the relay identity used when authenticating and publishing.2726 RelayURL string27+ // RemoteIP is the peer address observed by the HTTP server.2828 RemoteIP string2929 }3030 31-// Backend publishes while the Router's write gate is held. Publish must make32-// the event and any delivery intent durable before returning nil.31+// Backend publishes while the Router's write gate is held. Publish performs32+// the backend's admission, persistence, duplicate handling or control action33+// before returning. A nil error allows Router to fan out the event unless the34+// returned reason identifies a duplicate. Persistence and delivery planning35+// remain backend responsibilities for event kinds that require them.3336 type Backend interface {34- Publish(context.Context, event.Event, Session) (string, error)35- Query(context.Context, []event.Filter, Session) ([]event.Event, error)36- Count(context.Context, []event.Filter, Session) (any, error)37+ Publish(ctx context.Context, event event.Event, session Session) (reason string, err error)38+ Query(ctx context.Context, filters []event.Filter, session Session) ([]event.Event, error)39+ Count(ctx context.Context, filters []event.Filter, session Session) (any, error)3740 }3841 3942 // Reader-side privacy can be stricter than the query filter. It is checked for4043 // both historical and live delivery, including encrypted signer traffic.4144 type Reader interface {42- CanRead(event.Event, Session) bool45+ CanRead(event event.Event, session Session) bool4346 }4447 48+// FilterReader applies reader privacy with the subscription filter available49+// to the policy decision.4550 type FilterReader interface {46- CanReadFilter(event.Event, Session, *event.Filter) bool51+ CanReadFilter(event event.Event, session Session, filter *event.Filter) bool4752 }4853 54+// QueryHints can return query results together with completion hints. Router55+// places these values in the EOSE response; the relay backend uses values such56+// as "finish", "more" and "auth".4957 type QueryHints interface {50- QueryHints(context.Context, []event.Filter, Session) ([]event.Event, []string, error)58+ QueryHints(ctx context.Context, filters []event.Filter, session Session) ([]event.Event, []string, error)5159 }5260 53-// Policy changes can invalidate subscriptions. The Router removes them from54-// clients immediately; the optional callback lets storage invalidate caches.55-type PolicyChanges interface{ CloseSubscriptions(Session) }61+// PolicyChanges can invalidate subscriptions in storage after an access62+// policy revision. Router removes client state before invoking the callback.63+type PolicyChanges interface {64+ CloseSubscriptions(session Session)65+}5666 5767 // SyncBackend supplies the authorized event snapshot for a NIP-77 session.5868 // The Router holds its read gate while calling Sync, so a concurrent Publish5969 // cannot create an opening between snapshot selection and NEG-OPEN.6070 type SyncBackend interface {61- Sync(context.Context, event.Filter, Session) ([]syncprotocol.Item, error)71+ Sync(ctx context.Context, filter event.Filter, session Session) ([]syncprotocol.Item, error)6272 }6373 6474 type Config struct {65- RelayURL string75+ // RelayURL is the default relay identity included in sessions.76+ RelayURL string77+ // RequestRelayURL optionally derives the relay identity from each request.6678 RequestRelayURL func(*http.Request) string67- OnAuthenticate func(context.Context, Session) error79+ // OnAuthenticate checks the authenticated session candidate and may persist80+ // authentication bookkeeping. The Session argument is a value snapshot;81+ // Router records the newly authenticated public key after this callback.82+ OnAuthenticate func(context.Context, Session) error83+ // MaxMessageBytes limits websocket text messages. Zero leaves the limit84+ // unlimited; a negative value selects the 512 KiB default.6885 MaxMessageBytes int6486+ // MaxPendingBytes bounds queued output per connection.6987 MaxPendingBytes int70- ReadLimit time.Duration71- WriteLimit time.Duration72- PingInterval time.Duration73- OriginPatterns []string74- OnConnection func(int)75- OnSubscription func(int)88+ // ReadLimit, WriteLimit and PingInterval control websocket liveness.89+ ReadLimit time.Duration90+ WriteLimit time.Duration91+ PingInterval time.Duration92+ // OriginPatterns is passed to the websocket origin check.93+ OriginPatterns []string94+ // OnConnection and OnSubscription receive +1 and -1 lifecycle deltas.95+ OnConnection func(int)96+ OnSubscription func(int)7697 }7798 7899 func (c Config) withDefaults() Config {
M internal/replication/backup.go +14 -4
@@ -18,8 +18,10 @@ import (
1818 "github.com/FelineStateMachine/tinyrelay/internal/storage"1919 )2020 21+// BackupFormat identifies the JSON archive format produced by this package.2122 const BackupFormat = "tinyrelay/backup/1"2223 24+// BackupArchive is a sealed event and application-state export.2325 type BackupArchive struct {2426 Format string `json:"format"`2527 Events []event.Event `json:"events"`
@@ -28,6 +30,7 @@ type BackupArchive struct {
2830 Created int64 `json:"created_at"`2931 }3032 33+// BackupState contains application-owned state and exported objects.3134 type BackupState struct {3235 Config json.RawMessage `json:"config,omitempty"`3336 Community json.RawMessage `json:"community,omitempty"`
@@ -39,12 +42,14 @@ type BackupState struct {
3942 Database []BackupTable `json:"database,omitempty"`4043 }4144 45+// BackupTable is a named table snapshot represented as JSON rows.4246 type BackupTable struct {4347 Name string `json:"name"`4448 Columns []string `json:"columns"`4549 Rows [][]json.RawMessage `json:"rows"`4650 }4751 52+// BackupObject is an exported named file or blob with an integrity digest.4853 type BackupObject struct {4954 Name string `json:"name"`5055 SHA256 string `json:"sha256"`
@@ -54,18 +59,23 @@ type BackupObject struct {
5459 Uploaded int64 `json:"uploaded,omitempty"`5560 }5661 62+// BackupProvider snapshots and restores application state associated with a63+// relay database. The provider owns its external resources.5764 type BackupProvider interface {58- Snapshot(context.Context) (BackupState, error)59- Restore(context.Context, BackupState) error65+ Snapshot(ctx context.Context) (BackupState, error)66+ Restore(ctx context.Context, state BackupState) error6067 }6168 69+// FullBackupProvider can produce a complete archive using its own snapshot70+// coordination.6271 type FullBackupProvider interface {6372 BackupProvider64- SnapshotArchive(context.Context, int64) (BackupArchive, error)73+ SnapshotArchive(ctx context.Context, created int64) (BackupArchive, error)6574 }6675 76+// RestoreFinalizer runs application cleanup after a successful restore.6777 type RestoreFinalizer interface {68- FinalizeRestore(context.Context) error78+ FinalizeRestore(ctx context.Context) error6979 }7080 7181 func CreateBackup(ctx context.Context, store *storage.Store, now int64) (BackupArchive, error) {
A internal/replication/doc.go +19 -0
M internal/replication/jobs.go +5 -0
M internal/replication/replication.go +14 -7
@@ -1,7 +1,3 @@
1-// Package replication contains durable self-hosted relay fanout and job2-// planning. Network I/O is deliberately behind small interfaces so a relay3-// can use local test transports, a normal websocket client, or an operator's4-// proxy without changing queue semantics.51 package replication62 73 import (
@@ -16,6 +12,8 @@ import (
1612 "github.com/FelineStateMachine/tinyrelay/internal/storage"1713 )1814 15+// Origin identifies how an event entered the relay and controls whether it is16+// eligible for outbound fanout.1917 type Origin string2018 2119 const (
@@ -26,8 +24,8 @@ const (
2624 OriginBackupRestore Origin = "backup_restore"2725 )2826 29-// Policy contains only routing decisions. It has no lease, fuel, or product30-// quota fields; the host controls process capacity independently.27+// Policy supplies current fanout decisions and relay identity. The host28+// controls process capacity independently.3129 type Policy struct {3230 Enabled bool3331 ReadMembersOnly bool
@@ -35,26 +33,31 @@ type Policy struct {
3533 SelfRelayURL string3634 }3735 36+// RelayDirectory returns write relays for an author and read relays for a37+// recipient. Returned URLs are filtered by PrepareIntents.3838 type RelayDirectory interface {3939 WriteRelays(pubkey string) []string4040 ReadRelays(pubkey string) []string4141 }4242 43-// RelayListDiscovery preserves the source order of all NIP-65 relay tags for43+// RelayListDiscovery returns the source-ordered NIP-65 relay list used by4444 // owner backfill. RelayDirectory's directional methods remain sufficient for4545 // delivery planning.4646 type RelayListDiscovery interface {4747 RelayList(pubkey string) []string4848 }4949 50+// Discovery resolves write relays for an author.5051 type Discovery interface {5152 DiscoverRelays(ctx context.Context, pubkey string) ([]string, error)5253 }5354 55+// ReadDiscovery resolves read relays for a recipient.5456 type ReadDiscovery interface {5557 DiscoverReadRelays(ctx context.Context, pubkey string) ([]string, error)5658 }5759 60+// IntentPayload records the event origin in a delivery intent.5861 type IntentPayload struct {5962 Origin Origin `json:"origin"`6063 }
@@ -173,10 +176,14 @@ func canonicalRelayURL(raw string) string {
173176 return scheme + "://" + host + path + "?" + u.RawQuery174177 }175178 179+// DeliveryTransport sends one event to a relay target. The implementation180+// owns connection reuse, request deadlines and transport shutdown.176181 type DeliveryTransport interface {177182 Send(ctx context.Context, target string, e event.Event) (DeliveryResult, error)178183 }179184 185+// DeliveryResult reports the relay's acceptance response. Duplicate186+// acknowledgments are treated as successful delivery by the handlers.180187 type DeliveryResult struct {181188 Accepted bool182189 Message string
M internal/replication/service.go +8 -0
M internal/replication/sync.go +5 -0
M internal/replication/websocket.go +16 -5
@@ -18,20 +18,28 @@ import (
1818 "github.com/coder/websocket"1919 )2020 21+// Socket is the message-oriented connection used by NostrTransport. The22+// caller that receives a Socket owns it and closes it when the exchange ends.2123 type Socket interface {22- Read(context.Context) ([]byte, error)23- Write(context.Context, []byte) error24- Close(error) error24+ Read(ctx context.Context) ([]byte, error)25+ Write(ctx context.Context, message []byte) error26+ Close(cause error) error2527 }2628 29+// Dialer opens a Socket for one relay target and returns its HTTP handshake.2730 type Dialer interface {28- Dial(context.Context, string) (Socket, *http.Response, error)31+ Dial(ctx context.Context, target string) (Socket, *http.Response, error)2932 }3033 34+// IPResolver resolves relay hostnames for address-policy checks and dialing.3135 type IPResolver interface {32- LookupIPAddr(context.Context, string) ([]net.IPAddr, error)36+ LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error)3337 }3438 39+// WebsocketDialer validates websocket targets, blocks private addresses unless40+// AllowPrivate is set and optionally answers NIP-42 authentication challenges.41+// HTTPClient and Resolver are caller-supplied dependencies and remain owned by42+// the caller.3543 type WebsocketDialer struct {3644 AllowPrivate bool3745 MaxMessageBytes int64
@@ -250,6 +258,9 @@ func (s coderSocket) Close(err error) error {
250258 return s.conn.Close(code, "replication complete")251259 }252260 261+// NostrTransport implements PullTransport and PushTransport over Nostr262+// websocket protocols. Dialer defaults to WebsocketDialer when unset, and263+// Timeout defaults to 20 seconds.253264 type NostrTransport struct {254265 Dialer Dialer255266 Timeout time.Duration
A internal/sites/doc.go +21 -0
M internal/sites/service.go +21 -6
@@ -23,19 +23,34 @@ import (
2323 "github.com/FelineStateMachine/tinyrelay/internal/storage"2424 )2525 26+// Blob is a fetched site file and its declared content metadata.2627 type Blob struct {2728 Body io.ReadCloser2829 Type string2930 Size int643031 }3132 32-type BlobGet func(context.Context, string) (Blob, error)33-type BlobPut func(context.Context, string, string, io.Reader) error34-type Fetch func(context.Context, *http.Request) (*http.Response, error)35-type ResolveIP func(context.Context, string) ([]net.IP, error)36-type ResolveHost func(context.Context, string) (string, error)37-type ReadAccess func(*http.Request) bool33+// BlobGet opens a local blob by its SHA-256 hash. The caller closes Blob.Body.34+type BlobGet func(ctx context.Context, blobSHA string) (Blob, error)3835 36+// BlobPut stores bytes under blobSHA with the supplied content type. The37+// callback consumes the reader before returning.38+type BlobPut func(ctx context.Context, blobSHA string, contentType string, body io.Reader) error39+40+// Fetch performs an outbound site file request. The caller closes the41+// returned response body.42+type Fetch func(ctx context.Context, request *http.Request) (*http.Response, error)43+44+// ResolveIP resolves a host before an outbound site request is allowed.45+type ResolveIP func(ctx context.Context, host string) ([]net.IP, error)46+47+// ResolveHost resolves a request host to a site label.48+type ResolveHost func(ctx context.Context, host string) (label string, err error)49+50+// ReadAccess authorizes a request for site content.51+type ReadAccess func(request *http.Request) bool52+53+// Config supplies storage, blob, network and request policy dependencies.3954 type Config struct {4055 Store *storage.Store4156 GetBlob BlobGet
M internal/sites/sites.go +8 -0
@@ -28,6 +28,7 @@ var (
2828 pathPattern = regexp.MustCompile(`^/(?:[^/]+/)*[^/.]+\.[^/.]+$`)2929 )3030 31+// Site identifies a manifest family and its addressable key.3132 type Site struct {3233 Kind int3334 PubKey string
@@ -35,6 +36,7 @@ type Site struct {
3536 ID string3637 }3738 39+// Base36 encodes a lowercase 32-byte hexadecimal key as a fixed-width label.3840 func Base36(value string) (string, error) {3941 if !hexPattern.MatchString(value) {4042 return "", errors.New("expected lowercase 32-byte hex")
@@ -47,6 +49,7 @@ func Base36(value string) (string, error) {
4749 return strings.Repeat("0", 50-len(value36)) + value36, nil4850 }4951 52+// Unbase36 decodes a fixed-width site label into lowercase hexadecimal.5053 func Unbase36(value string) (string, bool) {5154 if len(value) != 50 || !regexp.MustCompile(`^[0-9a-z]+$`).MatchString(value) {5255 return "", false
@@ -58,6 +61,7 @@ func Unbase36(value string) (string, bool) {
5861 return fmt.Sprintf("%064x", n), true5962 }6063 64+// ParseSite parses an npub, named-site or snapshot label.6165 func ParseSite(label string) (Site, bool) {6266 if strings.HasPrefix(label, "npub1") {6367 key, ok := decodeNpub(label)
@@ -81,6 +85,7 @@ func ParseSite(label string) (Site, bool) {
8185 return Site{Kind: KindNamedSite, PubKey: key, D: label[50:]}, true8286 }8387 88+// SiteLabel returns the host label represented by a manifest event.8489 func SiteLabel(e event.Event) string {8590 switch e.Kind {8691 case KindSite:
@@ -106,6 +111,7 @@ func SiteLabel(e event.Event) string {
106111 }107112 }108113 114+// SitePaths returns the path tags in manifest order.109115 func SitePaths(e event.Event) [][]string {110116 paths := make([][]string, 0)111117 for _, tag := range e.Tags {
@@ -116,6 +122,7 @@ func SitePaths(e event.Event) [][]string {
116122 return paths117123 }118124 125+// Aggregate hashes the canonical path and blob mappings in a manifest.119126 func Aggregate(paths [][]string) string {120127 lines := make([]string, 0, len(paths))121128 for _, tag := range paths {
@@ -128,6 +135,7 @@ func Aggregate(paths [][]string) string {
128135 return hex.EncodeToString(sum[:])129136 }130137 138+// ValidateManifest checks a site manifest's paths, aggregate and lineage.131139 func ValidateManifest(e event.Event) error {132140 if e.Kind != KindSite && e.Kind != KindNamedSite && e.Kind != KindSiteSnapshot {133141 return nil
A internal/storage/doc.go +22 -0
M internal/storage/history.go +11 -0
M internal/storage/maintenance.go +10 -0
@@ -10,6 +10,7 @@ import (
1010 "github.com/FelineStateMachine/tinyrelay/internal/event"1111 )1212 13+// Stats reports event counts, database size and the current WAL size.1314 type Stats struct {1415 Events int64 `json:"events"`1516 Bytes int64 `json:"bytes"`
@@ -40,6 +41,8 @@ func (s *Store) Stats(ctx context.Context) (Stats, error) {
4041 return result, nil4142 }4243 44+// Vanish records a deletion horizon and removes the author's events and45+// related history through until, inclusive.4346 func (s *Store) Vanish(ctx context.Context, pubkey string, until int64) error {4447 return s.WithTx(ctx, func(tx *sql.Tx) error {4548 statements := []struct {
@@ -61,6 +64,8 @@ func (s *Store) Vanish(ctx context.Context, pubkey string, until int64) error {
6164 })6265 }6366 67+// SweepExpired removes expired events and history and returns the next event68+// expiration timestamp, or zero when none remain.6469 func (s *Store) SweepExpired(ctx context.Context, now int64) (int64, error) {6570 var next int646671 err := s.WithTx(ctx, func(tx *sql.Tx) error {
@@ -74,6 +79,7 @@ func (s *Store) SweepExpired(ctx context.Context, now int64) (int64, error) {
7479 return next, err7580 }7681 82+// DeleteEvent removes one event by ID and reports whether a row was removed.7783 func (s *Store) DeleteEvent(ctx context.Context, id string) (bool, error) {7884 result, err := s.db.ExecContext(ctx, "DELETE FROM events WHERE id=?", id)7985 if err != nil {
@@ -83,6 +89,7 @@ func (s *Store) DeleteEvent(ctx context.Context, id string) (bool, error) {
8389 return n > 0, err8490 }8591 92+// EraseAuthor removes an author's events and retained list and wiki history.8693 func (s *Store) EraseAuthor(ctx context.Context, pubkey string) (int64, error) {8794 var n int648895 err := s.WithTx(ctx, func(tx *sql.Tx) error {
@@ -103,6 +110,7 @@ func (s *Store) EraseAuthor(ctx context.Context, pubkey string) (int64, error) {
103110 return n, err104111 }105112 113+// GetSetting decodes a JSON setting into dest.106114 func (s *Store) GetSetting(ctx context.Context, key string, dest any) error {107115 var raw string108116 if err := s.db.QueryRowContext(ctx, "SELECT value FROM settings WHERE key=?", key).Scan(&raw); err != nil {
@@ -114,6 +122,7 @@ func (s *Store) GetSetting(ctx context.Context, key string, dest any) error {
114122 return nil115123 }116124 125+// PutSetting encodes value as JSON and upserts it under key.117126 func (s *Store) PutSetting(ctx context.Context, key string, value any) error {118127 return s.WithTx(ctx, func(tx *sql.Tx) error { return PutSetting(ctx, tx, key, value) })119128 }
@@ -154,6 +163,7 @@ func (s *Store) PruneSettings(ctx context.Context, prefix string, keep map[strin
154163 return nil155164 }156165 166+// PutSetting writes one setting using a caller-owned transaction.157167 func PutSetting(ctx context.Context, tx *sql.Tx, key string, value any) error {158168 raw, err := json.Marshal(value)159169 if err != nil {
M internal/storage/query.go +9 -0
M internal/storage/retention.go +8 -0
M internal/storage/save.go +15 -0
@@ -13,6 +13,9 @@ import (
1313 "github.com/FelineStateMachine/tinyrelay/internal/event"1414 )1515 16+// Intent describes durable work to insert with an event save. The tuple of17+// Kind, EventID and Target determines its stable ID; Payload is retained for18+// the worker handling that kind.1619 type Intent struct {1720 Kind string `json:"kind"`1821 EventID string `json:"event_id"`
@@ -20,6 +23,12 @@ type Intent struct {
2023 Payload string `json:"payload"`2124 }2225 26+// SaveOptions controls event persistence. Now is the Unix time used for27+// history and intent timestamps. SearchMode is empty for the default indexed28+// kinds, "full" for all public content and "off" to skip content indexing.29+// For stored events, Intents and BeforeCommit run in the event transaction.30+// Ephemeral events return before those hooks. A BeforeCommit error rolls back31+// the transaction owned by Save; SaveTx returns it to the transaction's owner.2332 type SaveOptions struct {2433 Now int642534 SearchMode string
@@ -27,11 +36,17 @@ type SaveOptions struct {
2736 BeforeCommit func(context.Context, *sql.Tx) error2837 }2938 39+// SaveResult reports the event sequence. Ephemeral events return without a40+// database row and set Ephemeral.3041 type SaveResult struct {3142 Sequence int643243 Ephemeral bool3344 }3445 46+// Save commits an event, its indexes and the supplied transaction hooks47+// together. Callers validate the event before saving. Duplicate, replaced,48+// deleted and vanished events return a Rejection. Ephemeral events return an49+// Ephemeral result without writing rows or running the hooks.3550 func (s *Store) Save(ctx context.Context, e event.Event, opts SaveOptions) (SaveResult, error) {3651 var result SaveResult3752 err := s.WithTx(ctx, func(tx *sql.Tx) error {
M internal/storage/store.go +24 -7
@@ -1,4 +1,3 @@
1-// Package storage owns each tenant's local SQLite database and transaction boundary.21 package storage32 43 import (
@@ -15,6 +14,7 @@ import (
1514 _ "modernc.org/sqlite"1615 )1716 17+// Rejection is a stable Save error that callers can match with errors.Is.1818 type Rejection string1919 2020 func (e Rejection) Error() string { return string(e) }
@@ -26,10 +26,14 @@ const (
2626 ErrVanished Rejection = "blocked: this pubkey asked for its events to be deleted"2727 )2828 29+// Observer receives operation name, outcome and duration for instrumented30+// Store operations.2931 type Observer interface {30- ObserveStorage(string, string, time.Duration)32+ ObserveStorage(operation, outcome string, duration time.Duration)3133 }3234 35+// Store is a tenant's SQLite database. A Store must be closed when its owner36+// is done with it.3337 type Store struct {3438 db *sql.DB3539 path string
@@ -37,6 +41,9 @@ type Store struct {
3741 observer Observer3842 }3943 44+// Open creates or opens path, initializes the tenant schema and returns a45+// Store. The parent directory is created with mode 0700 and the database file46+// with mode 0600.4047 func Open(ctx context.Context, path string) (*Store, error) {4148 if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {4249 return nil, fmt.Errorf("create database directory: %w", err)
@@ -58,8 +65,8 @@ func Open(ctx context.Context, path string) (*Store, error) {
5865 if err != nil {5966 return nil, fmt.Errorf("open database: %w", err)6067 }61- // One connection is the initial serialization boundary. Reader pools can be62- // introduced after measurements without changing transaction semantics.68+ // One connection serializes tenant database operations. Transaction hooks69+ // use their supplied transaction while it holds this connection.6370 db.SetMaxOpenConns(1)6471 db.SetMaxIdleConns(1)6572 s := &Store{db: db, path: path}
@@ -69,9 +76,18 @@ func Open(ctx context.Context, path string) (*Store, error) {
6976 return s, nil7077 }7178 72-func (s *Store) DB() *sql.DB { return s.db }79+// DB returns the database used by tenant services for their schemas, queries80+// and transactions. Event persistence uses Save or SaveTx to apply storage's81+// replacement, deletion and indexing rules.82+func (s *Store) DB() *sql.DB { return s.db }83+84+// Path returns the database file path supplied to Open.7385 func (s *Store) Path() string { return s.path }86+87+// Close releases the database connection.7488 func (s *Store) Close() error { return s.db.Close() }89+90+// SetObserver replaces the observer used for operation timing and outcomes.7591 func (s *Store) SetObserver(o Observer) {7692 s.observerMu.Lock()7793 defer s.observerMu.Unlock()
@@ -91,8 +107,9 @@ func (s *Store) observe(operation string, started time.Time, err error) {
91107 s.observer.ObserveStorage(operation, outcome, time.Since(started))92108 }93109 94-// WithTx must not call another Store method: the callback owns the connection.95-// A successful return guarantees all domain changes and work intents committed.110+// WithTx opens a transaction, calls fn and commits after fn succeeds. It rolls111+// back on failure. The callback uses the supplied transaction for database112+// work because it holds the store's sole connection until completion.96113 func (s *Store) WithTx(ctx context.Context, fn func(*sql.Tx) error) (err error) {97114 started := time.Now()98115 defer func() { s.observe("commit", started, err) }()
A internal/syncprotocol/doc.go +4 -0
M internal/syncprotocol/hll.go +6 -0
M internal/syncprotocol/negentropy.go +11 -0
@@ -8,11 +8,14 @@ import (
88 "fiatjaf.com/nostr/nip77/negentropy/storage/vector"99 )1010 11+// Item identifies one event in a reconciliation index.1112 type Item struct {1213 ID string1314 Timestamp int641415 }1516 17+// Result contains one negentropy response and the IDs discovered by the18+// initiator when reconciliation completes.1619 type Result struct {1720 Response string1821 Have []string
@@ -20,11 +23,16 @@ type Result struct {
2023 Done bool2124 }2225 26+// Session holds one stateful negentropy exchange. A session is used by one27+// initiator or responder for the lifetime of that exchange.2328 type Session struct {2429 neg *negentropy.Negentropy2530 initiator bool2631 }2732 33+// NewSession builds a sealed negentropy index from the relay's event IDs and34+// timestamps. The initiator flag selects the side that starts the exchange35+// and receives Have and Need IDs.2836 func NewSession(items []Item, initiator bool) (*Session, error) {2937 vec := vector.New()3038 for _, item := range items {
@@ -38,6 +46,7 @@ func NewSession(items []Item, initiator bool) (*Session, error) {
3846 return &Session{neg: negentropy.New(vec, 60_000, initiator, initiator), initiator: initiator}, nil3947 }4048 49+// Start begins an initiator session and returns its protocol message.4150 func (s *Session) Start() (string, error) {4251 if !s.initiator {4352 return "", fmt.Errorf("only the initiator can start a negentropy session")
@@ -45,6 +54,8 @@ func (s *Session) Start() (string, error) {
4554 return s.neg.Start(), nil4655 }4756 57+// Reconcile consumes one peer message and returns the next response. An58+// initiator receives IDs present on each side once the exchange is complete.4859 func (s *Session) Reconcile(message string) (Result, error) {4960 if message == "" {5061 return Result{}, fmt.Errorf("empty negentropy message")
A internal/telemetry/doc.go +9 -0
M internal/telemetry/telemetry.go +0 -1
A internal/templates/doc.go +10 -0
M internal/templates/templates.go +9 -1
@@ -1,4 +1,3 @@
1-// Package templates contains the built-in declarative relay and connection catalog.21 package templates32 43 import (
@@ -8,6 +7,7 @@ import (
87 "github.com/FelineStateMachine/tinyrelay/internal/policy"98 )109 10+// Template describes a built-in relay policy and its recommended connections.1111 type Template struct {1212 Name string1313 Title string
@@ -21,6 +21,7 @@ type Template struct {
2121 Connections []string2222 }2323 24+// Connection describes one client integration displayed for a relay.2425 type Connection struct {2526 Name string2627 Title string
@@ -35,7 +36,10 @@ type Connection struct {
3536 Links []Link3637 }3738 39+// Input describes a value a connection asks the user to supply.3840 type Input struct{ Name, Label, Placeholder, Default, Pattern string }41+42+// Link describes an external URL or copyable value for a connection.3943 type Link struct{ Label, Href, Copy string }4044 4145 var templateCatalog = []Template{
@@ -78,6 +82,8 @@ func Names() []string {
7882 }7983 return out8084 }85+86+// Connections returns the built-in connection catalog in display order.8187 func Connections() []Connection {8288 out := make([]Connection, len(connectionCatalog))8389 for i, c := range connectionCatalog {
@@ -85,6 +91,8 @@ func Connections() []Connection {
8591 }8692 return out8793 }94+95+// Find returns a named template and a boolean indicating whether it exists.8896 func Find(name string) (Template, bool) {8997 for _, t := range templateCatalog {9098 if t.Name == name {
A internal/views/doc.go +9 -0
M internal/views/views.go +0 -4
A internal/webpush/doc.go +9 -0
M internal/webpush/webpush.go +0 -3
M internal/webui/blossom-encryption.js +4 -3
M internal/webui/blossom-manifests.js +5 -2
M internal/webui/blossom-upload.js +4 -4
M internal/webui/bridge.js +3 -2
A internal/webui/doc.go +13 -0
M internal/webui/file-messages.js +2 -1
M internal/webui/file-workspace.js +2 -1
M internal/webui/page.html +4 -0
M internal/webui/private-services.js +2 -2
M internal/webui/read_contracts.go +30 -11
@@ -8,20 +8,37 @@ import (
88 "github.com/FelineStateMachine/tinyrelay/internal/event"99 )1010 11-// RoomsReader is the presentation-owned read contract for room pages. The12-// legacy Backend.Query method remains supported for adapters that have not13-// adopted this interface yet.11+// RoomsReader supplies the typed reads used by room pages. actor is the12+// authenticated public key, or an empty string for an anonymous request.13+// cursor is empty for the newest page and otherwise is the value returned in14+// NextCursor. limit requests at most that many rows. The daemon adapter uses15+// 100 when the requested limit falls outside 1 through 100.16+//17+// ListRooms returns visible room summaries ordered by room ID and a cursor for18+// the next page. ReadRoom returns the room summary, visible members, newest19+// room messages, message edits and a cursor for older messages. ReadThread20+// returns the room summary, one root event, its replies, edits and a cursor for21+// older replies. Reads enforce tenant and room access and return errors for22+// missing rooms, invalid cursors or an inaccessible room. ReadThread also23+// returns an error when rootID is not a lower-case hexadecimal event ID or does24+// not identify a message in roomID.1425 type RoomsReader interface {15- ListRooms(context.Context, string, string, int) (RoomList, error)16- ReadRoom(context.Context, string, string, string, int) (RoomPage, error)17- ReadThread(context.Context, string, string, string, string, int) (RoomPage, error)26+ // ListRooms returns up to limit visible room summaries after cursor.27+ ListRooms(ctx context.Context, actor, cursor string, limit int) (RoomList, error)28+ // ReadRoom returns one visible room, its members, messages and edits after cursor.29+ ReadRoom(ctx context.Context, actor, roomID, cursor string, limit int) (RoomPage, error)30+ // ReadThread returns one room thread root, replies and edits after cursor.31+ ReadThread(ctx context.Context, actor, roomID, rootID, cursor string, limit int) (RoomPage, error)1832 }1933 2034 type RoomList struct {21- Rooms []RoomSummary `json:"rooms"`22- NextCursor string `json:"next_cursor,omitempty"`35+ // Rooms contains visible room summaries in ascending ID order.36+ Rooms []RoomSummary `json:"rooms"`37+ // NextCursor is the last room ID when another page is available.38+ NextCursor string `json:"next_cursor,omitempty"`2339 }2440 41+// RoomSummary is the room metadata shown in the rail and room header.2542 type RoomSummary struct {2643 ID string `json:"id"`2744 Name string `json:"name,omitempty"`
@@ -46,6 +63,9 @@ type RoomMember struct {
4663 4764 type RoomMessage = event.Event4865 66+// RoomPage is the typed result for a room or thread read. A room read fills67+// Members and Messages; a thread read fills Root and Replies. Edits contains68+// the newest visible edit for the returned targets.4969 type RoomPage struct {5070 Room RoomSummary `json:"room"`5171 Members []RoomMember `json:"members,omitempty"`
@@ -56,9 +76,8 @@ type RoomPage struct {
5676 NextCursor string `json:"next_cursor,omitempty"`5777 }5878 59-// rowsReader adapts the legacy method-and-JSON backend to the read shapes60-// used by page renderers. It keeps wire compatibility while making list61-// queries consistently return rows at the presentation boundary.79+// rowsReader converts method-and-JSON query results into the row slices used80+// by page renderers.6281 type rowsReader struct{ backend Backend }6382 6483 func (r rowsReader) rows(ctx context.Context, method string, params []json.RawMessage, actor string) ([]any, error) {
M internal/webui/rooms.html +3 -1
M internal/webui/rooms.js +4 -0
M internal/webui/tiny.js +4 -2
M internal/webui/webmcp.js +4 -0
M internal/webui/webui.go +11 -6
A internal/wiki/doc.go +8 -0
M internal/wiki/wiki.go +0 -3
A internal/work/doc.go +15 -0
M internal/work/work.go +14 -5
@@ -1,4 +1,3 @@
1-// Package work provides durable, fenced work intents backed by SQLite.21 package work32 43 import (
@@ -20,6 +19,7 @@ import (
2019 2120 const defaultLease = 30 * time.Second2221 22+// ErrClaimLost denotes loss of worker lease ownership.2323 var ErrClaimLost = errors.New("work: claim lost")2424 2525 type stopError struct{ err error }
@@ -37,7 +37,9 @@ func (e *stopError) Unwrap() error { return e.err }
3737 // instead of retrying it, while retaining the underlying error for operators.3838 func Stop(err error) error { return &stopError{err: err} }3939 40-// Intent is a durable unit of work. ClaimToken is only valid until ClaimUntil.40+// Intent is a durable unit of work. ClaimToken identifies the current claim;41+// ClaimUntil determines when another worker can claim an expired lease.42+// State is pending, running, completed or cancelled.4143 type Intent struct {4244 ID string4345 Kind string
@@ -322,17 +324,24 @@ func metricKind(kind string) string {
322324 return "other"323325 }324326 325-// Handler processes one claimed intent.326-type Handler func(context.Context, Intent) error327+// Handler processes one claimed intent while the worker renews its lease.328+// A nil error completes the claim; an error schedules a retry. Stop marks a329+// terminal failure. The handler observes ctx cancellation so the worker can330+// finish shutdown or release an attempt whose claim was lost. A retry can331+// repeat an external effect after an interrupted acknowledgment; handlers own332+// deduplication of those effects.333+type Handler func(ctx context.Context, intent Intent) error327334 328335 // WorkerOptions controls claim leases and optional handler timing observation.336+// Lease and Renew default to 30 seconds and one third of the lease.329337 type WorkerOptions struct {330338 Lease time.Duration331339 Renew time.Duration332340 Observe func(kind, outcome string, duration time.Duration)333341 }334342 335-// PoolOptions controls a pool of durable workers.343+// PoolOptions controls a pool of durable workers. Workers less than or equal344+// to zero use the default of four.336345 type PoolOptions struct {337346 Workers int338347 Worker WorkerOptions