// refs.go resolves reference tokens (RFC 0011 §14). The grammar lives
// in markup, which knows no index and no capabilities; this file is the
// half that does — it turns a parsed token into a URL and, for
// ref.resolve, into the handful of facts a preview card shows.
//
// Cross-repository tokens resolve only when the reader may read the
// target repository AND holds the capability the target's own op
// requires. That is not a second policy: it is the SAME letter, reached
// for here rather than re-decided, so a preview can never say more than
// the page it links to would.
package api
import (
"context"
"fmt"
"strconv"
"strings"
"craftdesign.group/quarry/internal/chat"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/markup"
"craftdesign.group/quarry/internal/ops"
)
// refCap is the capability letter a reference's target kind requires —
// the letter the target's own op is registered with. A kind not listed
// needs none (a person page is forge-wide, not per-repository).
func refCap(kind string) string {
switch kind {
case markup.RefChat:
return "C"
case markup.RefCheckin, markup.RefHash:
return "o"
case markup.RefTicket:
return "r"
case markup.RefLanding, markup.RefForum:
return "2"
case markup.RefWiki:
return "j"
}
return ""
}
// refScope is what a CROSS-repository reference needs: who is reading
// and the seams that answer whether they may see the target. A nil
// scope resolves only same-repository tokens, which is the right answer
// for every render path that has no request in hand.
type refScope struct {
ctx context.Context
d Deps
}
// refResolver builds the markup resolver that speaks the whole
// grammar, cross-repository tokens included.
func (d Deps) refResolver(ctx context.Context, ix *index.Index, repo string) markup.Resolver {
return d.refTokenResolver(ctx, ix, repo)
}
// refTokenResolver is the same resolver, typed: the backlink writer
// needs the canonical name a render pass already resolved, and
// markup.Resolver cannot hand it back.
func (d Deps) refTokenResolver(ctx context.Context, ix *index.Index, repo string) resolver {
return resolver{ix: ix, repo: repo, scope: &refScope{ctx: ctx, d: d}, memo: map[string]string{}}
}
// target names the repository and index a token points into, refusing
// any token — cross-repository OR same-repository — whose target the
// reader may not follow. The addressed repository gets no discount:
// `demo:ticket/x` and `ticket/x` are the same reference, and a reader
// refused one spelling must be refused the other.
func (r resolver) target(t markup.RefToken) (repo string, ix *index.Index, kind string, ok bool) {
if t.Repo == "" || t.Repo == r.repo {
kind = followKind(r.ix, t)
if !r.mayFollow(kind, r.repo) {
return "", nil, "", false
}
return r.repo, r.ix, kind, true
}
if r.scope == nil {
return "", nil, "", false
}
d, ctx := r.scope.d, r.scope.ctx
other, err := d.repoFor(ctx, t.Repo)
if err != nil {
return "", nil, "", false
}
kind = followKind(other.Index, t)
if !r.mayFollow(kind, t.Repo) {
return "", nil, "", false
}
return t.Repo, other.Index, kind, true
}
// followKind is the kind the capability test must run against. The
// grammar's kind is the answer for every token but a bare hash, which
// only the index can classify: `[a91f3c2e]` may name a check-in, a
// ticket OR a forum post, and checking `o` because the grammar said
// "hash" hands a ticket's title to a reader holding only the check-in
// letter. A hash the index does not know keeps its own kind, and
// resolves to nothing anyway.
func followKind(ix *index.Index, t markup.RefToken) string {
if t.Kind != markup.RefHash || ix == nil {
return t.Kind
}
return hashKind(ix, t.ID)
}
// mayFollow is the ONE capability test every branch of target reaches
// for: the letter the target's own op is registered with, checked
// against the reader's caps on the TARGET repository.
//
// A scopeless resolver has no request in hand and answers yes: it is
// the render path of a page whose own op already gated the reader, and
// it can never cross repositories (target refuses that above).
func (r resolver) mayFollow(kind, repo string) bool {
if r.scope == nil {
return true
}
d, ctx := r.scope.d, r.scope.ctx
if d.CapsFor == nil {
return true
}
if kind == markup.RefPerson {
// A person card says what an account may do here — the same
// fact people.mention is gated on, and `h` is never anonymous.
// A signed-out reader gets the not-found a stranger's handle
// would give, so the card leaks neither caps nor existence.
return ops.User(ctx) != "" && d.CapsFor(author(ctx), repo).HasAny("h")
}
letter := refCap(kind)
return letter == "" || d.CapsFor(author(ctx), repo).HasAny(letter)
}
// ResolveToken is the markup.TokenResolver half of the seam: a URL for
// a token whose target this reader may see, and false for everything
// else — which leaves the token as the sentence its author wrote.
func (r resolver) ResolveToken(t markup.RefToken) (string, bool) {
repo, ix, kind, ok := r.target(t)
if !ok || repo == "" {
return "", false
}
base := "/r/" + repo
switch t.Kind {
case markup.RefChat:
// A chat sequence resolves without a lookup: the permalink page
// is honest about a message the window has dropped, which is a
// better answer than refusing to link at all.
return base + "/chat/" + t.ID, true
case markup.RefHash:
sub := resolver{ix: ix, repo: repo}
if u := sub.ResolveHash(t.ID); u != "" {
return u, true
}
return "", false
case markup.RefPerson:
return "/people/" + t.ID, true
}
// Everything else names an artifact, and an artifact the index has
// never heard of is not a reference: a mistyped hash is a sentence
// the author wrote, not a link to a page that would 404.
id, ok := r.canonicalIn(repo, ix, kind, t.ID)
if !ok {
return "", false
}
return base + "/" + t.Kind + "/" + id, true
}
// canonical answers the artifact's OWN name for a token this reader may
// follow, a bare hash's kind resolved on the way. It is the ONE
// resolution the render pass and the backlink writer share: a row must
// be keyed by exactly the name the link beside it points at, and a
// second copy of the lookup is a second chance to drift.
func (r resolver) canonical(t markup.RefToken) (string, bool) {
repo, ix, kind, ok := r.target(t)
if !ok || repo == "" {
return "", false
}
return r.canonicalIn(repo, ix, kind, t.ID)
}
// canonicalIn is canonicalTarget with the resolver's memo in front of
// it. A message's references are resolved twice — once to render the
// link, once to key the row — and the second pass reads the first
// pass's answer rather than asking the index again. A resolver with no
// memo (every literal outside refResolver) simply asks.
func (r resolver) canonicalIn(repo string, ix *index.Index, kind, id string) (string, bool) {
key := repo + ":" + kind + "/" + id
if name, seen := r.memo[key]; seen {
return name, name != ""
}
name, ok := canonicalTarget(ix, markup.RefToken{Kind: kind, ID: id})
if !ok {
name = ""
}
if r.memo != nil {
r.memo[key] = name
}
return name, name != ""
}
// canonicalTarget resolves an artifact token against the index and
// answers the artifact's OWN name — the full check-in hash, the ticket
// id, the thread root, the wiki title. Pages key their backlink rows by
// that name, so a reference typed as a prefix must be recorded under it
// or the row lands where nothing looks for it.
func canonicalTarget(ix *index.Index, t markup.RefToken) (string, bool) {
if ix == nil {
return "", false
}
switch t.Kind {
case markup.RefWiki:
if p, ok, err := ix.WikiPage(t.ID); err == nil && ok {
return p.Title, true
}
case markup.RefTicket:
if tk, ok, err := ix.TicketByPrefix(t.ID); err == nil && ok {
return tk.ID, true
}
case markup.RefCheckin:
if e, _, ok, err := ix.CheckinByPrefix(t.ID); err == nil && ok {
return e.Hash, true
}
case markup.RefLanding:
if l, ok, err := ix.LandingByPrefix(t.ID); err == nil && ok {
return l.ID, true
}
case markup.RefForum:
// A forum token may name a post OR its thread; the thread root
// is what a thread page shows and what a row must key on.
if _, thread, ok, err := ix.ForumPostThread(t.ID); err == nil && ok {
return thread, true
}
if _, _, ok, err := ix.ForumThreadPosts(t.ID); err == nil && ok {
return t.ID, true
}
}
return "", false
}
// refsIn lists a body's resolvable references as canonical tokens.
func refsIn(res markup.Resolver, text string) []string {
tr, ok := res.(markup.TokenResolver)
if !ok {
return nil
}
var out []string
for _, t := range markup.RefsIn(text, tr) {
out = append(out, t.Canonical())
}
return out
}
type refResolveIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Ref string `json:"ref" validate:"required,max=512"`
}
// refResolveOut is the preview card's data (RFC 0011 §14). Every field
// is optional but Kind and Href: a card says what it knows and stays
// quiet about the rest rather than printing empty labels.
type refResolveOut struct {
Kind string `json:"kind"`
ID string `json:"id"`
Repo string `json:"repo"`
Title string `json:"title,omitempty"`
Summary string `json:"summary,omitempty"`
State string `json:"state,omitempty"`
Href string `json:"href"`
// Expired marks a chat reference the window has dropped — the
// honest answer, not a 404 that reads like a typo.
Expired bool `json:"expired,omitempty"`
Author string `json:"author,omitempty"`
At string `json:"at,omitempty"`
}
func registerRefResolve(r *ops.Registry, d Deps) error {
return ops.Register(r, ops.Op[refResolveIn, refResolveOut]{
Name: "ref.resolve",
// No Caps here: the letter this needs depends on what the
// token names, and the router checks one fixed letter against
// the ADDRESSED repository. Run does the real check — the
// target's own letter against the target's repository — the
// same way chat.unread does its own per-repo test.
Caps: "",
Doc: "Resolve one reference token to the facts a preview card shows.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/ref",
Run: func(ctx context.Context, in refResolveIn) (refResolveOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return refResolveOut{}, err
}
tok, ok := markup.ParseRef(in.Ref)
if !ok {
return refResolveOut{}, fmt.Errorf("not a reference: %w", ops.ErrInvalid)
}
res := resolver{ix: repo.Index, repo: in.Repo, scope: &refScope{ctx: ctx, d: d}}
// No capability test here: ResolveToken runs one, on every
// branch, against the TARGET repository. A second test in
// this file is a second policy, and the two would drift.
href, ok := res.ResolveToken(tok)
if !ok {
return refResolveOut{}, fmt.Errorf("no reference %q: %w", in.Ref, ops.ErrNotFound)
}
targetRepo, ix, kind, _ := res.target(tok)
out := refResolveOut{Kind: tok.Kind, ID: tok.ID, Repo: targetRepo, Href: href}
d.describeRef(ctx, &out, ix, targetRepo, tok, kind)
return out, nil
},
})
}
// describeRef fills the card. Each kind is one to three facts and no
// more (RFC 0011 §14: "one to three lines and an open →"); a diff is
// never one of them.
// kind is what target already resolved the token to — a bare hash's
// real kind, and the SAME kind the capability test ran against. Asking
// the index again here would re-derive a kind nothing gated.
func (d Deps) describeRef(ctx context.Context, out *refResolveOut, ix *index.Index, repo string, tok markup.RefToken, kind string) {
switch tok.Kind {
case markup.RefChat:
d.describeChatRef(ctx, out, repo, tok)
return
case markup.RefPerson:
out.Title = tok.ID
if d.CapsFor != nil {
out.Summary = capLetters(d.CapsFor(tok.ID, repo))
}
return
}
if ix == nil {
return
}
if kind == "" {
kind = tok.Kind
}
out.Kind = kind
if id, ok := canonicalTarget(ix, markup.RefToken{Kind: kind, ID: tok.ID}); ok {
out.ID = id
}
switch kind {
case markup.RefCheckin:
if e, _, ok, err := ix.CheckinByPrefix(tok.ID); err == nil && ok {
out.ID, out.Title = e.Hash, firstLine(e.Comment)
out.Summary, out.Author, out.At = e.Branch, e.User, e.Date
out.State = e.CI
}
case markup.RefTicket:
if t, ok, err := ix.TicketByPrefix(tok.ID); err == nil && ok {
out.ID, out.At = t.ID, t.Updated
out.Title = ticketFieldValue(t, "title")
out.State = ticketFieldValue(t, "status")
}
case markup.RefLanding:
if l, ok, err := ix.LandingByPrefix(tok.ID); err == nil && ok {
out.ID, out.Title, out.State = l.ID, l.Title, l.State
out.Summary = l.Branch + " → " + l.Target
out.Author, out.At = l.Author, l.Updated
}
case markup.RefForum:
if title, posts, ok, err := ix.ForumThreadPosts(tok.ID); err == nil && ok {
out.Title = title
out.Summary = strconv.Itoa(max(0, len(posts)-1)) + " replies"
if n := len(posts); n > 0 {
out.Author, out.At = posts[n-1].User, posts[n-1].Date
}
}
case markup.RefWiki:
if p, ok, err := ix.WikiPage(tok.ID); err == nil && ok {
out.Title, out.Author, out.At = p.Title, p.User, p.Date
out.Summary = firstSentence(string(p.Body))
}
}
}
// describeChatRef reads the referenced message out of the window, and
// says so plainly when the window no longer holds it.
func (d Deps) describeChatRef(ctx context.Context, out *refResolveOut, repo string, tok markup.RefToken) {
if d.Chat == nil {
return
}
seq, err := strconv.ParseUint(tok.ID, 10, 64)
if err != nil {
return
}
r, err := d.repoFor(ctx, repo)
if err != nil {
return
}
// A BOUNDED window, not the rest of the channel. A hover is
// anonymous and constant; reading to the tip on each one is a
// channel drain, not a preview. The span is wide enough to catch
// the edit and delete tombstones that follow a message in ordinary
// use, and a tombstone published beyond it costs a stale card —
// which is what a stale card costs, and not an unbounded read.
msgs, err := d.Chat.Range(r.Code, seq, seq+chatFoldSpan)
if err != nil {
return
}
for _, m := range chat.Fold(msgs) {
if m.Seq == seq {
out.Author, out.At = m.User, m.At
out.Title = firstLine(m.Text)
break
}
}
if out.Author == "" {
if first, _, berr := d.Chat.Bounds(r.Code); berr == nil && first > 0 && seq < first {
out.Expired = true
}
return
}
// The reply count is a row, written at post time and pruned with
// the window — the SAME source chat.get counts from, so the card
// and the page it previews cannot disagree.
out.Summary = strconv.Itoa(d.chatReplyCount(repo, seq)) + " replies"
}
// chatFoldSpan bounds the tombstone lookahead behind a preview card.
const chatFoldSpan uint64 = 200
// chatReplyCount reads the reply count from the backlink store, which
// is a row lookup. A forge with no store answers zero rather than
// scanning: a missing count is a quieter wrong answer than a drain.
func (d Deps) chatReplyCount(repo string, seq uint64) int {
if d.ChatRefs == nil {
return 0
}
n, err := d.ChatRefs.ChatReplyCount(repo, seq)
if err != nil {
return 0
}
return n
}
// hashKind asks the index what an artifact name actually is. The
// grammar cannot know — a bare hash may be a check-in, a ticket or a
// forum post — and a card that guessed would be wrong a third of the
// time.
func hashKind(ix *index.Index, prefix string) string {
if _, _, ok, err := ix.CheckinByPrefix(prefix); err == nil && ok {
return markup.RefCheckin
}
if _, ok, err := ix.TicketByPrefix(prefix); err == nil && ok {
return markup.RefTicket
}
if _, _, ok, err := ix.ForumPostThread(prefix); err == nil && ok {
return markup.RefForum
}
return markup.RefHash
}
// capLetters reads a CapSet back as its letters. The seam answers only
// "do you have any of these" (the router needs nothing more), so the
// set is probed one letter at a time — a person card says what someone
// may do here, and "yes to something" is not that answer.
func capLetters(set CapSet) string {
if set == nil {
return ""
}
var b strings.Builder
for _, c := range anyCapLetters {
if set.HasAny(string(c)) {
b.WriteRune(c)
}
}
return b.String()
}
func ticketFieldValue(t index.TicketState, name string) string {
for _, f := range t.Fields {
if strings.EqualFold(f.Name, name) {
return f.Value
}
}
return ""
}
func firstLine(s string) string {
line, _, _ := strings.Cut(strings.TrimSpace(s), "\n")
return line
}
func firstSentence(s string) string {
line := firstLine(s)
if i := strings.Index(line, ". "); i > 0 {
return line[:i+1]
}
return line
}
type peopleMentionIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Q string `json:"q" validate:"max=64"`
}
// mentionHandle is one completion: the handle to insert and what that
// account may do here, so a composer can say who it is about to name.
type mentionHandle struct {
Handle string `json:"handle"`
Caps string `json:"caps"`
}
type peopleMentionOut struct {
Handles []mentionHandle `json:"handles"`
}
// mentionLimit is the RFC's ≤10: a completion list longer than a glance
// is a list nobody reads.
const mentionLimit = 10
func registerPeopleMention(r *ops.Registry, d Deps) error {
return ops.Register(r, ops.Op[peopleMentionIn, peopleMentionOut]{
Name: "people.mention",
Caps: "h",
Doc: "Handles with any capability on this repository, for @ completion.",
Kind: ops.Query,
Auth: ops.Token,
Route: "GET /api/v1/r/{repo}/mention",
Run: func(ctx context.Context, in peopleMentionIn) (peopleMentionOut, error) {
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return peopleMentionOut{}, err
}
out := peopleMentionOut{Handles: []mentionHandle{}}
if d.Accounts == nil {
return out, nil
}
accts, err := d.Accounts.Accounts()
if err != nil {
return peopleMentionOut{}, err
}
q := strings.ToLower(in.Q)
for _, a := range accts {
if len(out.Handles) >= mentionLimit {
break
}
if q != "" && !strings.Contains(strings.ToLower(a.Name), q) {
continue
}
// The SAME test mentions themselves use (RFC 0011 §4):
// completing a handle that would render as plain text
// would teach people to type dead mentions.
caps := ""
if d.CapsFor != nil {
set := d.CapsFor(a.Name, in.Repo)
if !set.HasAny(anyCapLetters) {
continue
}
caps = capLetters(set)
}
out.Handles = append(out.Handles, mentionHandle{Handle: a.Name, Caps: caps})
}
return out, nil
},
})
}