Artifact 455d50b878d003ed11dc3daa7c4aeff84a2c542f7bc303c34d646ba117269fde:
- File
internal/api/register.go
— part of check-in
[9005c16485]
at
2026-08-16 01:05:22
on branch trunk
— write path: wiki/ticket ops, forms, Basic auth over sync secrets
wiki.save + ticket.new/set through zit#71 verbs with read-your-writes; edit/new pages; Token ops gate on HTTP Basic against the RFC-derived shared secret (same credentials as sync). Harness stores now carry authored wiki+tickets; wiki surfaces join disposability. (user: dmestas, size: 23251) [annotate] [blame] [check-ins using]
// Package api owns RegisterAll — the one explicit wiring point where every // op joins the registry (0003 §2: no init() magic; the wiring stays // greppable) — plus the HTTP router that serves the registered ops. This // is the sanctioned place Run closures over the data planes are built; // the arch gate keeps every other consumer on the op side of the seam. package api import ( "bytes" "context" "errors" "fmt" "html" "net/url" "sort" "unicode/utf8" "craftdesign.group/quarry/internal/highlight" "craftdesign.group/quarry/internal/index" "craftdesign.group/quarry/internal/markup" "craftdesign.group/quarry/internal/ops" "craftdesign.group/quarry/internal/zitcli" ) // Repo is one registered store's data-plane pair. type Repo struct { Index *index.Index Zit *zitcli.Client // Sync runs one ingest round; write ops call it so a successful // response means the index already reflects the write (read-your- // writes). nil skips (the background poll converges instead). Sync func() (int, error) } // synced runs the repo's ingest round after a successful write. func (r *Repo) synced() error { if r.Sync == nil { return nil } _, err := r.Sync() return err } // author resolves the recorded author: the authenticated user, or // "anonymous" on an ungated repo. func author(ctx context.Context) string { if u := ops.User(ctx); u != "" { return u } return "anonymous" } // Deps carries what op implementations close over. Repos may be empty // (`quarry spec` runs storeless); ops answer unknown repos with // not-found, and the spec stays complete either way. type Deps struct { Version string Repos map[string]*Repo } // repo resolves one registered store; unknown names are not-found. func (d Deps) repo(name string) (*Repo, error) { if r, ok := d.Repos[name]; ok { return r, nil } return nil, fmt.Errorf("no repository %q: %w", name, ops.ErrNotFound) } type versionIn struct{} type versionOut struct { Version string `json:"version"` } type repoListIn struct{} type repoInfo struct { Name string `json:"name"` Checkins int `json:"checkins"` TipDate string `json:"tipDate"` TipComment string `json:"tipComment"` } type wikiSaveIn struct { Repo string `json:"repo" validate:"required,max=64"` Name string `json:"name" validate:"required,max=100"` Body string `json:"body" validate:"required,max=1048576"` Mimetype string `json:"mimetype" validate:"max=64"` } type wikiSaveOut struct { Hash string `json:"hash"` } type ticketNewIn struct { Repo string `json:"repo" validate:"required,max=64"` Title string `json:"title" validate:"required,max=200"` Status string `json:"status" validate:"max=40"` Comment string `json:"comment" validate:"max=65536"` } type ticketNewOut struct { ID string `json:"id"` Hash string `json:"hash"` } type ticketSetIn struct { Repo string `json:"repo" validate:"required,max=64"` ID string `json:"id" validate:"required,min=4,max=40"` Title string `json:"title" validate:"max=200"` Status string `json:"status" validate:"max=40"` Resolution string `json:"resolution" validate:"max=40"` Comment string `json:"comment" validate:"max=65536"` } type ticketSetOut struct { Hash string `json:"hash"` } type docsIn struct{} type docsOut struct { Markdown string `json:"markdown"` HTML string `json:"html"` } type repoListOut struct { Repos []repoInfo `json:"repos"` Count int `json:"count"` } type timelineIn struct { Repo string `json:"repo" validate:"required,max=64"` Limit int `json:"limit" validate:"min=0,max=200,default=50"` Before string `json:"before" validate:"max=96"` Branch string `json:"branch" validate:"max=64"` Y string `json:"y" validate:"oneof=all|ci|w|t"` } type timelineOut struct { Entries []index.TimelineEntry `json:"entries"` Count int `json:"count"` // Next is the `before` cursor for the following (older) page; // empty when this page is the last. Next string `json:"next"` } type wikiListIn struct { Repo string `json:"repo" validate:"required,max=64"` } type wikiListOut struct { Pages []index.WikiTitle `json:"pages"` Count int `json:"count"` } type wikiGetIn struct { Repo string `json:"repo" validate:"required,max=64"` Name string `json:"name" validate:"required,max=100"` Rev string `json:"rev" validate:"max=64"` // artifact hash prefix; empty = latest } type wikiGetOut struct { Hash string `json:"hash"` Title string `json:"title"` Date string `json:"date"` User string `json:"user"` Mimetype string `json:"mimetype"` Source string `json:"source"` HTML string `json:"html"` } type wikiHistoryIn struct { Repo string `json:"repo" validate:"required,max=64"` Name string `json:"name" validate:"required,max=100"` } type wikiHistoryOut struct { Title string `json:"title"` Edits []index.WikiRev `json:"edits"` Count int `json:"count"` } type fileGetIn struct { Repo string `json:"repo" validate:"required,max=64"` Hash string `json:"hash" validate:"required,min=4,max=64"` Path string `json:"path" validate:"required,max=512"` } type fileGetOut struct { Hash string `json:"hash"` Path string `json:"path"` Size int `json:"size"` Binary bool `json:"binary"` Content string `json:"content"` HTML string `json:"html"` // highlighted, sanitizer-gated; empty when no lexer matched } type searchIn struct { Repo string `json:"repo" validate:"required,max=64"` Q string `json:"q" validate:"required,max=200"` Limit int `json:"limit" validate:"min=0,max=200,default=50"` } type searchOut struct { Results []index.SearchResult `json:"results"` Count int `json:"count"` } type diffGetIn struct { Repo string `json:"repo" validate:"required,max=64"` Hash string `json:"hash" validate:"required,min=4,max=64"` } type diffGetOut struct { From string `json:"from"` To string `json:"to"` Files []zitcli.DiffEntry `json:"files"` Unified string `json:"unified"` Note string `json:"note"` // set when the diff could not be fully computed } type checkinGetIn struct { Repo string `json:"repo" validate:"required,max=64"` Hash string `json:"hash" validate:"required,min=4,max=64"` } type checkinGetOut struct { Hash string `json:"hash"` Branch string `json:"branch"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` Parents []string `json:"parents"` Files []zitcli.LsEntry `json:"files"` } type ticketListIn struct { Repo string `json:"repo" validate:"required,max=64"` Limit int `json:"limit" validate:"min=0,max=200,default=50"` Before string `json:"before" validate:"max=96"` } // ticketSummary is the list row: id plus the two fields every tracker // listing needs, pulled out of the folded state. type ticketSummary struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Updated string `json:"updated"` } type ticketListOut struct { Tickets []ticketSummary `json:"tickets"` Count int `json:"count"` // Next is the `before` cursor for the following (older-updated) // page; empty when this page is the last. Next string `json:"next"` } type ticketGetIn struct { Repo string `json:"repo" validate:"required,max=64"` ID string `json:"id" validate:"required,min=4,max=40"` } type ticketGetOut struct { Ticket index.TicketState `json:"ticket"` } func fieldValue(fields []index.TicketFieldSet, name string) string { for _, f := range fields { if f.Name == name { return f.Value } } return "" } // resolver implements markup.Resolver over one repo's index. Wiki names // resolve to existing pages; hash prefixes resolve to uniquely matching // check-ins or tickets. An unresolvable or ambiguous reference renders // as plain text, never as a dead link. type resolver struct { ix *index.Index repo string } func (r resolver) ResolveHash(prefix string) string { if r.ix == nil { return "" } if e, _, ok, err := r.ix.CheckinByPrefix(prefix); err == nil && ok { return "/r/" + r.repo + "/checkin/" + e.Hash } if t, ok, err := r.ix.TicketByPrefix(prefix); err == nil && ok { return "/r/" + r.repo + "/ticket/" + t.ID } return "" } func (r resolver) ResolveWiki(name string) string { if r.ix == nil { return "" } if _, ok, err := r.ix.WikiPage(name); err == nil && ok { return "/r/" + r.repo + "/wiki/" + url.PathEscape(name) } return "" } // RegisterAll wires every op. func RegisterAll(r *ops.Registry, d Deps) error { if err := ops.Register(r, ops.Op[versionIn, versionOut]{ Name: "meta.version", Doc: "Report the quarry build version.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/version", Run: func(_ context.Context, _ versionIn) (versionOut, error) { return versionOut{Version: d.Version}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[wikiSaveIn, wikiSaveOut]{ Name: "wiki.save", Doc: "Record a wiki page edit (creates the page on first save).", Kind: ops.Command, Auth: ops.Token, Route: "POST /api/v1/r/{repo}/wiki/{name}", Run: func(ctx context.Context, in wikiSaveIn) (wikiSaveOut, error) { repo, err := d.repo(in.Repo) if err != nil { return wikiSaveOut{}, err } mt := in.Mimetype if mt == "" { mt = "text/x-markdown" } hash, err := repo.Zit.WikiCommit(in.Name, author(ctx), mt, []byte(in.Body)) if err != nil { return wikiSaveOut{}, err } if err := repo.synced(); err != nil { return wikiSaveOut{}, err } return wikiSaveOut{Hash: hash}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[ticketNewIn, ticketNewOut]{ Name: "ticket.new", Doc: "Open a new ticket.", Kind: ops.Command, Auth: ops.Token, Route: "POST /api/v1/r/{repo}/ticket", Run: func(ctx context.Context, in ticketNewIn) (ticketNewOut, error) { repo, err := d.repo(in.Repo) if err != nil { return ticketNewOut{}, err } status := in.Status if status == "" { status = "Open" } fields := map[string]string{"title": in.Title, "status": status} if in.Comment != "" { fields["comment"] = in.Comment } id, hash, err := repo.Zit.TicketNew(author(ctx), fields) if err != nil { return ticketNewOut{}, err } if err := repo.synced(); err != nil { return ticketNewOut{}, err } return ticketNewOut{ID: id, Hash: hash}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[ticketSetIn, ticketSetOut]{ Name: "ticket.set", Doc: "Record a ticket change; empty fields are left untouched.", Kind: ops.Command, Auth: ops.Token, Route: "POST /api/v1/r/{repo}/ticket/{id}", Run: func(ctx context.Context, in ticketSetIn) (ticketSetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return ticketSetOut{}, err } // The route accepts the id prefixes pages link with; the zit // verb wants the full stored id. t, ok, err := repo.Index.TicketByPrefix(in.ID) if err != nil { return ticketSetOut{}, err } if !ok { return ticketSetOut{}, fmt.Errorf("no unique ticket for %q: %w", in.ID, ops.ErrNotFound) } fields := map[string]string{} for k, v := range map[string]string{ "title": in.Title, "status": in.Status, "resolution": in.Resolution, "comment": in.Comment, } { if v != "" { fields[k] = v } } if len(fields) == 0 { return ticketSetOut{}, fmt.Errorf("no fields to set") } hash, err := repo.Zit.TicketSet(t.ID, author(ctx), fields) if err != nil { return ticketSetOut{}, err } if err := repo.synced(); err != nil { return ticketSetOut{}, err } return ticketSetOut{Hash: hash}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[docsIn, docsOut]{ Name: "meta.docs", Doc: "The API reference, generated from this registry and rendered.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/docs", Run: func(_ context.Context, _ docsIn) (docsOut, error) { // r is complete by the time any op runs; the reference // documents the same registry that serves it. md := ops.Docs(r, "quarry") rendered, err := markup.Render(md, resolver{}) if err != nil { return docsOut{}, err } return docsOut{Markdown: string(md), HTML: string(rendered)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[repoListIn, repoListOut]{ Name: "repo.list", Doc: "List the served repositories with their tip check-in.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/repos", Run: func(_ context.Context, _ repoListIn) (repoListOut, error) { names := make([]string, 0, len(d.Repos)) for name := range d.Repos { names = append(names, name) } sort.Strings(names) out := repoListOut{Count: len(names)} for _, name := range names { info := repoInfo{Name: name} if ix := d.Repos[name].Index; ix != nil { info.Checkins, _ = ix.CheckinCount() if tip, _, err := ix.Timeline(1, "", "", "ci"); err == nil && len(tip) == 1 { info.TipDate, info.TipComment = tip[0].Date, tip[0].Comment } } out.Repos = append(out.Repos, info) } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[timelineIn, timelineOut]{ Name: "timeline.list", Doc: "List a repository's events — check-ins, wiki edits, ticket changes — newest first, paged by `before`.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/timeline", Run: func(_ context.Context, in timelineIn) (timelineOut, error) { repo, err := d.repo(in.Repo) if err != nil { return timelineOut{}, err } entries, more, err := repo.Index.Timeline(in.Limit, in.Before, in.Branch, in.Y) if err != nil { return timelineOut{}, err } out := timelineOut{Entries: entries, Count: len(entries)} if more && len(entries) > 0 { last := entries[len(entries)-1] out.Next = last.Date + "~" + last.Hash } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[wikiListIn, wikiListOut]{ Name: "wiki.list", Doc: "List a repository's wiki pages with their latest edit.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/wiki", Run: func(_ context.Context, in wikiListIn) (wikiListOut, error) { repo, err := d.repo(in.Repo) if err != nil { return wikiListOut{}, err } pages, err := repo.Index.WikiTitles() if err != nil { return wikiListOut{}, err } return wikiListOut{Pages: pages, Count: len(pages)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[ticketListIn, ticketListOut]{ Name: "ticket.list", Doc: "List a repository's tickets with their folded current state.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/ticket", Run: func(_ context.Context, in ticketListIn) (ticketListOut, error) { repo, err := d.repo(in.Repo) if err != nil { return ticketListOut{}, err } all, more, err := repo.Index.TicketSummaries(in.Limit, in.Before) if err != nil { return ticketListOut{}, err } out := ticketListOut{Count: len(all)} for _, t := range all { out.Tickets = append(out.Tickets, ticketSummary{ ID: t.ID, Title: t.Title, Status: t.Status, Updated: t.Updated, }) } if more && len(all) > 0 { last := all[len(all)-1] out.Next = last.Updated + "~" + last.ID } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[ticketGetIn, ticketGetOut]{ Name: "ticket.get", Doc: "Fetch one ticket: folded state plus change history.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/ticket/{id}", Run: func(_ context.Context, in ticketGetIn) (ticketGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return ticketGetOut{}, err } t, ok, err := repo.Index.TicketByPrefix(in.ID) if err != nil { return ticketGetOut{}, err } if !ok { return ticketGetOut{}, fmt.Errorf("no unique ticket for %q: %w", in.ID, ops.ErrNotFound) } return ticketGetOut{Ticket: t}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[searchIn, searchOut]{ Name: "search.query", Doc: "Full-text search over a repository's check-ins, wiki, and tickets.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/search", Run: func(_ context.Context, in searchIn) (searchOut, error) { repo, err := d.repo(in.Repo) if err != nil { return searchOut{}, err } results, err := repo.Index.Search(in.Q, in.Limit) if err != nil { return searchOut{}, err } return searchOut{Results: results, Count: len(results)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[diffGetIn, diffGetOut]{ Name: "checkin.diff", Doc: "Diff one check-in against its primary parent.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/checkin/{hash}/diff", Run: func(_ context.Context, in diffGetIn) (diffGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return diffGetOut{}, err } e, parents, ok, err := repo.Index.CheckinByPrefix(in.Hash) if err != nil { return diffGetOut{}, err } if !ok { return diffGetOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound) } out := diffGetOut{To: e.Hash} if len(parents) == 0 { // A root check-in has nothing to diff against; its // whole file set is the change. files, err := repo.Zit.Ls(e.Hash) if err != nil { return diffGetOut{}, err } for _, f := range files { out.Files = append(out.Files, zitcli.DiffEntry{Status: "A", Name: f.Name}) } return out, nil } out.From = parents[0] unified, brief, err := repo.Zit.Diff(out.From, e.Hash) if err != nil { return diffGetOut{}, err } out.Unified, out.Files = unified, brief // zit exits 0 with empty output when changed content is // unavailable (shunned) — flag it rather than serving what // looks like a legitimate no-op diff. if out.Unified == "" && len(out.Files) > 0 { out.Note = "diff content unavailable — the changed file content may have been shunned from the store" } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[fileGetIn, fileGetOut]{ Name: "file.get", Doc: "Fetch one file's content at a check-in.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/file/{hash}/{path...}", Run: func(_ context.Context, in fileGetIn) (fileGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return fileGetOut{}, err } e, _, ok, err := repo.Index.CheckinByPrefix(in.Hash) if err != nil { return fileGetOut{}, err } if !ok { return fileGetOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound) } raw, err := repo.Zit.Cat(in.Path, e.Hash) if errors.Is(err, zitcli.ErrNoEntry) { // Clean public message; the zit detail stays behind. return fileGetOut{}, fmt.Errorf("no file %q at this check-in: %w", in.Path, ops.ErrNotFound) } if err != nil { return fileGetOut{}, err } out := fileGetOut{Hash: e.Hash, Path: in.Path, Size: len(raw)} // Binary content stays out of the JSON surface; the page // says "binary" and the artifact read verb serves bytes. if utf8.Valid(raw) && !bytes.ContainsRune(raw, 0) { out.Content = string(raw) // Best-effort highlighting; sanitized like all rendered // HTML even though quarry generated it. if hl, ok := highlight.Code(raw, "", in.Path); ok { out.HTML = markup.Sanitize(hl) } } else { out.Binary = true } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[checkinGetIn, checkinGetOut]{ Name: "checkin.get", Doc: "Describe one check-in: metadata, parents, and its file set.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/checkin/{hash}", Run: func(_ context.Context, in checkinGetIn) (checkinGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return checkinGetOut{}, err } e, parents, ok, err := repo.Index.CheckinByPrefix(in.Hash) if err != nil { return checkinGetOut{}, err } if !ok { return checkinGetOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound) } out := checkinGetOut{ Hash: e.Hash, Branch: e.Branch, Date: e.Date, User: e.User, Comment: e.Comment, Parents: parents, } // File set is an on-demand zit read (0001 §2), not index // state — the index answers "which check-in", the store // answers "what does it hold". if repo.Zit != nil { files, err := repo.Zit.Ls(e.Hash) if err != nil { return checkinGetOut{}, err } out.Files = files } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[wikiHistoryIn, wikiHistoryOut]{ Name: "wiki.history", Doc: "List every recorded edit of one wiki page, newest first.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/wiki/{name}/history", Run: func(_ context.Context, in wikiHistoryIn) (wikiHistoryOut, error) { repo, err := d.repo(in.Repo) if err != nil { return wikiHistoryOut{}, err } edits, err := repo.Index.WikiHistory(in.Name) if err != nil { return wikiHistoryOut{}, err } if len(edits) == 0 { return wikiHistoryOut{}, fmt.Errorf("no wiki page %q: %w", in.Name, ops.ErrNotFound) } return wikiHistoryOut{Title: in.Name, Edits: edits, Count: len(edits)}, nil }, }); err != nil { return err } return ops.Register(r, ops.Op[wikiGetIn, wikiGetOut]{ Name: "wiki.get", Doc: "Fetch one wiki page, rendered — latest, or the revision named by rev.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/wiki/{name}", Run: func(_ context.Context, in wikiGetIn) (wikiGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return wikiGetOut{}, err } page, ok, err := repo.Index.WikiRevision(in.Name, in.Rev) if err != nil { return wikiGetOut{}, err } if !ok { return wikiGetOut{}, fmt.Errorf("no wiki page %q rev %q: %w", in.Name, in.Rev, ops.ErrNotFound) } var rendered []byte switch page.Mimetype { case "", "text/x-markdown", "text/markdown": rendered, err = markup.Render(page.Body, resolver{ix: repo.Index, repo: in.Repo}) if err != nil { return wikiGetOut{}, err } default: // Non-markdown wiki content renders as escaped text. rendered = []byte("<pre>" + html.EscapeString(string(page.Body)) + "</pre>") } return wikiGetOut{ Hash: page.Hash, Title: page.Title, Date: page.Date, User: page.User, Mimetype: page.Mimetype, Source: string(page.Body), HTML: string(rendered), }, nil }, }) }