// chatbridge.go is RFC 0011 §9's configuration surface: which
// repository's channel is bridged to which Fossil server's chat, and
// under whose login.
//
// The bridge is repository configuration, so it is `a` and Admin, the
// same standing every other mirror op takes. The credential goes in
// and never comes back: there is no field on any Out here that could
// carry it, which is the only way to be sure a page never renders it.
package api
import (
"context"
"craftdesign.group/quarry/internal/outbound"
"errors"
"fmt"
"net/url"
"strings"
"craftdesign.group/quarry/internal/ops"
)
// ChatBridge is one bridged channel as every caller but the poller
// sees it. No secret, by construction.
type ChatBridge struct {
Repo string `json:"repo"`
URL string `json:"url"`
User string `json:"user"`
// LastMsgID is the upstream chat message the poller has read
// through; At is when one last arrived and LastErr why the last
// poll failed, kept apart so "quiet" and "broken" read differently.
LastMsgID int64 `json:"lastMsgId"`
At string `json:"at,omitempty"`
LastErr string `json:"lastErr,omitempty"`
// Stopped is when the bridge stopped itself because chat was given
// to more than named people (ChatOpenTo); empty while it may run.
Stopped string `json:"stopped,omitempty"`
}
// State is the one word a status line needs: a bridge that has never
// heard anything and has no error is still connecting.
func (b ChatBridge) State() string {
switch {
case b.Stopped != "":
return "stopped"
case b.LastErr != "":
return "failing"
case b.At != "":
return "connected"
default:
return "connecting"
}
}
// ChatBridgeStore is the bridge's configuration. Stated in this
// package's own terms for the same reason MirrorStore is: the op layer
// names what it needs. nil disables the op, which is a forge with no
// OLTP — and therefore no bridge to configure.
type ChatBridgeStore interface {
SetChatBridge(repo, url, user, secret string) error
DetachChatBridge(repo string) error
ChatBridgeFor(repo string) (ChatBridge, bool, error)
ChatBridges() ([]ChatBridge, error)
// ChatOpenTo names the categories that give the repository's chat
// to people nobody named ("nobody", "anonymous"); a bridge needs
// it empty.
ChatOpenTo(repo string) []string
}
type chatBridgeIn struct {
Repo string `json:"repo" validate:"required,max=64"`
// URL is the upstream fossil's base URL — the one its /chat page
// lives under, not the /chat-poll endpoint.
URL string `json:"url" validate:"max=512"`
User string `json:"user" validate:"max=64"`
Password string `json:"password" validate:"max=256"`
// Detach removes the bridge and forgets its cursor.
Detach bool `json:"detach"`
}
type chatBridgeOut struct {
// Bridge is the configuration as stored — a value rather than a
// pointer because the ops spec reflector documents only concrete
// shapes, and Detached already says when there is nothing to read.
Bridge ChatBridge `json:"bridge"`
Detached bool `json:"detached,omitempty"`
}
func registerChatBridge(r *ops.Registry, d Deps) error {
return ops.Register(r, ops.Op[chatBridgeIn, chatBridgeOut]{
Name: "mirror.chatbridge",
Caps: "a",
Doc: "Bridge a repository's chat channel to a Fossil server's chat, or detach it. The password is stored and never returned. The repository's own admin's call. Refused while a category gives the channel's chat (C) to every visitor or anyone signed in: a bridge carries another server's chat, so only people named by a role or a team may read it.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/mirror/chatbridge",
Run: func(ctx context.Context, in chatBridgeIn) (chatBridgeOut, error) {
if d.ChatBridges == nil {
return chatBridgeOut{}, fmt.Errorf("the chat bridge is not configured: %w", ops.ErrUnavailable)
}
// The store must be one quarry serves, for the reason
// mirror.set checks the same thing: a row for a name that
// resolves to nothing is a bridge the scheduler skips for
// ever, which reads as "bridging is broken".
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return chatBridgeOut{}, err
}
if err := d.refuseWithout(ctx, in.Repo, "a", false); err != nil {
return chatBridgeOut{}, err
}
if in.Detach {
if err := d.ChatBridges.DetachChatBridge(in.Repo); err != nil {
return chatBridgeOut{}, err
}
return chatBridgeOut{Detached: true}, nil
}
if open := d.ChatBridges.ChatOpenTo(in.Repo); len(open) > 0 {
return chatBridgeOut{}, fmt.Errorf("%w: chat on %s is open to %s; close it to named people before bridging it",
ops.ErrInvalid, in.Repo, openWords(open))
}
if in.URL == "" || in.User == "" || in.Password == "" {
return chatBridgeOut{}, fmt.Errorf(
"a chat bridge needs a url, a user and a password: %w", ops.ErrInvalid)
}
// Checked HERE and not only in the store: the op is the
// forge's own answer about what it will accept, and a
// refusal that depends on which store is wired is a rule
// that holds in production and not in a test.
in.URL = bridgeBase(in.URL)
if err := checkBridgeURL(in.URL); err != nil {
return chatBridgeOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
}
if err := d.ChatBridges.SetChatBridge(in.Repo, in.URL, in.User, in.Password); err != nil {
return chatBridgeOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
}
// The bridge signs in as this login with the setter's
// password, so what that login writes upstream is theirs.
d.linkBridgeLogin(in.Repo, in.User, author(ctx))
b, ok, err := d.ChatBridges.ChatBridgeFor(in.Repo)
if err != nil || !ok {
return chatBridgeOut{}, err
}
return chatBridgeOut{Bridge: b}, nil
},
})
}
// openWords says who a category list opens chat to, in the settings
// page's words.
func openWords(open []string) string {
words := make([]string, 0, len(open))
for _, c := range open {
switch c {
case "nobody":
words = append(words, "every visitor")
case "anonymous":
words = append(words, "anyone signed in")
default:
words = append(words, c)
}
}
return strings.Join(words, " and ")
}
type chatBridgeGetIn struct {
Repo string `json:"repo" validate:"required,max=64"`
}
type chatBridgeGetOut struct {
Bridge ChatBridge `json:"bridge"`
Bridged bool `json:"bridged"`
// State is the bridge's one word: connecting, connected, failing
// or stopped; empty when there is no bridge.
State string `json:"state"`
// OpenTo names the categories that give this channel's chat to
// people nobody named. Non-empty, a bridge is refused, or stopped.
OpenTo []string `json:"openTo"`
}
func registerChatBridgeGet(r *ops.Registry, d Deps) error {
return ops.Register(r, ops.Op[chatBridgeGetIn, chatBridgeGetOut]{
Name: "mirror.chatbridge.get",
Caps: "a",
Doc: "A repository's chat bridge as its settings show it: the bridge if there is one (never its password), its state, and which categories give the channel's chat to people nobody named (`nobody`, `anonymous`), which must be none for a bridge to run.",
Kind: ops.Query,
Auth: ops.Token,
Route: "GET /api/v1/r/{repo}/mirror/chatbridge",
Run: func(ctx context.Context, in chatBridgeGetIn) (chatBridgeGetOut, error) {
if d.ChatBridges == nil {
return chatBridgeGetOut{}, fmt.Errorf("the chat bridge is not configured: %w", ops.ErrUnavailable)
}
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return chatBridgeGetOut{}, err
}
if err := d.refuseWithout(ctx, in.Repo, "a", false); err != nil {
return chatBridgeGetOut{}, err
}
out := chatBridgeGetOut{OpenTo: d.ChatBridges.ChatOpenTo(in.Repo)}
if out.OpenTo == nil {
out.OpenTo = []string{}
}
b, ok, err := d.ChatBridges.ChatBridgeFor(in.Repo)
if err != nil {
return chatBridgeGetOut{}, err
}
if ok {
out.Bridge, out.Bridged, out.State = b, true, b.State()
}
return out, nil
},
})
}
// bridgeOf answers one repository's bridge for a listing, false when
// it has none or the forge keeps no bridges at all.
func (d Deps) bridgeOf(repo string) (ChatBridge, bool) {
if d.ChatBridges == nil {
return ChatBridge{}, false
}
b, ok, err := d.ChatBridges.ChatBridgeFor(repo)
if err != nil || !ok {
return ChatBridge{}, false
}
return b, true
}
// bridgeBase is the repository's base URL for an address somebody
// copied from their browser while looking at the chat: the bridge
// appends /chat-poll and /chat-send itself, so ".../r/libfossil/chat"
// polled ".../chat/chat-poll", which fossil answers with its login page
// (libfossil's bridge, 2026-11-02). Anything else is left as given.
func bridgeBase(raw string) string {
s := strings.TrimRight(strings.TrimSpace(raw), "/")
if u, err := url.Parse(s); err == nil && u.RawQuery == "" && u.Fragment == "" {
s = strings.TrimSuffix(s, "/chat")
}
return s
}
// checkBridgeURL is the forge's rule for an upstream it will fetch on
// its own initiative: http(s) only, and no credentials in the URL —
// those go in the user and password fields, where exactly one reader
// sees them instead of every log line that names the upstream.
func checkBridgeURL(raw string) error {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return errors.New("chat bridge url is not a url")
}
if _, err := outbound.Check(raw); err != nil {
return errors.New("chat bridge url " + err.Error())
}
if u.User != nil {
return errors.New("chat bridge url must not carry a user or password; " +
"give them as the user and password fields")
}
return nil
}