diff --git a/packages/api/internal/api/router.go b/packages/api/internal/api/router.go index 6b68790..701fca9 100644 --- a/packages/api/internal/api/router.go +++ b/packages/api/internal/api/router.go @@ -102,10 +102,21 @@ func (s *Server) registerAdminRoutes(mux *http.ServeMux) { } func (s *Server) registerDocsRoutes(mux *http.ServeMux, h http.Handler) { - mux.Handle("GET /docs", h) - mux.Handle("GET /docs/search", h) - mux.Handle("GET /docs/documents", h) - mux.Handle("GET /docs/health", h) + for _, path := range []string{ + "GET /docs", + "GET /docs/search", + "GET /docs/documents", + "GET /docs/health", + "GET /docs/actors", + "GET /docs/issues", + "GET /docs/pulls", + "GET /docs/identity", + "GET /docs/activity", + "GET /docs/profiles", + "GET /docs/xrpc", + } { + mux.Handle(path, h) + } } func (s *Server) registerSiteRoutes(mux *http.ServeMux) { diff --git a/packages/api/internal/view/api-docs.json b/packages/api/internal/view/api-docs.json new file mode 100644 index 0000000..3abc310 --- /dev/null +++ b/packages/api/internal/view/api-docs.json @@ -0,0 +1,408 @@ +[ + { + "page": "search", + "route": "/search", + "method": "GET", + "summary": "Unified search across all indexed Tangled documents.", + "details": "Delegates to keyword search. The mode parameter is accepted but only \"keyword\" is supported.", + "queryParams": [ + { "name": "q", "type": "string", "required": true, "description": "Search query. Supports boolean operators, phrases, and prefix matching." }, + { "name": "mode", "type": "string", "required": false, "description": "Search mode. Only \"keyword\" is supported." }, + { "name": "limit", "type": "int", "required": false, "description": "Results per page (1–100). Default 20." }, + { "name": "offset", "type": "int", "required": false, "description": "Pagination offset. Default 0." }, + { "name": "collection", "type": "string", "required": false, "description": "Filter by ATProto collection NSID." }, + { "name": "type", "type": "string", "required": false, "description": "Filter by record type: repo, issue, pull, profile, string." }, + { "name": "author", "type": "string", "required": false, "description": "Filter by author handle or DID." }, + { "name": "repo", "type": "string", "required": false, "description": "Filter by repository name." }, + { "name": "language", "type": "string", "required": false, "description": "Filter by programming language." }, + { "name": "state", "type": "string", "required": false, "description": "Filter by state: open, closed, merged." }, + { "name": "from", "type": "string", "required": false, "description": "Created after (ISO 8601)." }, + { "name": "to", "type": "string", "required": false, "description": "Created before (ISO 8601)." } + ] + }, + { + "page": "search", + "route": "/search/keyword", + "method": "GET", + "summary": "FTS5 keyword search over indexed Tangled documents.", + "details": "Same parameters and response shape as GET /search. Use this route to bypass mode negotiation.", + "queryParams": [ + { "name": "q", "type": "string", "required": true, "description": "Search query. Supports boolean operators, phrases, and prefix matching." }, + { "name": "limit", "type": "int", "required": false, "description": "Results per page (1–100). Default 20." }, + { "name": "offset", "type": "int", "required": false, "description": "Pagination offset. Default 0." }, + { "name": "collection", "type": "string", "required": false, "description": "Filter by ATProto collection NSID." }, + { "name": "type", "type": "string", "required": false, "description": "Filter by record type: repo, issue, pull, profile, string." }, + { "name": "author", "type": "string", "required": false, "description": "Filter by author handle or DID." }, + { "name": "repo", "type": "string", "required": false, "description": "Filter by repository name." }, + { "name": "language", "type": "string", "required": false, "description": "Filter by programming language." }, + { "name": "state", "type": "string", "required": false, "description": "Filter by state: open, closed, merged." }, + { "name": "from", "type": "string", "required": false, "description": "Created after (ISO 8601)." }, + { "name": "to", "type": "string", "required": false, "description": "Created before (ISO 8601)." } + ] + }, + { + "page": "documents", + "route": "/documents/{id}", + "method": "GET", + "summary": "Fetch a single indexed document by its stable ID.", + "details": "The document ID has the format did|collection|rkey. Deleted documents return 404.", + "pathParams": [ + { "name": "id", "type": "string", "description": "Stable document ID (did|collection|rkey)." } + ] + }, + { + "page": "actors", + "route": "/actors/{handle}", + "method": "GET", + "summary": "Tangled actor profile with optional Bluesky social data.", + "details": "Resolves handle to DID, fetches sh.tangled.actor.profile from the actor's PDS. If the profile record has bluesky:true, Bluesky display name and avatar are also returned.", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Tangled handle or DID." } + ] + }, + { + "page": "actors", + "route": "/actors/{handle}/repos", + "method": "GET", + "summary": "List all sh.tangled.repo records for an actor.", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Tangled handle or DID." } + ] + }, + { + "page": "actors", + "route": "/actors/{handle}/repos/{repo}", + "method": "GET", + "summary": "Repo record with resolved knot host and AT URI.", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, + { "name": "repo", "type": "string", "description": "Repository name." } + ] + }, + { + "page": "actors", + "route": "/actors/{handle}/repos/{repo}/tree", + "method": "GET", + "summary": "Directory tree for a ref, proxied from the actor's knot.", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, + { "name": "repo", "type": "string", "description": "Repository name." } + ], + "queryParams": [ + { "name": "ref", "type": "string", "required": false, "description": "Branch, tag, or commit SHA. Defaults to default branch." }, + { "name": "path", "type": "string", "required": false, "description": "Subdirectory path within the tree." } + ] + }, + { + "page": "actors", + "route": "/actors/{handle}/repos/{repo}/blob", + "method": "GET", + "summary": "File contents at a path and ref, proxied from the actor's knot.", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, + { "name": "repo", "type": "string", "description": "Repository name." } + ], + "queryParams": [ + { "name": "ref", "type": "string", "required": false, "description": "Branch, tag, or commit SHA." }, + { "name": "path", "type": "string", "required": true, "description": "File path within the repository." } + ] + }, + { + "page": "actors", + "route": "/actors/{handle}/repos/{repo}/log", + "method": "GET", + "summary": "Commit log for a ref, proxied from the actor's knot (raw bytes).", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, + { "name": "repo", "type": "string", "description": "Repository name." } + ], + "queryParams": [ + { "name": "ref", "type": "string", "required": false, "description": "Branch, tag, or commit SHA." }, + { "name": "path", "type": "string", "required": false, "description": "Limit log to commits touching this path." }, + { "name": "limit", "type": "int", "required": false, "description": "Max commits to return." }, + { "name": "cursor", "type": "string", "required": false, "description": "Pagination cursor from a previous response." } + ] + }, + { + "page": "actors", + "route": "/actors/{handle}/repos/{repo}/branches", + "method": "GET", + "summary": "Branch list for a repo, proxied from the actor's knot (raw bytes).", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, + { "name": "repo", "type": "string", "description": "Repository name." } + ], + "queryParams": [ + { "name": "limit", "type": "int", "required": false, "description": "Max branches to return." }, + { "name": "cursor", "type": "string", "required": false, "description": "Pagination cursor." } + ] + }, + { + "page": "actors", + "route": "/actors/{handle}/repos/{repo}/default-branch", + "method": "GET", + "summary": "Default branch name for a repo, proxied from the actor's knot.", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, + { "name": "repo", "type": "string", "description": "Repository name." } + ] + }, + { + "page": "actors", + "route": "/actors/{handle}/repos/{repo}/languages", + "method": "GET", + "summary": "Language breakdown for a repo at a ref, proxied from the actor's knot.", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, + { "name": "repo", "type": "string", "description": "Repository name." } + ], + "queryParams": [ + { "name": "ref", "type": "string", "required": false, "description": "Branch, tag, or commit SHA." } + ] + }, + { + "page": "actors", + "route": "/actors/{handle}/repos/{repo}/tags", + "method": "GET", + "summary": "Tag list for a repo, proxied from the actor's knot (raw bytes).", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, + { "name": "repo", "type": "string", "description": "Repository name." } + ] + }, + { + "page": "actors", + "route": "/actors/{handle}/repos/{repo}/diff", + "method": "GET", + "summary": "Diff for a ref, proxied from the actor's knot (raw bytes).", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, + { "name": "repo", "type": "string", "description": "Repository name." } + ], + "queryParams": [ + { "name": "ref", "type": "string", "required": false, "description": "Commit SHA or branch to diff." } + ] + }, + { + "page": "actors", + "route": "/actors/{handle}/repos/{repo}/compare", + "method": "GET", + "summary": "Comparison between two refs, proxied from the actor's knot (raw bytes).", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, + { "name": "repo", "type": "string", "description": "Repository name." } + ], + "queryParams": [ + { "name": "from", "type": "string", "required": true, "description": "Base ref (branch, tag, or SHA)." }, + { "name": "to", "type": "string", "required": true, "description": "Head ref (branch, tag, or SHA)." } + ] + }, + { + "page": "actors", + "route": "/actors/{handle}/repos/{repo}/issues", + "method": "GET", + "summary": "Issues scoped to a repo, pre-joined with state (open/closed).", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, + { "name": "repo", "type": "string", "description": "Repository name." } + ] + }, + { + "page": "actors", + "route": "/actors/{handle}/repos/{repo}/pulls", + "method": "GET", + "summary": "Pull requests targeting a repo, pre-joined with status (open/closed/merged).", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, + { "name": "repo", "type": "string", "description": "Repository name." } + ] + }, + { + "page": "actors", + "route": "/actors/{handle}/issues", + "method": "GET", + "summary": "All issues authored by an actor, pre-joined with state.", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Tangled handle or DID." } + ] + }, + { + "page": "actors", + "route": "/actors/{handle}/pulls", + "method": "GET", + "summary": "All pull requests authored by an actor, pre-joined with status.", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Tangled handle or DID." } + ] + }, + { + "page": "actors", + "route": "/actors/{handle}/following", + "method": "GET", + "summary": "sh.tangled.graph.follow records for an actor.", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Tangled handle or DID." } + ] + }, + { + "page": "actors", + "route": "/actors/{handle}/strings", + "method": "GET", + "summary": "sh.tangled.string records posted by an actor.", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Tangled handle or DID." } + ] + }, + { + "page": "issues", + "route": "/issues/{handle}/{rkey}", + "method": "GET", + "summary": "Single issue with pre-joined state (open/closed).", + "details": "State is derived from sh.tangled.repo.issue.state records. If no state record exists the issue is considered open.", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Author's Tangled handle or DID." }, + { "name": "rkey", "type": "string", "description": "Record key of the issue." } + ] + }, + { + "page": "issues", + "route": "/issues/{handle}/{rkey}/comments", + "method": "GET", + "summary": "All comments for a specific issue.", + "details": "Filters sh.tangled.repo.issue.comment records from the actor's PDS by matching the issue AT URI.", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Author's Tangled handle or DID." }, + { "name": "rkey", "type": "string", "description": "Record key of the parent issue." } + ] + }, + { + "page": "pulls", + "route": "/pulls/{handle}/{rkey}", + "method": "GET", + "summary": "Single pull request with pre-joined status (open/closed/merged).", + "details": "Status is derived from sh.tangled.repo.pull.status records. If no status record exists the PR is considered open.", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Author's Tangled handle or DID." }, + { "name": "rkey", "type": "string", "description": "Record key of the pull request." } + ] + }, + { + "page": "pulls", + "route": "/pulls/{handle}/{rkey}/comments", + "method": "GET", + "summary": "All comments for a specific pull request.", + "details": "Filters sh.tangled.repo.pull.comment records from the actor's PDS by matching the pull AT URI.", + "pathParams": [ + { "name": "handle", "type": "string", "description": "Author's Tangled handle or DID." }, + { "name": "rkey", "type": "string", "description": "Record key of the parent pull request." } + ] + }, + { + "page": "identity", + "route": "/identity/resolve", + "method": "GET", + "summary": "Resolve a handle to a DID via bsky.social.", + "details": "Proxies com.atproto.identity.resolveHandle. The response shape mirrors the ATProto lexicon.", + "queryParams": [ + { "name": "handle", "type": "string", "required": true, "description": "Tangled or ATProto handle to resolve." } + ] + }, + { + "page": "identity", + "route": "/identity/did/{did}", + "method": "GET", + "summary": "Fetch a DID document for did:plc or did:web identifiers.", + "details": "did:plc resolves via plc.directory. did:web resolves via https://{host}/.well-known/did.json. Other DID methods return 400.", + "pathParams": [ + { "name": "did", "type": "string", "description": "A did:plc:… or did:web:… identifier." } + ] + }, + { + "page": "activity", + "route": "/activity", + "method": "GET", + "summary": "Paginated list of recent ATProto events from the Jetstream cache.", + "details": "Events are cached as the Jetstream firehose is consumed. Only Tangled-relevant collections are retained by default.", + "queryParams": [ + { "name": "limit", "type": "int", "required": false, "description": "Results per page (1–200). Default 50." }, + { "name": "offset", "type": "int", "required": false, "description": "Pagination offset. Default 0." }, + { "name": "collection", "type": "string", "required": false, "description": "Filter by ATProto collection NSID." }, + { "name": "operation", "type": "string", "required": false, "description": "Filter by operation: create, update, delete." }, + { "name": "did", "type": "string", "required": false, "description": "Filter by actor DID." } + ] + }, + { + "page": "activity", + "route": "/activity/stream", + "method": "GET", + "summary": "WebSocket proxy to the Bluesky Jetstream firehose.", + "details": "Accepts a WebSocket upgrade and forwards all query parameters to jetstream2.us-east.bsky.network. Standard Jetstream params: wantedCollections, wantedDids, cursor." + }, + { + "page": "profiles", + "route": "/profiles/{did}/summary", + "method": "GET", + "summary": "Social summary for an actor: follower count via Constellation.", + "details": "Best-effort — if Constellation is unavailable follower_count is 0 rather than an error.", + "pathParams": [ + { "name": "did", "type": "string", "description": "ATProto DID, e.g. did:plc:abc." } + ] + }, + { + "page": "profiles", + "route": "/backlinks/count", + "method": "GET", + "summary": "Constellation backlinks count for any subject/source pair.", + "details": "Used by the mobile client for star counts (source: sh.tangled.graph.star) and follower counts (source: sh.tangled.graph.follow).", + "queryParams": [ + { "name": "subject", "type": "string", "required": true, "description": "AT URI or DID being linked to." }, + { "name": "source", "type": "string", "required": true, "description": "Collection NSID of the backlink records." } + ] + }, + { + "page": "proxy", + "route": "/xrpc/knot/{knotHost}/{nsid}", + "method": "GET", + "summary": "Proxy a GET request to a Tangled knot's XRPC endpoint.", + "details": "Forwards to https://{knotHost}/xrpc/{nsid} with all query parameters. knotHost must not contain slashes or whitespace.", + "pathParams": [ + { "name": "knotHost", "type": "string", "description": "Knot hostname (no scheme), e.g. knot.example." }, + { "name": "nsid", "type": "string", "description": "XRPC NSID to call on the knot." } + ] + }, + { + "page": "proxy", + "route": "/xrpc/pds/{pds}/{nsid}", + "method": "GET", + "summary": "Proxy a GET request to an ATProto PDS XRPC endpoint.", + "details": "Forwards to https://{pds}/xrpc/{nsid} with all query parameters. pds must not contain slashes or whitespace.", + "pathParams": [ + { "name": "pds", "type": "string", "description": "PDS hostname (no scheme), e.g. bsky.social." }, + { "name": "nsid", "type": "string", "description": "XRPC NSID to call on the PDS." } + ] + }, + { + "page": "proxy", + "route": "/xrpc/bsky/{nsid}", + "method": "GET", + "summary": "Proxy a GET request to the Bluesky public API.", + "details": "Forwards to https://public.api.bsky.app/xrpc/{nsid}. No authentication is forwarded; only public endpoints are accessible.", + "pathParams": [ + { "name": "nsid", "type": "string", "description": "XRPC NSID to call on the Bluesky public API." } + ] + }, + { + "page": "health", + "route": "/healthz", + "method": "GET", + "summary": "Liveness probe. Returns 200 if the process is responsive.", + "details": "No dependency checks. Safe to call at any frequency." + }, + { + "page": "health", + "route": "/readyz", + "method": "GET", + "summary": "Readiness probe. Checks database reachability.", + "details": "Executes a test query against the Turso/SQLite database. Returns 503 if the database cannot be reached." + } +] diff --git a/packages/api/internal/view/docs.go b/packages/api/internal/view/docs.go new file mode 100644 index 0000000..aad3800 --- /dev/null +++ b/packages/api/internal/view/docs.go @@ -0,0 +1,46 @@ +package view + +import ( + _ "embed" + "encoding/json" +) + +// DocParam describes a single path or query parameter for an endpoint. +type DocParam struct { + Name string `json:"name"` + Type string `json:"type"` + Required bool `json:"required,omitempty"` + Description string `json:"description,omitempty"` +} + +// DocEntry describes a single API endpoint. +type DocEntry struct { + Page string `json:"page"` + Route string `json:"route"` + Method string `json:"method"` + Summary string `json:"summary"` + Details string `json:"details,omitempty"` + PathParams []DocParam `json:"pathParams,omitempty"` + QueryParams []DocParam `json:"queryParams,omitempty"` +} + +//go:embed api-docs.json +var apiDocFile []byte + +var docsByPage map[string][]DocEntry + +func init() { + var all []DocEntry + if err := json.Unmarshal(apiDocFile, &all); err != nil { + panic("api-docs.json: " + err.Error()) + } + docsByPage = make(map[string][]DocEntry) + for _, e := range all { + docsByPage[e.Page] = append(docsByPage[e.Page], e) + } +} + +// PageDocs returns the doc entries for a given page key. +func PageDocs(page string) []DocEntry { + return docsByPage[page] +} diff --git a/packages/api/internal/view/static/style.css b/packages/api/internal/view/static/style.css index 998b245..0bb830d 100644 --- a/packages/api/internal/view/static/style.css +++ b/packages/api/internal/view/static/style.css @@ -229,12 +229,20 @@ a:hover { text-decoration: underline; } .main pre code { background: none; padding: 0; } .main p, .main li, -.main td, -.main th, .main code { overflow-wrap: anywhere; word-break: break-word; } +.main th { + white-space: nowrap; +} +.main td:not(:last-child) { + white-space: nowrap; +} +.main td:last-child { + overflow-wrap: anywhere; + word-break: break-word; +} /* Mobile */ @media (max-width: 640px) { diff --git a/packages/api/internal/view/templates/docs/activity.html b/packages/api/internal/view/templates/docs/activity.html new file mode 100644 index 0000000..f4e7d11 --- /dev/null +++ b/packages/api/internal/view/templates/docs/activity.html @@ -0,0 +1,9 @@ +{{define "title"}}Activity — Twister API{{end}} +{{define "content"}} +

Activity

+

Activity endpoints expose the recent ATProto event cache and a real-time +WebSocket proxy to the Bluesky Jetstream firehose. The cache is populated as +the indexer consumes the firehose.

+{{range .}}{{template "doc-entry" .}}{{end}} +{{end}} +{{template "layout" .}} diff --git a/packages/api/internal/view/templates/docs/actors.html b/packages/api/internal/view/templates/docs/actors.html new file mode 100644 index 0000000..4bbe44b --- /dev/null +++ b/packages/api/internal/view/templates/docs/actors.html @@ -0,0 +1,9 @@ +{{define "title"}}Actors & Repos — Twister API{{end}} +{{define "content"}} +

Actors & Repos

+

Actor endpoints resolve Tangled handles and DIDs, fetching profile and repo +data from the actor’s PDS. Repo sub-resource endpoints proxy live data +from the actor’s assigned knot host.

+{{range .}}{{template "doc-entry" .}}{{end}} +{{end}} +{{template "layout" .}} diff --git a/packages/api/internal/view/templates/docs/documents.html b/packages/api/internal/view/templates/docs/documents.html index b5eede0..3b2087c 100644 --- a/packages/api/internal/view/templates/docs/documents.html +++ b/packages/api/internal/view/templates/docs/documents.html @@ -1,40 +1,10 @@ -{{define "title"}}GET /documents/{id} — Twister API{{end}} +{{define "title"}}Documents — Twister API{{end}} {{define "content"}} -

GET /documents/{id}

-

Fetch a single indexed document by its stable ID.

- -

Path Parameters

- - - - - -
NameTypeDescription
idstringDocument stable ID (format: did|collection|rkey).
- -

Example Request

-
curl "https://your-domain/documents/did:plc:abc|sh.tangled.repo|3kb3fge5lm32x"
- -

Example Response

-
{
-  "id": "did:plc:abc|sh.tangled.repo|3kb3fge5lm32x",
-  "did": "did:plc:abc",
-  "collection": "sh.tangled.repo",
-  "rkey": "3kb3fge5lm32x",
-  "at_uri": "at://did:plc:abc/sh.tangled.repo/3kb3fge5lm32x",
-  "cid": "bafyreig...",
-  "record_type": "repo",
-  "title": "glow-rs",
-  "body": "A TUI markdown viewer inspired by Glow, written in Rust.",
-  "summary": "Rust TUI markdown viewer",
-  "repo_name": "glow-rs",
-  "author_handle": "desertthunder.dev",
-  "tags_json": "[\"rust\", \"tui\", \"markdown\"]",
-  "language": "en",
-  "created_at": "2026-03-20T10:00:00Z",
-  "updated_at": "2026-03-22T15:03:11Z",
-  "indexed_at": "2026-03-22T15:05:00Z"
-}
- +

Documents

+

The documents endpoint retrieves individual indexed records by their stable +ID. Document IDs are returned in search results and have the form +did|collection|rkey.

+{{range .}}{{template "doc-entry" .}}{{end}}

Errors

diff --git a/packages/api/internal/view/templates/docs/health.html b/packages/api/internal/view/templates/docs/health.html index 38a651f..58ba48f 100644 --- a/packages/api/internal/view/templates/docs/health.html +++ b/packages/api/internal/view/templates/docs/health.html @@ -1,36 +1,12 @@ -{{define "title"}}Health Endpoints — Twister API{{end}} +{{define "title"}}Health — Twister API{{end}} {{define "content"}} -

Health Endpoints

+

Health

Twister exposes two health check endpoints for monitoring and orchestration.

- -

GET /healthz

-

Liveness probe. Returns 200 if the process is responsive. No dependency checks.

- -

Example Request

-
curl https://your-domain/healthz
- -

Response

-
{"status": "ok"}
-

Always returns 200 OK.

- -

GET /readyz

-

Readiness probe. Checks that the database is reachable by executing a test query.

- -

Example Request

-
curl https://your-domain/readyz
- -

Response (healthy)

-
{"status": "ready"}
-

Returns 200 OK when the database is reachable.

- -

Response (unhealthy)

-
{"error": "db_unreachable", "message": "database is not reachable"}
-

Returns 503 Service Unavailable when the database cannot be reached.

- +{{range .}}{{template "doc-entry" .}}{{end}}

Usage

-

Configure your orchestrator (Railway, Kubernetes, etc.) to poll these endpoints:

+

Configure your orchestrator to poll these endpoints:

StatusCondition
- + diff --git a/packages/api/internal/view/templates/docs/identity.html b/packages/api/internal/view/templates/docs/identity.html new file mode 100644 index 0000000..79b6f60 --- /dev/null +++ b/packages/api/internal/view/templates/docs/identity.html @@ -0,0 +1,9 @@ +{{define "title"}}Identity — Twister API{{end}} +{{define "content"}} +

Identity

+

Identity endpoints resolve ATProto identifiers. Both routes proxy upstream +services verbatim, making them safe to call from browser clients without +additional CORS configuration.

+{{range .}}{{template "doc-entry" .}}{{end}} +{{end}} +{{template "layout" .}} diff --git a/packages/api/internal/view/templates/docs/index.html b/packages/api/internal/view/templates/docs/index.html index 8913820..056162f 100644 --- a/packages/api/internal/view/templates/docs/index.html +++ b/packages/api/internal/view/templates/docs/index.html @@ -1,34 +1,50 @@ {{define "title"}}API Docs — Twister{{end}} {{define "content"}}

API Documentation

-

Twister exposes a public JSON API for searching indexed Tangled content. No authentication is required for read endpoints.

+

Twister exposes a public JSON API for searching indexed +Tangled content. +No authentication is required for read endpoints.

Base URL

https://<your-twister-domain>
-

All endpoints are relative to the base URL. When using the search site, the API is on the same origin.

+

All endpoints are relative to the base URL. When using the search site, the +API is on the same origin.

-

Response Shape

-

All responses are JSON. Successful responses return the resource directly. Errors return:

-
{
-  "error": "error_code",
-  "message": "Human-readable description"
-}
+

Response shape

+

All responses are JSON. Successful responses return the resource directly. +Errors return:

+
{"error": "error_code", "message": "what went wrong"}

Endpoints

EndpointPurposeFailure Action
EndpointPurposeFailure action
/healthzLivenessRestart the process
/readyzReadinessRemove from load balancer
- + - - - - + + + + + + + + + + + + + + + + + + + +
MethodPathDescription
ResourceDescription
GET/searchSearch indexed documents
GET/documents/{id}Fetch a single document
GET/healthzLiveness probe
GET/readyzReadiness probe
SearchFTS5 keyword search over indexed documents
DocumentsFetch a single indexed document by stable ID
Actors & ReposActor profiles, repo records, and repo sub-resources
IssuesIssue detail and comments with pre-joined state
Pull RequestsPR detail and comments with pre-joined status
IdentityHandle resolution and DID document lookup
ActivityCached Jetstream events and real-time WebSocket stream
Profiles & BacklinksSocial signals and Constellation backlinks counts
XRPC ProxyPass-through proxy to knots, PDS hosts, and Bluesky
HealthLiveness and readiness probes

Pagination

-

List endpoints use limit and offset query parameters. The default limit is 20, maximum is 100.

- -

Filtering

-

Search supports filters via query parameters: collection, type, author, repo, language, state, from, to. See the search endpoint docs for details.

+

List endpoints use limit and offset query +parameters. Defaults and maximums vary per endpoint — see each resource page +for details.

{{end}} {{template "layout" .}} diff --git a/packages/api/internal/view/templates/docs/issues.html b/packages/api/internal/view/templates/docs/issues.html new file mode 100644 index 0000000..1d8fae5 --- /dev/null +++ b/packages/api/internal/view/templates/docs/issues.html @@ -0,0 +1,9 @@ +{{define "title"}}Issues — Twister API{{end}} +{{define "content"}} +

Issues

+

Issue endpoints fetch records and comments from an actor’s PDS. +Responses include a pre-joined state field derived from +sh.tangled.repo.issue.state records.

+{{range .}}{{template "doc-entry" .}}{{end}} +{{end}} +{{template "layout" .}} diff --git a/packages/api/internal/view/templates/docs/profiles.html b/packages/api/internal/view/templates/docs/profiles.html new file mode 100644 index 0000000..fc15cd5 --- /dev/null +++ b/packages/api/internal/view/templates/docs/profiles.html @@ -0,0 +1,9 @@ +{{define "title"}}Profiles & Backlinks — Twister API{{end}} +{{define "content"}} +

Profiles & Backlinks

+

These endpoints surface derived social data from Constellation, the external +graph service. Both are best-effort: counts default to zero when Constellation +is not configured or unavailable.

+{{range .}}{{template "doc-entry" .}}{{end}} +{{end}} +{{template "layout" .}} diff --git a/packages/api/internal/view/templates/docs/pulls.html b/packages/api/internal/view/templates/docs/pulls.html new file mode 100644 index 0000000..0614dc5 --- /dev/null +++ b/packages/api/internal/view/templates/docs/pulls.html @@ -0,0 +1,10 @@ +{{define "title"}}Pull Requests — Twister API{{end}} +{{define "content"}} +

Pull Requests

+

Pull request endpoints fetch records and comments from an actor’s PDS. +Responses include a pre-joined status field derived from +sh.tangled.repo.pull.status records: +open, closed, or merged.

+{{range .}}{{template "doc-entry" .}}{{end}} +{{end}} +{{template "layout" .}} diff --git a/packages/api/internal/view/templates/docs/search.html b/packages/api/internal/view/templates/docs/search.html index 55f08fa..937266d 100644 --- a/packages/api/internal/view/templates/docs/search.html +++ b/packages/api/internal/view/templates/docs/search.html @@ -1,56 +1,12 @@ -{{define "title"}}GET /search — Twister API{{end}} +{{define "title"}}Search — Twister API{{end}} {{define "content"}} -

GET /search

-

Search indexed Tangled documents with keyword matching. Returns a paginated list of results with highlighted snippets.

- -

Parameters

- - - - - - - - - - - - - - - - -
NameTypeDefaultDescription
qstringrequiredSearch query. Supports boolean operators, phrases, and prefix matching.
modestringkeywordSearch mode: keyword, semantic, or hybrid.
limitint20Results per page (1–100).
offsetint0Pagination offset.
collectionstring—Filter by ATProto collection NSID.
typestring—Filter by record type: repo, issue, pull, profile, string.
authorstring—Filter by author handle or DID.
repostring—Filter by repo name.
languagestring—Filter by programming language.
statestring—Filter by state: open, closed, merged.
fromstring—Created after (ISO 8601).
tostring—Created before (ISO 8601).
- -

Example Request

-
curl "https://your-domain/search?q=rust+tui&type=repo&limit=5"
- -

Example Response

-
{
-  "query": "rust tui",
-  "mode": "keyword",
-  "total": 12,
-  "limit": 5,
-  "offset": 0,
-  "results": [
-    {
-      "id": "did:plc:abc|sh.tangled.repo|3kb3fge5lm32x",
-      "collection": "sh.tangled.repo",
-      "record_type": "repo",
-      "title": "glow-rs",
-      "body_snippet": "A <mark>TUI</mark> markdown viewer written in <mark>Rust</mark>...",
-      "summary": "Rust TUI markdown viewer",
-      "repo_name": "glow-rs",
-      "author_handle": "desertthunder.dev",
-      "score": 0.842,
-      "created_at": "2026-03-20T10:00:00Z",
-      "updated_at": "2026-03-22T15:03:11Z"
-    }
-  ]
-}
- -

Query Syntax

-

The keyword search uses Tantivy query syntax:

+

Search

+

Search endpoints run FTS5 keyword queries over indexed Tangled documents. +Results include highlighted body snippets and BM25 relevance scores. If +Constellation is configured, repository results are enriched with star counts.

+{{range .}}{{template "doc-entry" .}}{{end}} +

Query syntax

+

The keyword search uses SQLite FTS5 query syntax:

@@ -61,12 +17,11 @@
FeatureExample
Field-scopedtitle:parser
-

Errors

- +
StatusCondition
400Missing q, invalid limit/offset, unknown parameters.
400Missing q, invalid limit/offset, or unknown parameter.
500Internal search failure.
diff --git a/packages/api/internal/view/templates/docs/xrpc.html b/packages/api/internal/view/templates/docs/xrpc.html new file mode 100644 index 0000000..3a1b815 --- /dev/null +++ b/packages/api/internal/view/templates/docs/xrpc.html @@ -0,0 +1,10 @@ +{{define "title"}}XRPC — Twister API{{end}} +{{define "content"}} +

XRPC

+

XRPC endpoints provide a single-origin pass-through to Tangled knots, PDS +hosts, and the Bluesky public API. All query parameters are forwarded verbatim. +Responses are streamed back unchanged, making these suitable for browser clients +that cannot make direct cross-origin requests.

+{{range .}}{{template "doc-entry" .}}{{end}} +{{end}} +{{template "layout" .}} diff --git a/packages/api/internal/view/templates/partials/doc-entry.html b/packages/api/internal/view/templates/partials/doc-entry.html new file mode 100644 index 0000000..a2d609f --- /dev/null +++ b/packages/api/internal/view/templates/partials/doc-entry.html @@ -0,0 +1,38 @@ +{{define "doc-entry"}} +
+

{{.Method}} {{.Route}}

+

{{.Summary}}

+ {{if .Details}}

{{.Details}}

{{end}} + {{if .PathParams}} +

Path parameters

+ + + + {{range .PathParams}} + + + + + + {{end}} + +
NameTypeDescription
{{.Name}}{{.Type}}{{.Description}}
+ {{end}} + {{if .QueryParams}} +

Query parameters

+ + + + {{range .QueryParams}} + + + + + + + {{end}} + +
NameTypeRequiredDescription
{{.Name}}{{.Type}}{{if .Required}}yes{{else}}—{{end}}{{.Description}}
+ {{end}} +
+{{end}} diff --git a/packages/api/internal/view/view.go b/packages/api/internal/view/view.go index 1c27b7e..3651e8a 100644 --- a/packages/api/internal/view/view.go +++ b/packages/api/internal/view/view.go @@ -7,22 +7,39 @@ import ( "net/http" ) -//go:embed templates static +//go:embed templates static api-docs.json var content embed.FS var pageTemplates map[string]*template.Template func init() { pageTemplates = make(map[string]*template.Template) + for _, name := range []string{ "index.html", + } { + pageTemplates[name] = template.Must(template.New("layout").ParseFS(content, + "templates/layout.html", + "templates/"+name, + )) + } + + for _, name := range []string{ "docs/index.html", "docs/search.html", "docs/documents.html", "docs/health.html", + "docs/actors.html", + "docs/issues.html", + "docs/pulls.html", + "docs/identity.html", + "docs/activity.html", + "docs/profiles.html", + "docs/xrpc.html", } { pageTemplates[name] = template.Must(template.New("layout").ParseFS(content, "templates/layout.html", + "templates/partials/doc-entry.html", "templates/"+name, )) } @@ -35,9 +52,16 @@ func Handler() http.Handler { staticFS, _ := fs.Sub(content, "static") mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFS))) - mux.HandleFunc("GET /docs/search", renderPage("docs/search.html")) - mux.HandleFunc("GET /docs/documents", renderPage("docs/documents.html")) - mux.HandleFunc("GET /docs/health", renderPage("docs/health.html")) + mux.HandleFunc("GET /docs/search", renderDocPage("docs/search.html", "search")) + mux.HandleFunc("GET /docs/documents", renderDocPage("docs/documents.html", "documents")) + mux.HandleFunc("GET /docs/health", renderDocPage("docs/health.html", "health")) + mux.HandleFunc("GET /docs/actors", renderDocPage("docs/actors.html", "actors")) + mux.HandleFunc("GET /docs/issues", renderDocPage("docs/issues.html", "issues")) + mux.HandleFunc("GET /docs/pulls", renderDocPage("docs/pulls.html", "pulls")) + mux.HandleFunc("GET /docs/identity", renderDocPage("docs/identity.html", "identity")) + mux.HandleFunc("GET /docs/activity", renderDocPage("docs/activity.html", "activity")) + mux.HandleFunc("GET /docs/profiles", renderDocPage("docs/profiles.html", "profiles")) + mux.HandleFunc("GET /docs/xrpc", renderDocPage("docs/xrpc.html", "proxy")) mux.HandleFunc("GET /docs", renderPage("docs/index.html")) mux.HandleFunc("GET /{$}", renderPage("index.html")) @@ -51,10 +75,23 @@ func renderPage(name string) http.HandlerFunc { http.NotFound(w, r) return } - w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.ExecuteTemplate(w, "layout", nil); err != nil { http.Error(w, "template error", http.StatusInternalServerError) } } } + +func renderDocPage(name, page string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tmpl, ok := pageTemplates[name] + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.ExecuteTemplate(w, "layout", PageDocs(page)); err != nil { + http.Error(w, "template error", http.StatusInternalServerError) + } + } +} diff --git a/packages/api/overview.md b/packages/api/overview.md new file mode 100644 index 0000000..3e35ee2 --- /dev/null +++ b/packages/api/overview.md @@ -0,0 +1,144 @@ +# Twister Backend Overview + +Twister is a Go backend service that indexes Tangled network content and serves +search queries for the Twisted mobile client. It complements Tangled's public +APIs by providing global search and derived data that's hard to compute client-side. + +## Core Responsibilities + +1. **Real-time Indexing**: Consumes record changes from Tap (Tangled's event + stream) and indexes them into SQLite FTS5 for full-text search. + +2. **Search API**: Exposes HTTP endpoints for keyword search over repos, + profiles, issues, pull requests, and follows. + +3. **Graph Augmentation**: Caches follower relationships and provides profile + summaries not easily derivable from the public knot/PDS APIs. + +## Architecture + +```text +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Tap │────▶│ Indexer │────▶│ Turso │ +│ (event stream) │ (ingest) │ │ (SQLite) │ +└─────────────┘ └─────────────┘ └──────┬──────┘ + │ +┌─────────────┐ ┌─────────────┐ │ +│ Twisted │────▶│ API │◀───────────┘ +│ (mobile) │ │ (search) │ +└─────────────┘ └─────────────┘ +``` + +## Commands + +The `twister` binary provides six subcommands: + +- `api` / `serve`: HTTP server for search and proxy endpoints +- `indexer`: Tap consumer that processes record events into the index +- `backfill`: Discovers users from seeds and registers repos with Tap +- `reindex`: Rebuilds the FTS index from existing documents +- `enrich`: Backfills derived fields (handles, URLs) on existing documents +- `healthcheck`: One-shot health probe + +## Data Flow + +### Indexing Pipeline (internal/ingest) + +1. **Tap Consumer** (`internal/tapclient`): Connects to Tap WebSocket, reads + record events with cursor-based resume. + +2. **Normalizers** (`internal/normalize`): Collection-specific adapters convert + raw records into normalized `Document` structs. Each collection (repos, + issues, PRs, follows, profiles) has a dedicated adapter. + +3. **Store** (`internal/store`): Upserts documents into SQLite with FTS5 index. + Tracks cursor position in `sync_state` table for resume-after-restart. + +### Search (internal/search) + +- **Keyword Search**: SQLite FTS5 full-text search with BM25 ranking +- **Filters**: By collection, author, repo, language, date range, state +- **Results**: Denormalized documents with snippet highlighting + +## Key Packages + +| Package | Purpose | +| ------------------------ | ------------------------------------------ | +| `internal/api` | HTTP router, handlers, middleware | +| `internal/store` | Database access layer (Turso/local SQLite) | +| `internal/ingest` | Tap event processing pipeline | +| `internal/normalize` | Record adapters for each collection type | +| `internal/search` | FTS5 query execution | +| `internal/backfill` | Graph discovery and Tap registration | +| `internal/xrpc` | XRPC client for Tangled PDS/knot APIs | +| `internal/constellation` | External service for graph queries | + +## API Endpoints + +### Search + +- `GET /search` - Unified search across all collections +- `GET /search/keyword` - FTS5 keyword search + +### Actors & Repos + +- `GET /actors/{handle}` - Profile data +- `GET /actors/{handle}/repos` - User's repositories +- `GET /actors/{handle}/repos/{repo}` - Repo details (tree, log, branches) + +### Issues & Pulls + +- `GET /issues/{handle}/{rkey}` - Issue detail +- `GET /pulls/{handle}/{rkey}` - Pull request detail + +### Identity + +- `GET /identity/resolve` - Resolve handle to DID +- `GET /identity/did/{did}` - DID document lookup + +### Proxy + +- `GET /xrpc/knot/{knot}/{nsid}` - Proxy to Tangled knot +- `GET /xrpc/pds/{pds}/{nsid}` - Proxy to PDS +- `GET /xrpc/bsky/{nsid}` - Proxy to Bluesky + +## Database Schema + +Core tables in Turso/libSQL: + +- `documents`: Denormalized search documents (title, body, metadata) +- `documents_fts`: FTS5 virtual table for full-text search +- `identity_handles`: DID → handle mapping with active status +- `record_state`: Issue/PR state (open/closed/merged) +- `sync_state`: Cursor tracking for Tap consumer resume +- `indexing_jobs`: Queue for async read-through indexing + +## Configuration + +Environment variables (loaded by `internal/config`): + +| Variable | Purpose | +| --------------------- | ------------------------------------------ | +| `HTTP_BIND_ADDR` | API server address (default `:8080`) | +| `TURSO_DATABASE_URL` | libsql:// URL for Turso or file: for local | +| `TURSO_AUTH_TOKEN` | Turso auth token (empty for local) | +| `TAP_URL` | Tap WebSocket URL (required for indexer) | +| `TAP_AUTH_PASSWORD` | Tap authentication password | +| `INDEXED_COLLECTIONS` | Comma-separated allowlist (empty = all) | + +## Local Development + +```bash +# API only (uses local SQLite file) +just api-dev + +# Indexer only +just api-run-indexer + +# Both in parallel (three terminals) +pnpm dev # Frontend +pnpm api:run:api # API +pnpm api:run:indexer # Indexer +``` + +For remote Turso, use `--remote` flag or set `TURSO_DATABASE_URL` in `.env`.