AgentBBS: M0 core hub, M1 arcade, pods with CoinPay membership

A modern BBS over SSH for humans and AI agents (docs/PRD.md), plus the
pods addendum (docs/pods.md). Go + charmbracelet (wish/bubbletea).

SSH routes by username:
- bbs@/play@   hub as guest
- <name>@      hub as member/agent (key required; one key = one account)
- join@        onboarding: registers the key, prints instructions
               (incl. coinpay pay command with HMAC payment ref), kicks
- pod@         personal Linux container, paid membership $1/mo via
               CoinPay; rootless podman preferred, hardened docker
               fallback (cap-drop ALL, no-new-privileges, uid 1000,
               cpu/mem/pids caps, per-user volume)

M0: plugin contract (ID/Title/Description/RequiresAuth/New + ExitMsg),
hub menu, SQLite store (users/sessions/scores/pod_subscriptions),
session audit, grant-pod ops command.

M1 arcade: doom-ascii + Freedoom via scripts/fetch-assets.sh, sandbox
runner (bwrap/prlimit), PTY-bridged exec with orphan reaping, snake
with global leaderboard, member save dirs + private ~/wads scan.

Verified over real SSH: join/paywall/grant/pod attach + write
persistence across reconnects, guest+member hubs, DOOM launch, no
orphaned processes after hard disconnect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-11 11:08:17 +00:00
commit f3b085a08f
21 changed files with 2405 additions and 0 deletions

63
internal/auth/auth.go Normal file
View file

@ -0,0 +1,63 @@
// Package auth resolves SSH connections into AgentBBS identities (PRD §4.4).
package auth
import (
"strings"
"github.com/charmbracelet/ssh"
gossh "golang.org/x/crypto/ssh"
)
// Kind classifies an identity.
type Kind string
const (
Guest Kind = "guest"
Member Kind = "member"
Agent Kind = "agent"
)
// User is the resolved identity for one session.
type User struct {
Name string
Kind Kind
PubKeyFP string // SHA256 fingerprint, empty for guests without a key
StoreID int64 // 0 for guests
}
// GuestNames are usernames that always map to an anonymous guest hub session.
var GuestNames = map[string]bool{"bbs": true, "play": true, "guest": true}
// PodNames are usernames that route to a personal pod instead of the hub.
// Pod access requires an active paid membership (PRD pods addendum).
var PodNames = map[string]bool{"pod": true}
// JoinNames are usernames that trigger the onboarding flow: register the
// visitor's public key, print instructions, and disconnect.
var JoinNames = map[string]bool{"join": true, "signup": true, "register": true}
// IsGuestName reports whether the SSH username requests anonymous hub access.
func IsGuestName(u string) bool { return GuestNames[strings.ToLower(u)] }
// IsPodName reports whether the SSH username requests the pod route.
func IsPodName(u string) bool { return PodNames[strings.ToLower(u)] }
// IsJoinName reports whether the SSH username requests onboarding.
func IsJoinName(u string) bool { return JoinNames[strings.ToLower(u)] }
// KindFor infers the identity kind from a (non-guest) username.
// Usernames prefixed "agent-" are automated clients (PRD §3).
func KindFor(username string) Kind {
if strings.HasPrefix(strings.ToLower(username), "agent-") {
return Agent
}
return Member
}
// Fingerprint returns the SHA256 fingerprint for a session public key, or "".
func Fingerprint(key ssh.PublicKey) string {
if key == nil {
return ""
}
return gossh.FingerprintSHA256(key)
}

116
internal/hub/hub.go Normal file
View file

@ -0,0 +1,116 @@
// Package hub is the Bubble Tea model every BBS session lands in (PRD §4.1):
// it lists registered plugins and routes the session to the selection,
// reclaiming it when the plugin emits ExitMsg.
package hub
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/plugin"
)
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
lockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
frameStyle = lipgloss.NewStyle().Padding(1, 2)
)
// Model is the hub menu.
type Model struct {
user auth.User
ctx plugin.Context
plugins []plugin.Plugin
cursor int
active tea.Model
width int
height int
note string
}
// New builds a hub for one session.
func New(user auth.User, ctx plugin.Context, plugins []plugin.Plugin) Model {
return Model{user: user, ctx: ctx, plugins: plugins}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Window size is shared with whichever model is active.
if ws, ok := msg.(tea.WindowSizeMsg); ok {
m.width, m.height = ws.Width, ws.Height
}
// A plugin owns the session until it emits ExitMsg (PRD §4.3).
if m.active != nil {
if _, ok := msg.(plugin.ExitMsg); ok {
m.active = nil
return m, nil
}
next, cmd := m.active.Update(msg)
m.active = next
return m, cmd
}
switch msg := msg.(type) {
case tea.KeyMsg:
m.note = ""
switch msg.String() {
case "q", "ctrl+c", "esc":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.plugins)-1 {
m.cursor++
}
case "enter":
p := m.plugins[m.cursor]
if p.RequiresAuth() && m.user.Kind == auth.Guest {
m.note = "members only — ssh join@ to register"
return m, nil
}
m.active = p.New(m.user, m.ctx)
cmds := []tea.Cmd{m.active.Init()}
if m.width > 0 {
next, cmd := m.active.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
m.active = next
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
}
return m, nil
}
func (m Model) View() string {
if m.active != nil {
return m.active.View()
}
who := fmt.Sprintf("%s (%s)", m.user.Name, m.user.Kind)
s := titleStyle.Render("AgentBBS") + dimStyle.Render(" · "+who) + "\n\n"
for i, p := range m.plugins {
cur := " "
if i == m.cursor {
cur = cursorStyle.Render("> ")
}
label := p.Title()
if p.RequiresAuth() && m.user.Kind == auth.Guest {
label += lockStyle.Render(" [members]")
}
s += fmt.Sprintf("%s%s\n %s\n", cur, label, dimStyle.Render(p.Description()))
}
s += "\n" + dimStyle.Render("↑/↓ move · enter select · q quit")
if m.note != "" {
s += "\n" + lockStyle.Render(m.note)
}
return frameStyle.Render(s)
}

View file

@ -0,0 +1,79 @@
// Package payments gates paid features (the pod subscription, $1/mo) on
// CoinPay — the default LogicSRC payment/DID/wallet plugin.
//
// v1 integration is CLI-shaped: join@ hands the user a `coinpay` command
// carrying a unique payment reference, and verification shells out to the
// coinpay CLI. The exact command templates are env-configurable so the
// deployed CoinPay surface can evolve without a rebuild:
//
// AGENTBBS_COINPAY_PAY_TMPL e.g. "coinpay pay --to profullstack --amount 1 --currency USDC --memo %s"
// AGENTBBS_COINPAY_VERIFY_CMD e.g. "coinpay verify --memo %s" (exit 0 == paid)
//
// Operators can also grant manually: `agentbbs grant-pod <user> --months N`.
package payments
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// PodPriceLabel is the human-readable price for the pod membership.
const PodPriceLabel = "$1/mo"
// PodTerm is how much access one payment buys.
const PodTerm = 31 * 24 * time.Hour
// Reference derives a stable, short payment reference for a user+plan from
// the user's key fingerprint, so CoinPay memos can be reconciled to accounts.
func Reference(plan, pubkeyFP string) string {
mac := hmac.New(sha256.New, []byte("agentbbs."+plan))
mac.Write([]byte(pubkeyFP))
return "abbs-" + plan + "-" + hex.EncodeToString(mac.Sum(nil))[:12]
}
// PayCommand renders the coinpay command a user should run, with the payment
// reference substituted.
func PayCommand(ref string) string {
tmpl := os.Getenv("AGENTBBS_COINPAY_PAY_TMPL")
if tmpl == "" {
tmpl = "coinpay pay --to profullstack --amount 1 --currency USDC --memo %s"
}
if strings.Contains(tmpl, "%s") {
return fmt.Sprintf(tmpl, ref)
}
return tmpl + " " + ref
}
// Verify checks a payment reference against the coinpay CLI. It returns
// (paid, checked): checked is false when no verifier is configured or the
// coinpay binary is unavailable, so callers can fall back to store state.
func Verify(ref string) (paid bool, checked bool) {
tmpl := os.Getenv("AGENTBBS_COINPAY_VERIFY_CMD")
if tmpl == "" {
return false, false
}
var line string
if strings.Contains(tmpl, "%s") {
line = fmt.Sprintf(tmpl, ref)
} else {
line = tmpl + " " + ref
}
parts := strings.Fields(line)
if len(parts) == 0 {
return false, false
}
if _, err := exec.LookPath(parts[0]); err != nil {
return false, false
}
cmd := exec.Command(parts[0], parts[1:]...)
if err := cmd.Run(); err != nil {
return false, true
}
return true, true
}

45
internal/plugin/plugin.go Normal file
View file

@ -0,0 +1,45 @@
// Package plugin defines the AgentBBS plugin contract (PRD §4.3).
//
// A plugin is one interface implementation plus one registration in the hub.
// Plugins return control to the hub by emitting ExitMsg, never by quitting
// the session.
package plugin
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/sandbox"
"github.com/profullstack/agentbbs/internal/store"
)
// Context carries the shared services a plugin may use.
type Context struct {
Store store.Store
Sandbox *sandbox.Runner
// DataDir is the per-user persistent directory (members/agents only;
// empty for guests).
DataDir string
// AssetsDir is the read-only platform assets tree (wads, binaries).
AssetsDir string
}
// Plugin is the only integration point between a feature and the hub.
type Plugin interface {
// ID is a stable unique identifier, e.g. "arcade".
ID() string
// Title is the hub menu label.
Title() string
// Description is a one-line summary shown in the menu.
Description() string
// RequiresAuth reports whether guests are admitted.
RequiresAuth() bool
// New returns a fresh Bubble Tea model for one session.
New(user auth.User, ctx Context) tea.Model
}
// ExitMsg is emitted by a plugin model to hand the session back to the hub.
type ExitMsg struct{}
// Exit is a convenience command for plugins.
func Exit() tea.Msg { return ExitMsg{} }

170
internal/pods/pods.go Normal file
View file

@ -0,0 +1,170 @@
// Package pods gives paid members a personal Linux container over SSH
// (`ssh pod@host`) — "run shit in a docker-like" without root on the host.
//
// Engine preference: rootless Podman (daemonless; container root maps to an
// unprivileged host uid via user namespaces), falling back to Docker with a
// hardened profile (cap-drop ALL, no-new-privileges, non-root user, cpu/mem/
// pids caps). Either way the SSH user never touches the host OS.
package pods
import (
"fmt"
"io"
"os"
"os/exec"
"regexp"
"strings"
"sync"
"github.com/charmbracelet/ssh"
"github.com/creack/pty"
)
// Manager provisions and attaches per-user pods.
type Manager struct {
engine string // "podman" or "docker"
image string
mu sync.Mutex
attached map[string]int // container name -> live session count
}
// Detect picks the best available engine. Returns an error if neither
// podman nor docker is present.
func Detect() (*Manager, error) {
image := os.Getenv("AGENTBBS_POD_IMAGE")
if image == "" {
image = "debian:stable-slim"
}
for _, eng := range []string{"podman", "docker"} {
if _, err := exec.LookPath(eng); err == nil {
return &Manager{engine: eng, image: image, attached: map[string]int{}}, nil
}
}
return nil, fmt.Errorf("pods: neither podman nor docker found")
}
// Engine reports the active container engine.
func (m *Manager) Engine() string { return m.engine }
var unsafeName = regexp.MustCompile(`[^a-zA-Z0-9_.-]`)
func (m *Manager) containerName(user string) string {
return "agentbbs-pod-" + unsafeName.ReplaceAllString(strings.ToLower(user), "-")
}
// ensure creates (or starts) the user's container and returns its name.
func (m *Manager) ensure(user string) (string, error) {
name := m.containerName(user)
if m.engine == "docker" {
// Under docker the pod runs as uid 1000 (never container root), so
// the named home volume must be owned by 1000. A trusted one-shot
// init container enforces that on every ensure — volumes can predate
// the container or survive recreation. Rootless podman doesn't need
// this: container root maps to the unprivileged host user.
init := exec.Command(m.engine, "run", "--rm",
"-v", name+"-home:/home/dev", m.image,
"sh", "-c", "chown 1000:1000 /home/dev")
if out, err := init.CombinedOutput(); err != nil {
return "", fmt.Errorf("pods: volume init failed: %v: %s", err, strings.TrimSpace(string(out)))
}
}
// Already exists?
if err := exec.Command(m.engine, "container", "inspect", name).Run(); err == nil {
_ = exec.Command(m.engine, "start", name).Run() // no-op if running
return name, nil
}
args := []string{
"run", "-d",
"--name", name,
"--hostname", "pod-" + unsafeName.ReplaceAllString(user, "-"),
"--memory", env("AGENTBBS_POD_MEM", "512m"),
"--cpus", env("AGENTBBS_POD_CPUS", "1"),
"--pids-limit", "256",
"--cap-drop", "ALL",
"--security-opt", "no-new-privileges",
"--restart", "unless-stopped",
"-v", name + "-home:/home/dev",
"-w", "/home/dev",
"-e", "HOME=/home/dev",
}
if m.engine == "docker" {
// Rootless podman user-ns maps container root safely; under docker,
// refuse to hand out container root at all.
args = append(args, "--user", "1000:1000")
}
args = append(args, m.image, "sleep", "infinity")
out, err := exec.Command(m.engine, args...).CombinedOutput()
if err != nil {
return "", fmt.Errorf("pods: create failed: %v: %s", err, strings.TrimSpace(string(out)))
}
return name, nil
}
// Attach provisions the pod and wires the SSH session to a shell inside it.
// Blocks until the shell exits or the session closes.
func (m *Manager) Attach(s ssh.Session, user string) error {
ptyReq, winCh, hasPty := s.Pty()
if !hasPty {
return fmt.Errorf("pods: a PTY is required (ssh -t)")
}
name, err := m.ensure(user)
if err != nil {
return err
}
shell := env("AGENTBBS_POD_SHELL", "/bin/bash")
cmd := exec.Command(m.engine, "exec", "-it",
"-e", "TERM="+ptyReq.Term,
name, shell, "-l")
f, err := pty.Start(cmd)
if err != nil {
// busybox-ish images may lack bash
cmd = exec.Command(m.engine, "exec", "-it", "-e", "TERM="+ptyReq.Term, name, "/bin/sh", "-l")
f, err = pty.Start(cmd)
if err != nil {
return fmt.Errorf("pods: attach failed: %w", err)
}
}
defer f.Close()
m.ref(name, +1)
defer m.deref(name)
_ = pty.Setsize(f, &pty.Winsize{Rows: uint16(ptyReq.Window.Height), Cols: uint16(ptyReq.Window.Width)})
go func() {
for w := range winCh {
_ = pty.Setsize(f, &pty.Winsize{Rows: uint16(w.Height), Cols: uint16(w.Width)})
}
}()
go func() { _, _ = io.Copy(f, s) }() // ssh -> pod
_, _ = io.Copy(s, f) // pod -> ssh
_ = cmd.Wait()
return nil
}
func (m *Manager) ref(name string, d int) {
m.mu.Lock()
defer m.mu.Unlock()
m.attached[name] += d
}
// deref stops the container shortly after the last session detaches, unless
// AGENTBBS_POD_KEEP=1 keeps pods running between visits.
func (m *Manager) deref(name string) {
m.mu.Lock()
m.attached[name]--
last := m.attached[name] <= 0
m.mu.Unlock()
if last && os.Getenv("AGENTBBS_POD_KEEP") != "1" {
go func() { _ = exec.Command(m.engine, "stop", "-t", "2", name).Run() }()
}
}
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}

105
internal/sandbox/sandbox.go Normal file
View file

@ -0,0 +1,105 @@
// Package sandbox wraps game/agent subprocesses with per-session isolation
// and resource limits (PRD §7 S2). It prefers bubblewrap, falls back to
// prlimit, and degrades to a plain exec with a warning.
package sandbox
import (
"fmt"
"os/exec"
)
// Mode selects the isolation technology.
type Mode string
const (
ModeAuto Mode = "auto"
ModeBwrap Mode = "bwrap"
ModePrlimit Mode = "prlimit"
ModeNone Mode = "none"
)
// Limits are per-process resource caps.
type Limits struct {
CPUSeconds int // hard CPU-time cap (fork-bomb/runaway protection)
MemoryMB int
MaxProcs int
}
// DefaultLimits suit a single interactive game session.
var DefaultLimits = Limits{CPUSeconds: 3600, MemoryMB: 512, MaxProcs: 64}
// Runner builds sandboxed exec.Cmds.
type Runner struct {
mode Mode
}
// New picks the best available mode when ModeAuto is requested.
func New(mode Mode) *Runner {
if mode == "" || mode == ModeAuto {
switch {
case have("bwrap"):
mode = ModeBwrap
case have("prlimit"):
mode = ModePrlimit
default:
mode = ModeNone
}
}
return &Runner{mode: mode}
}
// Mode reports the active isolation mode.
func (r *Runner) Mode() Mode { return r.mode }
func have(bin string) bool { _, err := exec.LookPath(bin); return err == nil }
// Command wraps program+args in the runner's sandbox. workDir is the only
// writable path (savegames land there); everything else is read-only.
func (r *Runner) Command(workDir, program string, args ...string) *exec.Cmd {
lim := DefaultLimits
switch r.mode {
case ModeBwrap:
bw := []string{
"--ro-bind", "/", "/",
"--dev", "/dev",
"--proc", "/proc",
"--tmpfs", "/tmp",
"--bind", workDir, workDir,
"--unshare-net",
"--unshare-pid",
"--die-with-parent",
"--chdir", workDir,
}
// Resource caps still come from prlimit when available.
if have("prlimit") {
pl := prlimitArgs(lim)
full := append(pl, "bwrap")
full = append(full, bw...)
full = append(full, "--", program)
full = append(full, args...)
return exec.Command("prlimit", full...)
}
full := append(bw, "--", program)
full = append(full, args...)
return exec.Command("bwrap", full...)
case ModePrlimit:
full := append(prlimitArgs(lim), program)
full = append(full, args...)
cmd := exec.Command("prlimit", full...)
cmd.Dir = workDir
return cmd
default:
cmd := exec.Command(program, args...)
cmd.Dir = workDir
return cmd
}
}
func prlimitArgs(l Limits) []string {
return []string{
fmt.Sprintf("--cpu=%d", l.CPUSeconds),
fmt.Sprintf("--as=%d", l.MemoryMB*1024*1024),
fmt.Sprintf("--nproc=%d", l.MaxProcs),
"--",
}
}

214
internal/store/store.go Normal file
View file

@ -0,0 +1,214 @@
// Package store is the persistence layer (PRD §4.2): SQLite behind a Store
// interface so a move to Postgres is a driver swap, not a rewrite.
package store
import (
"database/sql"
"errors"
"time"
_ "modernc.org/sqlite"
)
// User is a persisted account (member or agent; guests are never stored).
type User struct {
ID int64
Name string
Kind string
PubKeyFP string
CreatedAt time.Time
}
// Score is one leaderboard entry.
type Score struct {
User string
Game string
Score int64
At time.Time
}
// Store is the persistence contract shared by all plugins.
type Store interface {
// EnsureUser returns the user with this name, creating it with the given
// kind and key fingerprint on first sight. If the name exists with a
// different fingerprint, ErrKeyMismatch is returned.
EnsureUser(name, kind, pubkeyFP string) (User, error)
// UserByFingerprint finds an account by SSH key fingerprint.
UserByFingerprint(fp string) (User, bool, error)
RecordSession(userID int64, username, remote, route string) (int64, error)
EndSession(sessionID int64) error
AddScore(userID int64, game string, score int64) error
TopScores(game string, n int) ([]Score, error)
// Pod subscription (paid membership, e.g. $1/mo via CoinPay).
PodPaidUntil(userID int64) (time.Time, bool, error)
GrantPod(userID int64, until time.Time, paymentRef string) error
Close() error
}
// ErrKeyMismatch means a username is already registered with another key.
var ErrKeyMismatch = errors.New("username registered with a different key")
type sqliteStore struct{ db *sql.DB }
// Open opens (and migrates) the SQLite store at path.
func Open(path string) (Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
if err != nil {
return nil, err
}
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, err
}
return &sqliteStore{db: db}, nil
}
const schema = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL,
pubkey_fp TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY,
user_id INTEGER,
username TEXT NOT NULL,
remote_addr TEXT NOT NULL,
route TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
ended_at TEXT
);
CREATE TABLE IF NOT EXISTS scores (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
game TEXT NOT NULL,
score INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_scores_game ON scores(game, score DESC);
CREATE TABLE IF NOT EXISTS pod_subscriptions (
user_id INTEGER PRIMARY KEY REFERENCES users(id),
paid_until TEXT NOT NULL,
payment_ref TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
`
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
var u User
var created string
err := s.db.QueryRow(`SELECT id, name, kind, pubkey_fp, created_at FROM users WHERE name = ?`, name).
Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &created)
switch {
case err == sql.ErrNoRows:
res, err := s.db.Exec(`INSERT INTO users (name, kind, pubkey_fp) VALUES (?,?,?)`, name, kind, fp)
if err != nil {
return User{}, err
}
id, _ := res.LastInsertId()
return User{ID: id, Name: name, Kind: kind, PubKeyFP: fp, CreatedAt: time.Now().UTC()}, nil
case err != nil:
return User{}, err
}
if u.PubKeyFP != "" && fp != "" && u.PubKeyFP != fp {
return User{}, ErrKeyMismatch
}
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
return u, nil
}
func (s *sqliteStore) UserByFingerprint(fp string) (User, bool, error) {
if fp == "" {
return User{}, false, nil
}
var u User
var created string
err := s.db.QueryRow(`SELECT id, name, kind, pubkey_fp, created_at FROM users WHERE pubkey_fp = ?`, fp).
Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &created)
if err == sql.ErrNoRows {
return User{}, false, nil
}
if err != nil {
return User{}, false, err
}
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
return u, true, nil
}
func (s *sqliteStore) RecordSession(userID int64, username, remote, route string) (int64, error) {
var uid any
if userID > 0 {
uid = userID
}
res, err := s.db.Exec(`INSERT INTO sessions (user_id, username, remote_addr, route) VALUES (?,?,?,?)`,
uid, username, remote, route)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *sqliteStore) EndSession(id int64) error {
_, err := s.db.Exec(`UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, id)
return err
}
func (s *sqliteStore) AddScore(userID int64, game string, score int64) error {
_, err := s.db.Exec(`INSERT INTO scores (user_id, game, score) VALUES (?,?,?)`, userID, game, score)
return err
}
func (s *sqliteStore) TopScores(game string, n int) ([]Score, error) {
rows, err := s.db.Query(`
SELECT u.name, s.game, s.score, s.created_at
FROM scores s JOIN users u ON u.id = s.user_id
WHERE s.game = ? ORDER BY s.score DESC LIMIT ?`, game, n)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Score
for rows.Next() {
var sc Score
var at string
if err := rows.Scan(&sc.User, &sc.Game, &sc.Score, &at); err != nil {
return nil, err
}
sc.At, _ = time.Parse(time.RFC3339, at)
out = append(out, sc)
}
return out, rows.Err()
}
func (s *sqliteStore) PodPaidUntil(userID int64) (time.Time, bool, error) {
var until string
err := s.db.QueryRow(`SELECT paid_until FROM pod_subscriptions WHERE user_id = ?`, userID).Scan(&until)
if err == sql.ErrNoRows {
return time.Time{}, false, nil
}
if err != nil {
return time.Time{}, false, err
}
t, err := time.Parse(time.RFC3339, until)
return t, err == nil, err
}
func (s *sqliteStore) GrantPod(userID int64, until time.Time, ref string) error {
_, err := s.db.Exec(`
INSERT INTO pod_subscriptions (user_id, paid_until, payment_ref)
VALUES (?,?,?)
ON CONFLICT(user_id) DO UPDATE SET
paid_until = excluded.paid_until,
payment_ref = excluded.payment_ref,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
userID, until.UTC().Format(time.RFC3339), ref)
return err
}
func (s *sqliteStore) Close() error { return s.db.Close() }