agentbbs/plugins/qryptinvite/qryptinvite.go
Anthony Ettinger e0a267e343
Fix/pod public html bind mount (#30)
* feat(pods/admin): root admin alias, default-caps cleanup, pod rebuild script

- auth: add `root` as an admin-console route alias (alongside admin/sysop);
  still gated by $AGENTBBS_ADMINS — the name confers nothing on its own.
- pods: drop the now-redundant tuneApt apt-sandbox hack. Rootless podman keeps
  its default capability set, so apt/chown/su work without disabling the
  download sandbox.
- scripts/rebuild-pods.sh: recreate all member pods (keeps home volumes) so
  they pick up the current container profile on next `ssh pod@`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(arcade,ui): 80s arcade classics + shared menu theme

Arcade games (PRD §5.1), generalizing the sandboxed-PTY DOOM path into an
external-game registry: Space Invaders (nInvaders), Pac-Man (pacman4console),
Tetris (tint/vitetris), Moon Patrol (moon-buggy). Binaries resolve from
assets/bin, PATH, then /usr/games, so a distro install or a hand-built binary
lights each game up; missing games are skipped with a discovery hint. Installed
on the host via `scripts/fetch-assets.sh --arcade` (apt), wired into setup.sh
behind FETCH_ARCADE (default on).

UI: new shared ui.Theme.MenuItem widget (accent cursor + badge, description
shown only for the focused row) adopted by the hub and arcade menus; the hub
groups rows under Features/Sessions headers and the arcade under
DOOM/ARCADE/BUILT-IN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 08:06:32 -07:00

111 lines
3.7 KiB
Go

// Package qryptinvite is the hub plugin that lets an authenticated member mint
// a single-use qrypt.chat anonymous invite. AgentBBS is the trusted issuer: the
// plugin signs a token with the operator's Ed25519 key, records it against the
// member's per-account quota, and prints the token + redeem URL. The separate
// qrypt.chat app verifies the signature and burns the jti on redeem.
//
// See internal/qryptinvite for the token format and docs/qrypt-invites.md.
package qryptinvite
import (
"errors"
"strconv"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/plugin"
qi "github.com/profullstack/agentbbs/internal/qryptinvite"
"github.com/profullstack/agentbbs/internal/store"
"github.com/profullstack/agentbbs/internal/ui"
)
// Plugin is the hub registration. It admits members only (guests have no
// account to quota against).
type Plugin struct{}
func (Plugin) ID() string { return "qrypt-invite" }
func (Plugin) Title() string { return "qrypt.chat invite" }
func (Plugin) Description() string { return "Mint an anonymous qrypt.chat signup invite" }
func (Plugin) RequiresAuth() bool { return true }
func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model {
cfg := qi.ConfigFromEnv()
m := model{user: user, store: ctx.Store, cfg: cfg}
m.issue() // do the work once up front; the view just reports the result
return m
}
type model struct {
user auth.User
store store.Store
cfg qi.Config
body string // rendered result, ready to display
}
// issue runs the full flow: check quota, mint, record, build the output.
func (m *model) issue() {
if m.user.Kind == auth.Guest || m.user.Name == "" {
m.body = errStyle.Render("Sign in with your SSH key to mint an invite.")
return
}
priv, err := m.cfg.PrivateKey()
if err != nil {
m.body = errStyle.Render("Invites are not configured on this host yet.\n") +
dStyle.Render(" ("+err.Error()+")")
return
}
used, err := m.store.QryptInviteCount(m.user.Name)
if err != nil {
m.body = errStyle.Render("Couldn't read your invite count: " + err.Error())
return
}
if m.cfg.Quota > 0 && used >= m.cfg.Quota {
m.body = errStyle.Render("You've used all your invites ") +
dStyle.Render("("+strconv.Itoa(used)+"/"+strconv.Itoa(m.cfg.Quota)+"). Ask an operator for more.")
return
}
token, jti, err := qi.Mint(m.cfg.IssuerID, priv, m.cfg.TTL)
if err != nil {
m.body = errStyle.Render("Mint failed: " + err.Error())
return
}
if err := m.store.RecordQryptInvite(m.user.Name, jti, m.cfg.Quota); err != nil {
if errors.Is(err, store.ErrQuotaExceeded) {
m.body = errStyle.Render("You've used all your invites. Ask an operator for more.")
return
}
m.body = errStyle.Render("Couldn't record the invite: " + err.Error())
return
}
remaining := m.cfg.Quota - (used + 1)
m.body = hStyle.Render("Your qrypt.chat anonymous invite") + "\n\n" +
" Redeem at:\n" +
urlStyle.Render(" "+m.cfg.RedeemURLFor(token)) + "\n\n" +
dStyle.Render(" Token (same thing, if you'd rather paste it):") + "\n" +
" " + token + "\n\n" +
dStyle.Render(" Single-use · expires in "+m.cfg.TTL.String()+" · invites left: "+strconv.Itoa(max(remaining, 0)))
}
func (m model) Init() tea.Cmd { return nil }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if _, ok := msg.(tea.KeyMsg); ok {
return m, plugin.Exit
}
return m, nil
}
func (m model) View() string {
return ui.Frame.Render(m.body + "\n\n" + ui.KeyBar("esc/q return to menu"))
}
var (
hStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
dStyle = ui.Dim
errStyle = ui.Danger
urlStyle = lipgloss.NewStyle().Foreground(ui.Blue)
)