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>
@@ -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@@ -0,0 +1,9 @@
1+// Command tiny runs and administers a multitenant Nostr relay.2+//3+// main creates a context cancelled by interrupt or SIGTERM and reports the4+// error returned by run. serve binds the HTTP listeners, starts daemon.App,5+// runs optional peer monitoring and joins server shutdown before closing the6+// application. Tenant subcommands open an App for catalog operations. The7+// git-token subcommand produces signed Git authorization material from an8+// operator-selected key source.9+package main@@ -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@@ -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.@@ -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())@@ -0,0 +1,6 @@
1+// Package auth verifies the signed proofs used at HTTP and WebSocket2+// boundaries. It handles NIP-98 request signatures, Blossom authorization,3+// GRASP-08 repository proofs and NIP-42 challenges. Callers use the returned4+// public key to apply relay policy and keep authorization state outside this5+// package.6+package auth@@ -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 {@@ -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@@ -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@@ -1,4 +1,3 @@
1-// Package catalog owns the durable registry of self-hosted relay tenants.21 package catalog32 43 import (@@ -20,9 +19,15 @@ import (
2019 type Status string2120 2221 const (22+ // StatusCreating identifies a tenant whose directories or services are23+ // still being initialized.2324 StatusCreating Status = "creating"24- StatusReady Status = "ready"25+ // StatusReady identifies a tenant available for normal service.26+ StatusReady Status = "ready"27+ // StatusDisabled identifies a tenant stopped by the operator and eligible28+ // for reactivation or deletion.2529 StatusDisabled Status = "disabled"30+ // StatusDeleting identifies a tenant whose deletion intent is recorded.2631 StatusDeleting Status = "deleting"2732 )2833 @@ -36,12 +41,17 @@ var (
3641 )3742 3843 type CreateOptions struct {39- Name string40- Owner string44+ // Name is the operator-visible unique tenant name.45+ Name string46+ // Owner is the tenant owner's public key.47+ Owner string48+ // Template identifies the configuration template used during setup.4149 Template string42- Source string50+ // Source records where the tenant configuration came from.51+ Source string4352 }4453 54+// Paths contains the private filesystem locations assigned to a tenant.4555 type Paths struct {4656 Root string4757 Database string@@ -0,0 +1,11 @@
1+// Package catalog owns the durable registry and filesystem paths for2+// self-hosted relay tenants.3+//4+// Open creates or opens the catalog database, configures SQLite durability,5+// prepares the tenant tables and resumes recorded deletions. Create records a6+// tenant in StatusCreating and creates its private directories; callers move7+// it to StatusReady after tenant initialization succeeds. Disabled tenants8+// can be enabled again or permanently removed with Delete. Delete records its9+// intent, stages the tenant directory, removes the staged data, then removes10+// the catalog rows, allowing Open to resume an interrupted deletion.11+package catalog@@ -175,8 +175,10 @@ func readConfigRows(ctx context.Context, db *sql.DB, query string, scan func(*sq
175175 return rows.Err()176176 }177177 178-// ApplyConfigTx replaces selected community configuration sections inside a179-// caller-owned transaction. Empty sections are meaningful replacements.178+// ApplyConfigTx replaces each selected community configuration section inside179+// the caller-owned transaction. A selected empty section clears that section;180+// an unselected section is left unchanged. The caller commits or rolls back181+// the transaction after all related configuration writes succeed.180182 func ApplyConfigTx(ctx context.Context, tx *sql.Tx, c ConfigSnapshot, members, bans, addresses, eventBans, kinds, retention bool) error {181183 now := time.Now().Unix()182184 if members {@@ -0,0 +1,16 @@
1+// Package community owns the tenant's membership, moderation, invitations,2+// rooms, agents and audit records.3+//4+// A Service stores community state in the tenant database and exposes direct5+// operations plus transaction hooks for event ingestion. Methods whose names6+// end in Tx and accept *sql.Tx use a transaction owned by the caller and leave7+// commit or rollback to that caller. Event handlers whose callback accepts8+// *sql.Tx open and close the transaction internally; the callback stores the9+// accepted event in that transaction, so event, community state and10+// projection work commit or roll back together.11+//12+// Community policy is read through the function supplied to ConfigurePolicy.13+// Records consumes the narrow read model implemented by Members,14+// MemberStatus and ModerationCounts, which keeps records independent of the15+// community tables.16+package community@@ -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 }@@ -31,7 +31,7 @@ func (s *Service) Members(ctx context.Context) ([]communityread.Member, error) {
3131 return out, rows.Err()3232 }3333 34-// MemberStatus reports heir membership and the current member count in one34+// MemberStatus reports the key's membership and the current member count in one3535 // call so succession checks use a consistent community snapshot.3636 func (s *Service) MemberStatus(ctx context.Context, pubkey string) (bool, int, error) {3737 var count int@@ -1,13 +1,16 @@
1-// Package communityread contains the small read models shared by community2-// and records. It has no storage or service dependencies.31 package communityread42 53 type Member struct {4+ // PubKey is the member's 32-byte public key in lowercase hexadecimal form.65 PubKey string7- Name string8- Role string6+ // Name is the member's optional NIP-05-style local name.7+ Name string8+ // Role is the current community role.9+ Role string910 }1011 12+// ModerationCounts contains moderation activity counted from a caller-supplied13+// Unix timestamp.1114 type ModerationCounts struct {1215 Bans, Reports, Resolved, Hidden, BlockedAddresses int1316 }@@ -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@@ -0,0 +1,10 @@
1+// Package configport imports, exports, plans and applies bind.ws relay2+// configuration files.3+//4+// Parse accepts the current relay configuration format and records which5+// sections were present. ApplyWithOptions merges policy values, replaces each6+// supplied community section, and writes policy, community data, connections7+// and jobs in one storage transaction. An omitted section is left unchanged;8+// an included empty section clears that section. OnApplied runs only after a9+// successful commit, while ValidatePolicy runs before the transaction.10+package configport@@ -604,8 +604,8 @@ func (s *callbackService) callbackOwnerCanSee(ctx context.Context, owner string,
604604 return s.gate.CanSee(ctx, e, relay.Session{PubKeys: []string{owner}, RelayURL: s.relayURL}, nil)605605 }606606 607-// Prepare preserves the original planning API for callers that do not yet608-// persist captured candidate IDs alongside the event.607+// Prepare matches current callback registrations and rechecks their access608+// before returning delivery intents. The caller persists the returned work.609609 func (s *callbackService) Prepare(ctx context.Context, e event.Event) ([]storage.Intent, error) {610610 ids, err := s.CandidateIDs(ctx, e)611611 if err != nil {@@ -614,8 +614,9 @@ func (s *callbackService) Prepare(ctx context.Context, e event.Event) ([]storage
614614 return s.PrepareFor(ctx, e, ids)615615 }616616 617-// notifyCallbacks runs after an event is stored. It preserves the legacy618-// best-effort queueing path for callers that do not share the event tx.617+// notifyCallbacks plans and queues delivery in its own transaction, logging618+// planning or storage failures. Ephemeral publication uses this path because619+// its event is carried by the delivery payload.619620 func (s *callbackService) notifyCallbacks(ctx context.Context, e event.Event) {620621 intents, err := s.Prepare(ctx, e)621622 if err != nil {@@ -13,7 +13,8 @@ import (
1313 )1414 1515 // callbackService owns callback registration, matching and durable delivery.16-// Its dependencies are explicit so callback state does not enlarge Tenant.16+// It borrows the tenant store, community and gate. Per-registration locks17+// serialize remote delivery; the daemon owns worker startup and shutdown.1718 type callbackService struct {1819 store *storage.Store1920 community *community.Service@@ -30,6 +31,9 @@ type callbackService struct {
3031 callbackClient *http.Client3132 }3233 34+// callbackServiceConfig binds storage, current policy and visibility checks.35+// PublicURL identifies the HTTP relay in deliveries; RelayURL supplies the36+// WebSocket address used by event visibility checks.3337 type callbackServiceConfig struct {3438 Store *storage.Store3539 Community *community.Service@@ -31,8 +31,9 @@ import (
3131 )3232 3333 // customViewService owns custom view definitions, transform execution and34-// artifact storage. Tenant supplies the narrow lifecycle callbacks when it35-// constructs the service.34+// artifact storage. It borrows tenant dependencies and serializes transforms35+// per view. Git is bound after repository initialization and before workers36+// start; Records supplies the signing identity for generated artifacts.3637 type customViewService struct {3738 store *storage.Store3839 community *community.Service@@ -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@@ -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@@ -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)@@ -500,6 +500,10 @@ func (t *Tenant) manageHTTP(w http.ResponseWriter, r *http.Request) {
500500 writeJSON(w, 200, map[string]any{"result": result})501501 }502502 503+// Execute dispatches a management method with positional JSON parameters.504+// actor is the authenticated public key, or empty for an anonymous caller.505+// The transport establishes actor identity; method handlers apply current506+// membership and resource permissions. Results retain each method's wire shape.503507 func (t *Tenant) Execute(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) {504508 ctx, done, admissionErr := t.beginOperation(ctx)505509 if admissionErr != nil {@@ -27,6 +27,9 @@ type lifecycleState struct {
2727 done chan struct{}2828 }2929 30+// AcquireProcessLock exclusively locks dataDir for a serving process. Lock31+// acquisition fails immediately when another process holds it. The caller32+// retains the lock until every tenant using the directory has closed.3033 func AcquireProcessLock(dataDir string) (*ProcessLock, error) {3134 if dataDir == "" {3235 return nil, errors.New("daemon: data directory is required")@@ -45,6 +48,7 @@ func AcquireProcessLock(dataDir string) (*ProcessLock, error) {
4548 return &ProcessLock{file: file}, nil4649 }4750 51+// Close releases the process lock and closes its file handle.4852 func (l *ProcessLock) Close() error {4953 if l == nil || l.file == nil {5054 return nil@@ -52,8 +56,9 @@ func (l *ProcessLock) Close() error {
5256 return errors.Join(syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN), l.file.Close())5357 }5458 55-// Start eagerly opens permanent ready tenants, so jobs resume after restart56-// without waiting for the first browser or websocket request.59+// Start acquires the process lock, opens ready tenants and starts catalog60+// reconciliation. baseURL supplies their public address. ctx bounds startup;61+// App.Close ends the running lifecycle. Calls on a started App return nil.5762 func (a *App) Start(ctx context.Context, baseURL string) error {5863 a.lifecycle.mu.Lock()5964 defer a.lifecycle.mu.Unlock()@@ -6,12 +6,17 @@ import (
66 "sync"77 )88 9+// Admission errors distinguish exclusive maintenance from tenant shutdown.10+// Callers may retry maintenance refusals after the active operation finishes.911 var (1012 ErrMaintenance = errors.New("retryable: tenant maintenance in progress")1113 ErrMaintenanceBusy = errors.New("retryable: tenant maintenance is already active")1214 ErrTenantClosing = errors.New("tenant is closing")1315 )1416 17+// maintenanceGate counts admitted operations and grants exclusive maintenance18+// after those operations drain. Context markers carry admission across nested19+// service calls. A returned completion function releases each admission once.1520 type maintenanceGate struct {1621 mu sync.Mutex1722 active int@@ -27,9 +32,14 @@ type maintenanceContext struct {
2732 maintenance bool2833 }2934 35+// beginOperation admits ordinary tenant work. The caller passes the returned36+// context to nested operations and defers the completion function on success.3037 func (t *Tenant) beginOperation(ctx context.Context) (context.Context, func(), error) {3138 return t.maintenance.beginOperation(ctx)3239 }40+41+// beginMaintenance grants exclusive tenant access after active work drains.42+// The returned context admits maintenance's nested calls until completion.3343 func (t *Tenant) beginMaintenance(ctx context.Context) (context.Context, func(), error) {3444 return t.maintenance.beginMaintenance(ctx)3545 }@@ -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() {@@ -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@@ -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 {@@ -0,0 +1,11 @@
1+// Package domains manages operator-configured host mappings for one tenant.2+//3+// Service writes mappings through catalog and validates host names and site4+// labels before doing so. Management methods require the configured owner5+// check; list access is available to every caller of Execute. Check resolves6+// the mapping and, when configured, performs DNS lookup for the host.7+//8+// WebAddressHandler serves the NIP-05A web-address discovery endpoint. It9+// validates the requested path, applies the configured authorization and read10+// checks, and asks the host for the storage-backed address filter.11+package domains@@ -1,6 +1,3 @@
1-// Package domains implements operator-managed local host mappings. A mapping2-// is explicit configuration; it never provisions DNS, certificates, or a3-// wildcard route on the operator's behalf.41 package domains52 63 import (@@ -20,11 +17,18 @@ import (
2017 type ResolveDNS func(context.Context, string) ([]net.IP, error)2118 2219 type Config struct {23- Catalog *catalog.Catalog24- TenantID string25- BaseHost string26- RelayURL string27- Owner func(string) bool20+ // Catalog stores the tenant's primary and secondary host mappings.21+ Catalog *catalog.Catalog22+ // TenantID scopes all writes and reads performed by Service.23+ TenantID string24+ // BaseHost is the relay's canonical host and may be empty for a tenant25+ // without a primary host.26+ BaseHost string27+ // RelayURL is returned in discovery responses.28+ RelayURL string29+ // Owner authorizes management calls made through Execute.30+ Owner func(string) bool31+ // ResolveDNS checks the configured host's DNS records when Check runs.2832 ResolveDNS ResolveDNS2933 }3034 @@ -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 }@@ -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 {@@ -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 {@@ -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 {@@ -19,6 +19,7 @@ import (
1919 "strings"2020 )2121 22+// BackupObject is one validated Git object and its compressed content chunks.2223 type BackupObject struct {2324 OID string `json:"oid"`2425 Type string `json:"type"`@@ -26,6 +27,7 @@ type BackupObject struct {
2627 Chunks []string `json:"chunks"`2728 }2829 30+// BackupRepository contains the objects and refs for one imported repository.2931 type BackupRepository struct {3032 Key string `json:"key"`3133 Owner string `json:"owner"`@@ -35,11 +37,13 @@ type BackupRepository struct {
3537 Refs map[string]string `json:"refs"`3638 }3739 40+// GitBackup is the complete bind.ws/git-sqlite/1 import document.3841 type GitBackup struct {3942 Format string `json:"format"`4043 Repositories []BackupRepository `json:"repositories"`4144 }4245 46+// ValidateGitBackup validates an import document without writing Git state.4347 func ValidateGitBackup(input []byte) (GitBackup, error) {4448 var backup GitBackup4549 if err := json.Unmarshal(input, &backup); err != nil {@@ -0,0 +1,24 @@
1+// Package gitrelay coordinates signed Nostr repository events with native2+// Git object storage and smart HTTP. Signed events establish repository refs,3+// metadata and maintainer authority.4+//5+// GitRelay stores bare repositories under Config.Root. Journal files under6+// .tinyrelay/journal make event-to-ref transitions recoverable, while pending7+// state files keep a signed ref announcement out of the visible repository8+// until every advertised tip object is present. New creates the root,9+// recovers journals and reloads repository metadata from the event store.10+// Synchronization runs through Service.Tick or transport callbacks invoked by11+// the caller.12+//13+// Validate checks event and maintainer admission. After the caller durably14+// stores the event, CommitAfterStore stages the corresponding Git transition.15+// CommitAfterStoreNoNotify serves callers already holding the host publication16+// fence. PromotePending completes a receive-pack repair and invokes OnPromote17+// after state becomes visible.18+//19+// ServeHTTP exposes Git smart HTTP for public and private repositories.20+// Private requests pass through AuthorizeHTTP. GRASP-08 private peer traffic21+// is limited to Config.PrivatePeers and is signed by HTTPAuth. GitSync and22+// EventSync supply synchronization transports; the daemon supplies the relay23+// client and WebSocket lifecycle.24+package gitrelay@@ -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@@ -0,0 +1,11 @@
1+// Package mcp serves the Model Context Protocol revision 2026-07-28 through2+// its stateless Streamable HTTP binding.3+//4+// Server accepts one JSON-RPC request or notification per POST. Protocol5+// version, method, tool name and selected resource name are mirrored in HTTP6+// headers and checked against the JSON body. Registry keeps tools in7+// registration order, validates their JSON Schema subset, and invokes each8+// Handler with an authenticated actor and request context. Tool failures are9+// returned as execution results; transport and validation failures use10+// JSON-RPC errors.11+package mcp@@ -1,7 +1,3 @@
1-// Package mcp implements the Model Context Protocol, revision 2026-07-28, in2-// the stateless form of its Streamable HTTP binding: one endpoint that3-// accepts POST, no sessions, no initialize handshake, and every request4-// carrying its protocol version in both the headers and the body.51 package mcp62 73 import (@@ -14,9 +10,7 @@ import (
1410 // Version is the protocol revision this package implements.1511 const Version = "2026-07-28"1612 17-// SupportedVersions lists the protocol revisions the server accepts. Older18-// revisions negotiated a session with an initialize handshake and are not19-// served.13+// SupportedVersions lists the protocol revisions the server accepts.2014 var SupportedVersions = []string{Version}2115 2216 // Header names mirrored from the JSON-RPC body so intermediaries can route@@ -62,8 +62,6 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.Handle(w,
6262 func (s *Server) Handle(w http.ResponseWriter, r *http.Request) Observation {6363 w.Header().Set("Cache-Control", "no-store")6464 if r.Method != http.MethodPost {65- // GET opened a server stream and DELETE ended a session in earlier66- // revisions. Neither exists in this one.6765 w.Header().Set("Allow", "POST")6866 http.Error(w, "method not allowed: the MCP endpoint accepts POST", http.StatusMethodNotAllowed)6967 return Observation{Outcome: OutcomeInvalid, Status: http.StatusMethodNotAllowed}@@ -13,6 +13,8 @@ type Access struct {
1313 Owner bool1414 }1515 16+// CanRead applies the read rule, kind blocks, private repository rules and17+// recipient addressing to one event.1618 func CanRead(p Policy, e event.Event, a Access) bool {1719 if containsInt(p.BlockedKinds, e.Kind) {1820 return false@@ -44,6 +46,8 @@ func IsPrivateRepository(e event.Event) bool {
4446 return false4547 }4648 49+// CanWrite applies kind blocks, allowlists and the tenant write rule to one50+// event.4751 func CanWrite(p Policy, e event.Event, a Access) bool {4852 if containsInt(p.BlockedKinds, e.Kind) {4953 return false@@ -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")@@ -12,13 +12,11 @@ import (
1212 // replaceable repository state events. It keeps the policy package independent1313 // from the storage implementation.1414 type PrivateRepositoryLookup interface {15- IsPrivateRepository(context.Context, string, string) (bool, error)15+ IsPrivateRepository(ctx context.Context, pubkey, identifier string) (bool, error)1616 }1717 18-// PrivateServiceEnabled reports whether the tenant can serve the complete19-// GRASP-08 private service contract. Valid policies already enforce these20-// implications, but keeping the predicate here gives callers one definition21-// when inspecting policy values during startup or migration.18+// PrivateServiceEnabled reports whether the tenant has the complete GRASP-0819+// private service contract enabled.2220 func (p Policy) PrivateServiceEnabled() bool {2321 return p.Features.Grasp && p.Features.Grasp08 && p.Reads == "members"2422 }@@ -0,0 +1,15 @@
1+// Package records owns durable relay identity, signed protocol records,2+// projections, views, notifications and succession state.3+//4+// New initializes the records schema and loads the relay signing key from the5+// tenant settings. Config.Community is required because membership and6+// moderation projections are part of the records service's read contract.7+//8+// Signed records that advance the durable records clock run under the service9+// signing lock and persist the clock before invoking Config.OnGenerated.10+// OnGenerated runs after that transaction commits and commonly persists or11+// publishes the returned event. Its error is returned to the caller; the clock12+// remains durable even when the callback fails.13+// GenerateEphemeral signs a response without advancing the durable clock or14+// invoking OnGenerated.15+package records@@ -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@@ -0,0 +1,10 @@
1+// Package relay is the WebSocket transport boundary for the relay.2+//3+// Router owns connection lifecycle, authentication messages, subscriptions,4+// request and count queries, event publication and NIP-77 negotiation. A5+// Backend supplies durable publication and query behavior; optional interfaces6+// add reader privacy, query hints, policy invalidation and sync snapshots.7+// Router serializes publication with subscription registration and snapshots,8+// so a live event cannot pass a historical query boundary. Close stops every9+// connection and waits for its reader and writer goroutines to finish.10+package relay@@ -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 {@@ -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) {@@ -0,0 +1,19 @@
1+// Package replication plans and executes durable relay synchronization.2+//3+// Planning is separate from transport. RelayDirectory and Discovery provide4+// routing inputs; PrepareIntents turns them into idempotent storage.Intent5+// values that can be committed with the event that caused them. Delivery,6+// PullTransport and PushTransport perform network operations and are owned by7+// the application that constructs the Service or calls the standalone sync8+// functions. Those callers retain ownership of their transports and stores.9+//10+// Service owns replication's durable work queue and job state. Handlers can be11+// composed with other work handlers and run by work.RunPool. A handler error12+// is retried by the work package with backoff; a terminal error can be marked13+// with work.Stop. Successful recurring jobs enqueue a new intent, preserving a14+// completed record for the prior run.15+//16+// WebSocket transports validate relay URLs and public address resolution before17+// dialing. Backup and artifact helpers write complete files through temporary18+// files and same-directory rename.19+package replication@@ -13,6 +13,7 @@ import (
1313 "github.com/FelineStateMachine/tinyrelay/internal/work"1414 )1515 16+// JobKind selects the durable replication operation.1617 type JobKind string1718 1819 const (@@ -24,6 +25,8 @@ const (
2425 JobBackup JobKind = "backup"2526 )2627 28+// JobSpec describes one immediate or recurring replication job. Every is an29+// interval in hours; zero runs once.2730 type JobSpec struct {2831 ID string `json:"id"`2932 Kind JobKind `json:"kind"`@@ -33,10 +36,12 @@ type JobSpec struct {
3336 DiscoverPubKey string `json:"discoverPubKey,omitempty"`3437 }3538 39+// JobRunner executes a validated job specification.3640 type JobRunner interface {3741 Run(ctx context.Context, spec JobSpec) error3842 }3943 44+// JobHandler decodes and validates durable job intents before delegating them.4045 type JobHandler struct {4146 runner JobRunner4247 }@@ -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@@ -16,6 +16,10 @@ import (
1616 "github.com/FelineStateMachine/tinyrelay/internal/work"1717 )1818 19+// Config supplies a Service's store, routing policy, transports and optional20+// application hooks. Store is required. DataDir defaults to the store's21+// directory; Workers defaults to work.RunPool's worker count when Run is used.22+// The caller retains ownership of the supplied transports and store.1923 type Config struct {2024 Store *storage.Store2125 Policy Policy@@ -36,12 +40,14 @@ type Config struct {
3640 Workers int3741 }3842 43+// Service owns replication job state and the durable queue built on Store.3944 type Service struct {4045 store *storage.Store4146 queue *work.Queue4247 config Config4348 }4449 50+// NewService initializes replication job tables and returns a service.4551 func NewService(config Config) (*Service, error) {4652 if config.Store == nil {4753 return nil, errors.New("replication: store is required")@@ -55,8 +61,10 @@ func NewService(config Config) (*Service, error) {
5561 return &Service{store: config.Store, queue: work.New(config.Store), config: config}, nil5662 }5763 64+// Store returns the service's supplied store.5865 func (s *Service) Store() *storage.Store { return s.store }5966 67+// Queue returns the service's durable replication queue.6068 func (s *Service) Queue() *work.Queue { return s.queue }6169 6270 func (s *Service) Prepare(e event.Event, origin Origin) []storage.Intent {@@ -9,14 +9,19 @@ import (
99 "github.com/FelineStateMachine/tinyrelay/internal/storage"1010 )1111 12+// PullTransport reads events from target. The transport owns network clients13+// and their lifecycle; PullOnce owns validation and storage.1214 type PullTransport interface {1315 Query(ctx context.Context, target string, filter event.Filter) ([]event.Event, error)1416 }1517 18+// PushTransport sends one event to target. PushAfter advances its cursor only19+// after Send returns, including a relay duplicate acknowledgment.1620 type PushTransport interface {1721 Send(ctx context.Context, target string, e event.Event) (DeliveryResult, error)1822 }1923 24+// SyncStats summarizes one pull or push pass.2025 type SyncStats struct {2126 Stored int2227 Duplicates int@@ -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@@ -0,0 +1,21 @@
1+// Package sites implements NIP-5A site manifests and serves their content.2+//3+// A manifest event maps URL paths to blob hashes. SiteLabel and ParseSite4+// translate the three supported labels: an owner's npub, a named site, and a5+// snapshot. ValidateManifest checks path entries, aggregate hashes and6+// lineage before a manifest is indexed.7+//8+// Service keeps the label-to-event index in the site_manifests table of the9+// supplied storage.Store. New creates that table and its deletion trigger.10+// SaveOptions supplies the storage transaction hook that updates the index11+// with an accepted event and, when mirroring is enabled by policy, queues a12+// site-mirror intent. ApplyTx and DeleteTx are the corresponding transaction13+// hooks for callers that already own the event transaction.14+//15+// Mirror reads missing files from the servers advertised by a manifest,16+// verifies each response against its declared hash and stores it through the17+// configured BlobPut callback. RunMirrorIntent resolves the event captured by18+// storage and retries that idempotent operation. Handler serves only GET and19+// HEAD content after host, policy and optional ReadAccess checks; the manifest20+// remains the authority for path selection and discovery responses.21+package sites@@ -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@@ -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@@ -0,0 +1,22 @@
1+// Package storage owns a tenant's SQLite database and the transaction boundary2+// for event data, projections and durable work intents.3+//4+// Open creates the database and schema with SQLite WAL mode, full synchronous5+// writes, foreign-key enforcement and one database connection. Each method6+// uses its context for database work.7+//8+// Save applies duplicate, deletion and replacement rules, stores searchable9+// indexes and inserts durable work intents in one transaction. Callers validate10+// events before passing them to storage.11+// SaveTx performs the same operation inside a transaction supplied by the12+// caller. The caller owns that transaction and commits it after SaveTx13+// succeeds. WithTx provides the matching begin, commit and rollback boundary;14+// its callback uses the supplied *sql.Tx for all database operations.15+//16+// Query excludes expired, hidden and pending events and applies recipient17+// checks through QueryOptions.Access. It returns events in created-at18+// descending order with ID ascending as the stable tie breaker. The tenant19+// gate applies the remaining policy and resource visibility rules. After20+// reads events by their increasing insertion sequence for cursored21+// replication; removed events leave gaps in that sequence.22+package storage@@ -8,6 +8,7 @@ import (
88 "github.com/FelineStateMachine/tinyrelay/internal/event"99 )1010 11+// HistoryRow identifies one retained version of a replaceable list.1112 type HistoryRow struct {1213 Kind int `json:"kind"`1314 D string `json:"d"`@@ -33,21 +34,31 @@ func (s *Store) ListHistory(ctx context.Context, owner string, now int64) ([]His
3334 return result, rows.Err()3435 }3536 37+// RestoreResult contains a prior list event draft and its difference from the38+// current list.3639 type RestoreResult struct {3740 Draft Draft `json:"draft"`3841 Diff Diff `json:"diff"`3942 }43+44+// Draft is the event body needed to publish a restored list version.4045 type Draft struct {4146 Kind int `json:"kind"`4247 CreatedAt int64 `json:"created_at"`4348 Tags [][]string `json:"tags"`4449 Content string `json:"content"`4550 }51+52+// Diff describes content and tag changes between the retained and current53+// versions.4654 type Diff struct {4755 AddedTags [][]string `json:"addedTags"`4856 RemovedTags [][]string `json:"removedTags"`4957 ContentChanged bool `json:"contentChanged"`5058 }59+60+// RestoreOptions identifies the retained list event and the query time used61+// for expiration checks.5162 type RestoreOptions struct {5263 Owner string5364 EventID string@@ -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 {@@ -11,10 +11,16 @@ import (
1111 "github.com/FelineStateMachine/tinyrelay/internal/event"1212 )1313 14+// Access controls which private and callback events a query may return. All15+// bypasses recipient filtering but still excludes hidden, pending and expired16+// rows.1417 type Access struct {1518 PubKeys []string1619 All bool1720 }21+22+// QueryOptions supplies query time, access principals, an optional result23+// limit and a cursor from a prior Query result.1824 type QueryOptions struct {1925 Now int642026 Access Access@@ -27,6 +33,9 @@ type EventCursor struct {
2733 CreatedAt int642834 ID string2935 }36+37+// QueryResult contains the matching events and whether the limit hid more38+// rows.3039 type QueryResult struct {3140 Events []event.Event3241 More bool@@ -7,14 +7,21 @@ import (
77 "fmt"88 )99 10+// RetentionRule removes non-identity events older than Days. A negative Kind11+// applies the catch-all rule to kinds eligible for retention.1012 type RetentionRule struct {1113 Kind int1214 Days int1315 }16+17+// MemberRetention removes eligible events and list history for one author18+// older than Days.1419 type MemberRetention struct {1520 PubKey string1621 Days int1722 }23+24+// RetentionOptions supplies the retention time, relay identity and rules.1825 type RetentionOptions struct {1926 Now int642027 Identity string@@ -86,6 +93,7 @@ func deleteCount(ctx context.Context, tx *sql.Tx, query string, args ...any) (in
8693 return result.RowsAffected()8794 }8895 96+// KindStats reports count, size and time bounds for one stored event kind.8997 type KindStats struct {9098 Kind int `json:"kind"`9199 N int64 `json:"n"`@@ -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 {@@ -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) }()@@ -25,6 +25,9 @@ func NewHLL(offset int) *HLL {
2525 return &HLL{offset: offset}2626 }2727 28+// HLLFilterOffset derives the register byte offset from the single tag value29+// used by the relay's count filter. Hashed values and address-style values are30+// both accepted.2831 func HLLFilterOffset(f event.Filter) (int, bool) {2932 if len(f.Tags) != 1 {3033 return 0, false@@ -51,6 +54,7 @@ func HLLFilterOffset(f event.Filter) (int, bool) {
5154 return 0, false5255 }5356 57+// Add incorporates one 32-byte hexadecimal public key into the sketch.5458 func (h *HLL) Add(pubkeyHex string) error {5559 if len(pubkeyHex) != 64 || !isHexCharacters(pubkeyHex) {5660 return fmt.Errorf("invalid pubkey: expected 32-byte hex")@@ -75,8 +79,10 @@ func (h *HLL) Add(pubkeyHex string) error {
7579 return nil7680 }7781 82+// Hex returns the 256 registers in their wire hexadecimal form.7883 func (h *HLL) Hex() string { return hex.EncodeToString(h.regs[:]) }7984 85+// MergeHex unions another 256-register sketch into h.8086 func (h *HLL) MergeHex(encoded string) error {8187 if len(encoded) != hllRegisters*2 {8288 return fmt.Errorf("invalid HLL: expected %d hex characters", hllRegisters*2)@@ -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")@@ -0,0 +1,9 @@
1+// Package telemetry is the relay's process-local observability boundary.2+//3+// Telemetry owns an isolated Prometheus registry, OpenTelemetry tracer4+// provider, structured logger and authenticated diagnostics handlers. Metrics5+// use bounded operation and outcome labels. New configures sampling and an6+// optional OTLP HTTP exporter; Close flushes and shuts down the tracer7+// provider. HTTP exposes metrics and, when configured, token-protected8+// diagnostics without registering process-wide handlers.9+package telemetry@@ -0,0 +1,10 @@
1+// Package templates contains the built-in relay policy and connection catalog.2+//3+// Templates describe a named starting policy, event kind limits, retention,4+// source settings and recommended connections. Connections describe the app,5+// visibility, inputs, links and copyable values shown by the relay UI. The6+// catalog functions return copies so callers can customize results without7+// changing the built-in definitions. ApplyTemplate starts with policy defaults8+// for an owner, applies the template patch, then applies kind and retention9+// limits.10+package templates@@ -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 {@@ -0,0 +1,9 @@
1+// Package views parses fenced code blocks and connects them to custom view2+// renderers.3+//4+// Blocks preserves source order and normalizes CRLF line endings. Hash names5+// an artifact by its language and source, while Path gives its relay URL.6+// Figure emits accessible markup with the rendered image and an inline source7+// fallback. Renderer maps each lowercased language to the first registered8+// view that supports it.9+package views@@ -1,7 +1,3 @@
1-// Package views holds the parts of custom views that the renderers and the2-// relay share: finding fenced code blocks in text, naming a block by its3-// hash and the markup that shows an artifact in place of a block. It knows4-// nothing about any particular transform.51 package views62 73 import (@@ -0,0 +1,9 @@
1+// Package webpush implements browser push delivery for the relay.2+//3+// Subscription validates an HTTPS endpoint, an uncompressed P-256 subscriber4+// key and its 16-byte authentication secret. Keys stores the relay's P-2565+// VAPID key and serializes it as unpadded base64url PKCS#8. Encrypt implements6+// RFC 8291 aes128gcm payload encryption. Send builds the RFC 8292 VAPID7+// authorization, posts the encrypted record, and reports whether the service8+// accepted or retired the subscription.9+package webpush@@ -1,8 +1,9 @@
1-// BUD-15 draft (June 2026), BUD-15: Client-side CHK encrypted blobs.2-// https://github.com/hzrd149/blossom/pull/1043-// Keys are client-only values. Callers must not include k in requests to a server.1+// BUD-15 client-side CHK encrypted blobs. Keys remain in the browser page and2+// are passed to the caller for sharing or later decryption.43 (() => {54 "use strict";5+ // Browser encryption for Blossom content. The public API stores and checks6+ // content-addressed ciphertext while keeping keys in the caller's page.67 78 const encoder = new TextEncoder();89 const zeroNonce = new Uint8Array(12);@@ -1,7 +1,10 @@
1-// BUD-16 and BUD-17 MessagePack manifests. This is maintained browser source,2-// embedded directly by the Go templates; it is intentionally not bundled.1+// BUD-16 and BUD-17 MessagePack manifests. Go templates embed this browser2+// source directly so the same codec serves pages and file controls.33 (() => {44 "use strict";5+ // Manifest codec and tree reader. The API encodes and validates file and6+ // directory manifests, builds chunked trees and reads them through caller7+ // supplied fetch functions.58 const CHUNK_SIZE = 2 * 1024 * 1024;69 const MAX_LINKS = 174;710 const MAX_DEPTH = 64;@@ -1,9 +1,9 @@
1-// BUD-14 draft multipart uploads.2-// https://github.com/hzrd149/blossom/pull/1023-// Upload state is deliberately held by the caller. It does not survive a page4-// navigation because BUD-14 has no offset discovery protocol.1+// BUD-14 multipart uploads. Upload state belongs to the caller and supports2+// retry, progress and cancellation for the active page.53 (() => {64 "use strict";5+ // Upload transport. plan describes signed requests for a payload; upload6+ // executes that plan with retry, progress and cancellation support.77 88 const {sha256, hex} = globalThis.tiny.util;99 const asBytes = async value => {@@ -1,6 +1,7 @@
11 // Signer bridge: session sign-in and sign-out, NIP-07 and NIP-46 signers,2-// Nostr Connect, and the NIP-98 signed fetch that every management call uses.3-// The browser never receives a relay private key; it only asks the signer.2+// Nostr Connect and the NIP-98 signed fetch used by authenticated calls.3+// It exposes tiny.localPath, tiny.authorization and tiny.signedFetch, and4+// announces a connected signer with tiny:signer.45 (() => {56 "use strict";67 @@ -0,0 +1,13 @@
1+// Package webui renders the relay's HTML pages and browser support modules.2+//3+// App owns the HTTP surface, templates, page data and browser assets. It asks4+// Backend for relay identity, policy and method results, and uses ActorResolver5+// to associate a request with the authenticated public key. The daemon owns6+// relay policy and relay writes.7+//8+// Pages use a shared shell in page.html. The shell supplies the navigation rail,9+// content column, contextual panel and command footer. Each page template10+// supplies one content section and remains readable when scripts are disabled.11+// JavaScript modules progressively add signing, navigation, forms, components,12+// room streams, file transfers and WebMCP controls.13+package webui@@ -1,4 +1,5 @@
1-/* NIP-17 file messages. This module never publishes to ordinary relays. */1+/* NIP-17 file messages. The module builds encrypted rumors and gift-wrap2+ events, discovers inbox relays and sends bounded deliveries. */23 (() => {34 "use strict";45 const {relayURL} = globalThis.tiny.util;@@ -1,3 +1,7 @@
1+{{/* The page shell owns the rail, content column, contextual panel and2+footer. Page templates render one section selected by .Tab. The body works3+before scripts load; tiny.js and bridge.js establish shared browser services,4+then feature modules are loaded for the current page. */}}15 {{define "page"}}26 <html lang="en" data-js="no" data-scripts="{{scriptsVersion}}">37 <head>@@ -1,5 +1,5 @@
1-// PrivateServices edits the encrypted GRASP-08 kind 10318 list. The relay2-// only sees the signed event; NIP-44 encryption stays with the user's signer.1+// PrivateServices edits the encrypted kind 10318 relay list. NIP-44 encryption2+// and event signing stay with the user's signer.33 (() => {44 "use strict";55 @@ -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) {@@ -1,4 +1,6 @@
1-{{/* Rooms: the list, one room's timeline with its compose bar, and one thread. */}}1+{{/* Rooms renders the list, one room's timeline with its compose bar and one2+thread. room-live receives the room stream after the server-rendered page3+establishes the initial state. */}}24 {{define "rooms"}}<section id="rooms">35 <h1>Rooms</h1>46 {{if .Error}}{{template "alert" .Error}}@@ -2,6 +2,10 @@
22 // runs after components.js has published the shared UI helper contract.33 (() => {44 "use strict";5+ // Room elements own composition, room management actions and live updates.6+ // room-compose signs kind 9 and 12 events, room-create signs room metadata,7+ // room-action handles membership controls, and room-live consumes the room8+ // event stream.59 const tiny = window.tiny;610 const {FormElement, el, isHex64} = tiny.ui;711 // Rooms: the compose bar, room creation, room administration and the live@@ -1,5 +1,7 @@
1-// Shared browser helpers. This file is loaded before every feature module2-// and the signer bridge, so each helper exists exactly once.1+// Shared browser helpers. This file is loaded before every feature module and2+// the signer bridge. It creates tiny.util for byte, URL, encoding and wiki3+// name operations, tiny.signing for verified event signing and publication,4+// and tiny.require for loading page-specific bundles once.35 (() => {46 "use strict";57 const tiny = globalThis.tiny || {};@@ -1,5 +1,9 @@
11 (function () {22 "use strict";3+ // WebMCP registers read, write and navigation tools for the browser model4+ // context. Read tools call the relay query surface, write tools use the5+ // signed management or event boundary, and navigation tools open relay6+ // pages in the current tab.37 48 const context = document.modelContext || navigator.modelContext;59 const state = window.tinyWebMCP = {@@ -1,6 +1,3 @@
1-// Package webui contains the deliberately plain HTML surface for a hosted2-// relay. It owns presentation and form routing; relay policy and mutations3-// remain in the daemon supplied Backend.41 package webui52 63 import (@@ -34,8 +31,12 @@ var signerJS []byte
3431 //go:embed fixi.js3532 var fixiJS []byte3633 34+// Backend supplies relay identity and the method calls used by page handlers.35+// Query receives the request context, method name, JSON arguments and actor36+// public key, and returns the method's JSON-shaped result. Policy, URL, slug37+// and identity describe the relay rendered into each page.3738 type Backend interface {38- Query(context.Context, string, []json.RawMessage, string) (any, error)39+ Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error)3940 Policy() policy.Policy4041 URL() string4142 Slug() string@@ -48,8 +49,12 @@ type CustomViewSource interface {
4849 CustomViews() []views.View4950 }5051 51-type ActorResolver func(*http.Request) (string, error)52-type QRProvider func(string) ([]byte, error)52+// ActorResolver returns the authenticated public key associated with request.53+// An empty key represents an anonymous browser request.54+type ActorResolver func(request *http.Request) (string, error)55+56+// QRProvider encodes text as an image for pages that show a connection QR.57+type QRProvider func(text string) ([]byte, error)5358 5459 type App struct {5560 backend Backend@@ -0,0 +1,8 @@
1+// Package wiki implements storage-independent NIP-54 article behavior.2+//3+// Normalize converts a title to an article d tag. Links discovers wikilinks,4+// reference links and nostr: targets in Djot content. RenderHTML supports the5+// article syntax used by the relay, escapes text and unsafe link targets, and6+// returns template HTML for server-side insertion. RenderHTMLWith accepts a7+// fenced-block callback so custom views can replace selected code blocks.8+package wiki@@ -0,0 +1,15 @@
1+// Package work provides durable, fenced work intents backed by a storage.Store.2+//3+// Queue.Enqueue is idempotent by intent ID. Claim selects the oldest due4+// pending intent, or a running intent whose lease expired, and assigns a5+// random claim token. Renew, Complete, Retry and CancelClaim update an intent6+// only while that token owns the claim, fencing updates from earlier attempts.7+//8+// Worker invokes the handler for a claim while renewing its lease. A nil or9+// missing handler is an ordinary failure and is retried. Handler errors are10+// retried with deterministic jittered exponential backoff; Stop marks the11+// intent cancelled and records the underlying error. If lease renewal fails,12+// the handler context is cancelled and its result is discarded as fenced.13+// RunPool starts the requested number of workers, defaults to four, and stops14+// the pool when its context is cancelled or a queue operation fails.15+package work@@ -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