One login: pod/IRC/news/Tor as hub menu items + single-login onboarding

The join@ output and hub previously pushed members to ssh into separate
servers (ssh pod@, ssh irc@, ssh news@, ssh tor@). Members now reach
everything from one `ssh <name>@bbs.profullstack.com` login:

- hub: new SessionApp entries (Pod, IRC, News, Tor shell) run as
  terminal-takeover features via tea.Exec, then return to the menu.
  Gated by membership/email-verification/plan; shown locked otherwise.
- main: build the session apps in teaHandler; extract runIRC/runNews so
  the irc@/news@ routes and the hub share one path. The old pod@/irc@/
  news@/tor@ routes stay as aliases (handy for bots).
- onboarding: rewrite the "You're in" and Founding-Member copy to present
  ONE login and stop advertising separate servers.
- email: member mailboxes move to mail.profullstack.com (apex reserved
  for corporate mail); webmail shown at https://mail.profullstack.com.
- username: harden the default handle to a safe /home/<name> token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-14 15:22:12 +00:00
parent 579999153e
commit d4ada98b69
2 changed files with 252 additions and 63 deletions

View file

@ -150,7 +150,9 @@ func main() {
host := env("AGENTBBS_HOST", "bbs.profullstack.com") host := env("AGENTBBS_HOST", "bbs.profullstack.com")
fe := forwardemail.ConfigFromEnv() fe := forwardemail.ConfigFromEnv()
if fe.Domain == "" { if fe.Domain == "" {
fe.Domain = host // personal addresses live on the BBS host by default // Member mailboxes live on a dedicated mail subdomain (mail.profullstack.com),
// not the BBS host and not the apex (which is reserved for corporate mail).
fe.Domain = env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com")
} }
a := &app{ a := &app{
st: st, st: st,
@ -332,7 +334,9 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
username := strings.ToLower(s.User()) username := strings.ToLower(s.User())
var u auth.User var u auth.User
if auth.IsGuestName(username) || fp == "" { var su store.User
guest := auth.IsGuestName(username) || fp == ""
if guest {
// Keyless or explicitly anonymous → guest. Named accounts require a key. // Keyless or explicitly anonymous → guest. Named accounts require a key.
if !auth.IsGuestName(username) { if !auth.IsGuestName(username) {
wish.Println(s, "note: member access requires an SSH key; joining as guest.") wish.Println(s, "note: member access requires an SSH key; joining as guest.")
@ -341,7 +345,9 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
} else { } else {
// A key maps to exactly one account: if this key is already // A key maps to exactly one account: if this key is already
// registered, that identity wins regardless of the username typed. // registered, that identity wins regardless of the username typed.
su, found, err := a.st.UserByFingerprint(fp) var found bool
var err error
su, found, err = a.st.UserByFingerprint(fp)
if err == nil && !found { if err == nil && !found {
su, err = a.st.EnsureUser(username, string(auth.KindFor(username)), fp) su, err = a.st.EnsureUser(username, string(auth.KindFor(username)), fp)
} }
@ -377,7 +383,88 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
// URL works the moment a member first signs in. // URL works the moment a member first signs in.
seedHomepage(filepath.Join(ctx.DataDir, "public_html"), u.Name, a.host) seedHomepage(filepath.Join(ctx.DataDir, "public_html"), u.Name, a.host)
} }
return hub.New(u, ctx, a.enabledPlugins()), []tea.ProgramOption{tea.WithAltScreen()} return hub.New(u, ctx, a.enabledPlugins(), a.sessionApps(s, su, guest)), []tea.ProgramOption{tea.WithAltScreen()}
}
// sessionExec adapts a func to tea.ExecCommand so the hub can run a
// terminal-takeover feature (pod shell, IRC, news, mail, Tor) via tea.Exec and
// return to the menu afterwards. The feature reads and writes the ssh.Session
// directly, so the stream hooks are no-ops.
type sessionExec struct{ run func() error }
func (e sessionExec) Run() error { return e.run() }
func (e sessionExec) SetStdin(io.Reader) {}
func (e sessionExec) SetStdout(io.Writer) {}
func (e sessionExec) SetStderr(io.Writer) {}
// sessionApps builds the hub's terminal-takeover entries (pod, IRC, news, Tor)
// so a member reaches everything from one `ssh <name>@host` login instead of
// separate `ssh pod@`/`irc@`/`news@`/`tor@` connections (which still work as
// aliases, mainly for bots). Each entry is gated by membership/verification/plan
// and shown locked with a reason when unavailable.
func (a *app) sessionApps(s ssh.Session, su store.User, guest bool) []hub.SessionApp {
membersOnly := "members only — register first: ssh join@" + a.host
apps := make([]hub.SessionApp, 0, 4)
// Pod — free for verified members.
podLock := ""
switch {
case guest:
podLock = membersOnly
case env("AGENTBBS_REQUIRE_VERIFIED_EMAIL", "1") != "0" && !su.EmailVerified:
podLock = "confirm your email first — re-run: ssh join@" + a.host
case a.pods == nil:
podLock = "pods are temporarily unavailable on this host"
}
apps = append(apps, hub.SessionApp{
Title: "Pod",
Description: "your own Linux pod — a full shell",
Locked: podLock,
Cmd: sessionExec{run: func() error { return a.pods.Attach(s, su.Name) }},
})
// IRC — free for any registered member.
ircLock := ""
if guest {
ircLock = membersOnly
}
apps = append(apps, hub.SessionApp{
Title: "IRC",
Description: "members-only chat network (humans + agents)",
Locked: ircLock,
Cmd: sessionExec{run: func() error { return a.runIRC(s, su.Name, su.Premium) }},
})
// News — free for any registered member.
newsLock := ""
if guest {
newsLock = membersOnly
}
apps = append(apps, hub.SessionApp{
Title: "News",
Description: "members-only Usenet/NNTP discussion",
Locked: newsLock,
Cmd: sessionExec{run: func() error { return a.runNews(s, su.Name) }},
})
// Tor — a Founding Lifetime Member perk: a torsocks shell in the pod.
torLock := ""
switch {
case guest:
torLock = membersOnly
case !su.Premium:
torLock = "Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@" + a.host
case a.pods == nil:
torLock = "pods are temporarily unavailable on this host"
}
apps = append(apps, hub.SessionApp{
Title: "Tor shell",
Description: "a torsocks shell in your pod (everything over Tor)",
Locked: torLock,
Cmd: sessionExec{run: func() error { return a.pods.Exec(s, su.Name, tor.Torsocks([]string{"bash", "-l"})) }},
})
return apps
} }
// readLine reads one line of interactive input from an SSH session that is // readLine reads one line of interactive input from an SSH session that is
@ -464,7 +551,7 @@ func (a *app) handleJoin(s ssh.Session) {
}, "\n")) }, "\n"))
// 1) email -> emailed code -> enter code. A verified account is a free // 1) email -> emailed code -> enter code. A verified account is a free
// member: it gets a Docker pod (ssh pod@) and a /~name homepage. // member: it gets a Docker pod, IRC/news, and a /~name homepage, all from the hub.
if !u.EmailVerified { if !u.EmailVerified {
if !a.verifyEmailInteractive(s, in, &u) { if !a.verifyEmailInteractive(s, in, &u) {
_ = s.Exit(1) _ = s.Exit(1)
@ -477,10 +564,15 @@ func (a *app) handleJoin(s ssh.Session) {
seedHomepage(filepath.Join(a.dataDir, "users", u.Name, "public_html"), u.Name, a.host) seedHomepage(filepath.Join(a.dataDir, "users", u.Name, "public_html"), u.Name, a.host)
wish.Println(s, "\n"+strings.Join([]string{ wish.Println(s, "\n"+strings.Join([]string{
" You're in — free membership includes:", " You're in. One login gets you everything — no other servers to ssh into:",
" pod ssh pod@" + a.host + " your own Linux pod", "",
" hub ssh " + u.Name + "@" + a.host, " ssh " + u.Name + "@" + a.host,
" homepage https://" + a.host + "/~" + u.Name, "",
" Inside, free membership includes:",
" • your own Linux pod (a full shell)",
" • IRC chat + Usenet/news (members-only)",
" • the arcade & games",
" • your homepage https://" + a.host + "/~" + u.Name,
}, "\n")) }, "\n"))
// 2) Founding Lifetime ($99 one-time): personal @host email + custom domains. // 2) Founding Lifetime ($99 one-time): personal @host email + custom domains.
@ -515,14 +607,35 @@ func (a *app) acceptTerms(s ssh.Session, in *bufio.Reader) bool {
} }
} }
// fpToken derives up to n lowercase alphanumeric characters from an SSH key
// fingerprint, for use in a default username/home-dir. The raw base64
// fingerprint can contain '+' and '/', which are unsafe as a filesystem token,
// so we keep only [a-z0-9].
func fpToken(fp string, n int) string {
cleaned := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
return r
default:
return -1
}
}, strings.ToLower(strings.TrimPrefix(fp, "SHA256:")))
if len(cleaned) > n {
cleaned = cleaned[:n]
}
return cleaned
}
// registerNewMember asks the visitor to choose a username, then creates their // registerNewMember asks the visitor to choose a username, then creates their
// member account under it. The name is sanitized to the hub/subdomain charset, // member account under it. The name is sanitized to the hub/subdomain charset,
// rejected if reserved, and must be free; pressing enter accepts a generated // rejected if reserved, and must be free; pressing enter accepts a generated
// member-<fp8> default. Returns the created user. // member-<fp8> default. The chosen name is also the member's pod home
// (/home/<name>), so it must be a safe shell/filesystem token — the sanitizer
// (and the default below) keep it to lowercase [a-z0-9-]. Returns the user.
func (a *app) registerNewMember(s ssh.Session, in *bufio.Reader, fp string) (store.User, error) { func (a *app) registerNewMember(s ssh.Session, in *bufio.Reader, fp string) (store.User, error) {
def := "member-" + strings.ToLower(strings.TrimPrefix(fp, "SHA256:"))[:8] def := "member-" + fpToken(fp, 8)
wish.Println(s, "\n Pick a username — letters, numbers and dashes, 320 chars.") wish.Println(s, "\n Pick a username — letters, numbers and dashes, 320 chars.")
wish.Println(s, " It's your handle for ssh <name>@"+a.host+" and https://"+a.host+"/~<name>.") wish.Println(s, " It's your handle for ssh <name>@"+a.host+" (your pod home is /home/<name>).")
for tries := 0; tries < 5; tries++ { for tries := 0; tries < 5; tries++ {
wish.Print(s, "\n Username ["+def+"]: ") wish.Print(s, "\n Username ["+def+"]: ")
@ -651,23 +764,19 @@ func (a *app) ensurePremium(u *store.User) bool {
return true return true
} }
// showPremiumWelcome prints a premium member's perks: their personal email, // showPremiumWelcome prints a premium member's perks: their mailbox, the webmail
// where it forwards, the webmail URL, and custom domains. // URL, the in-hub Mail/Tor entries, and custom domains.
func (a *app) showPremiumWelcome(s ssh.Session, u store.User) { func (a *app) showPremiumWelcome(s ssh.Session, u store.User) {
lines := []string{ lines := []string{
"", "",
" ★ Founding Lifetime Member — thanks! Your perks:", " ★ Founding Lifetime Member — thanks! Your perks:",
"", "",
" email " + a.fe.Address(u.Name), " mailbox " + a.fe.Address(u.Name),
" forwards " + u.Email, " webmail https://" + a.fe.Domain,
} " mail/tor pick “Mail” or “Tor shell” in the hub: ssh " + u.Name + "@" + a.host,
if url := a.fe.WebmailURL(); url != "" { " domains ssh domain@" + a.host + " add <yourdomain.com>",
lines = append(lines, " webmail "+url)
}
lines = append(lines,
" domains ssh domain@"+a.host+" add <yourdomain.com>",
"", "",
) }
wish.Println(s, strings.Join(lines, "\n")) wish.Println(s, strings.Join(lines, "\n"))
} }
@ -691,10 +800,10 @@ func (a *app) offerPremium(s ssh.Session, u *store.User) {
"", "",
" Everything in your free membership stays free — founding adds these", " Everything in your free membership stays free — founding adds these",
" bonus features, forever:", " bonus features, forever:",
" • your own email " + a.fe.Address(u.Name) + " (forwards to you, webmail included)", " • your own mailbox " + a.fe.Address(u.Name) + " (webmail: https://" + a.fe.Domain + ")",
" • custom domains point yourdomain.com at your homepage", " • custom domains point yourdomain.com at your homepage",
" • Tor access ssh tor@" + a.host + " — fetch URLs & join IRC over Tor", " • Tor a “Tor shell” in your pod — everything over Tor",
" • locked-in price founding rate is yours for life — never renew, never pay again", " • locked-in price founding rate is yours for life — never renew, never pay again",
"", "",
} }
if c, ok, err := payments.CreatePremiumCharge(ref); ok && err == nil { if c, ok, err := payments.CreatePremiumCharge(ref); ok && err == nil {
@ -1071,21 +1180,28 @@ func (a *app) handleIRC(s ssh.Session) {
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "irc") sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "irc")
defer func() { _ = a.st.EndSession(sessID) }() defer func() { _ = a.st.EndSession(sessID) }()
// Premium members may /create channels; free members can still join existing
// ones. ensurePremium also settles any pending charge.
if err := a.runIRC(s, u.Name, a.ensurePremium(&u)); err != nil {
wish.Println(s, "irc: "+err.Error())
_ = s.Exit(1)
}
}
// runIRC dials the members-only IRC network as name and runs the in-process
// client TUI on the session. Shared by the irc@ route and the hub's IRC entry.
func (a *app) runIRC(s ssh.Session, name string, canCreate bool) error {
addr := strings.TrimSpace(os.Getenv("AGENTBBS_IRC_ADDR")) addr := strings.TrimSpace(os.Getenv("AGENTBBS_IRC_ADDR"))
if addr == "" { if addr == "" {
addr = irc.DefaultAddr addr = irc.DefaultAddr
} }
log.Info("irc connect", "user", u.Name, "addr", addr) log.Info("irc connect", "user", name, "addr", addr)
c, err := irc.Dial(s.Context(), addr, u.Name) c, err := irc.Dial(s.Context(), addr, name)
if err != nil { if err != nil {
wish.Println(s, "irc: "+err.Error()) return err
_ = s.Exit(1)
return
} }
_ = c.Join(irc.DefaultChannel) _ = c.Join(irc.DefaultChannel)
if err := irc.Run(s, c); err != nil { return irc.Run(s, c, canCreate)
wish.Println(s, "irc: "+err.Error())
}
} }
// handleNews drops a member into the BBS's own (members-only) Usenet/NNTP server // handleNews drops a member into the BBS's own (members-only) Usenet/NNTP server
@ -1114,15 +1230,21 @@ func (a *app) handleNews(s ssh.Session) {
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "news") sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "news")
defer func() { _ = a.st.EndSession(sessID) }() defer func() { _ = a.st.EndSession(sessID) }()
if err := a.runNews(s, u.Name); err != nil {
wish.Println(s, "news: "+err.Error())
_ = s.Exit(1)
}
}
// runNews runs the in-process newsreader TUI for name on the session. Shared by
// the news@ route and the hub's News entry.
func (a *app) runNews(s ssh.Session, name string) error {
addr := a.newsAddr addr := a.newsAddr
if addr == "" { if addr == "" {
addr = news.DefaultAddr addr = news.DefaultAddr
} }
log.Info("news connect", "user", u.Name, "addr", addr) log.Info("news connect", "user", name, "addr", addr)
if err := news.RunReader(s, addr, u.Name); err != nil { return news.RunReader(s, addr, name)
wish.Println(s, "news: "+err.Error())
_ = s.Exit(1)
}
} }
// handleTorCmd runs an arbitrary command through Tor (torsocks) inside the // handleTorCmd runs an arbitrary command through Tor (torsocks) inside the

View file

@ -1,6 +1,13 @@
// Package hub is the Bubble Tea model every BBS session lands in (PRD §4.1): // 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, // it lists registered plugins and routes the session to the selection,
// reclaiming it when the plugin emits ExitMsg. // reclaiming it when the plugin emits ExitMsg.
//
// Besides in-hub plugins, the hub also lists "session apps" — features that take
// over the whole terminal (a pod shell, the IRC client, the newsreader, the mail
// reader, Tor) rather than rendering inside the hub. Selecting one suspends the
// hub via tea.Exec, runs it, and returns to the menu. This is what lets a member
// reach everything from a single `ssh <name>@host` login instead of separate
// `ssh pod@` / `ssh irc@` / `ssh news@` connections.
package hub package hub
import ( import (
@ -21,11 +28,30 @@ var (
frameStyle = lipgloss.NewStyle().Padding(1, 2) frameStyle = lipgloss.NewStyle().Padding(1, 2)
) )
// SessionApp is a hub entry that takes over the terminal — a pod shell, the IRC
// client, the newsreader, the mail reader, or Tor — instead of running as an
// in-hub model. Selecting it suspends the hub (tea.Exec), runs Cmd, then returns
// to the menu.
type SessionApp struct {
Title string
Description string
// Locked, when non-empty, marks the entry visible-but-unavailable (e.g. a
// paid feature for a free member, or members-only for a guest). The reason
// is shown when the entry is selected; the app cannot be launched.
Locked string
// Cmd is the terminal-takeover command run under tea.Exec.
Cmd tea.ExecCommand
}
// appExitMsg is delivered when a SessionApp launched via tea.Exec returns.
type appExitMsg struct{ err error }
// Model is the hub menu. // Model is the hub menu.
type Model struct { type Model struct {
user auth.User user auth.User
ctx plugin.Context ctx plugin.Context
plugins []plugin.Plugin plugins []plugin.Plugin
apps []SessionApp
cursor int cursor int
active tea.Model active tea.Model
@ -34,19 +60,31 @@ type Model struct {
note string note string
} }
// New builds a hub for one session. // New builds a hub for one session. apps are terminal-takeover features listed
func New(user auth.User, ctx plugin.Context, plugins []plugin.Plugin) Model { // after the in-hub plugins (may be nil).
return Model{user: user, ctx: ctx, plugins: plugins} func New(user auth.User, ctx plugin.Context, plugins []plugin.Plugin, apps []SessionApp) Model {
return Model{user: user, ctx: ctx, plugins: plugins, apps: apps}
} }
func (m Model) Init() tea.Cmd { return nil } func (m Model) Init() tea.Cmd { return nil }
// entries is the total number of selectable rows (plugins then apps).
func (m Model) entries() int { return len(m.plugins) + len(m.apps) }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Window size is shared with whichever model is active. // Window size is shared with whichever model is active.
if ws, ok := msg.(tea.WindowSizeMsg); ok { if ws, ok := msg.(tea.WindowSizeMsg); ok {
m.width, m.height = ws.Width, ws.Height m.width, m.height = ws.Width, ws.Height
} }
// A returning session app (tea.Exec finished) hands control back here.
if ae, ok := msg.(appExitMsg); ok {
if ae.err != nil {
m.note = ae.err.Error()
}
return m, nil
}
// A plugin owns the session until it emits ExitMsg (PRD §4.3). // A plugin owns the session until it emits ExitMsg (PRD §4.3).
if m.active != nil { if m.active != nil {
if _, ok := msg.(plugin.ExitMsg); ok { if _, ok := msg.(plugin.ExitMsg); ok {
@ -69,44 +107,64 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.cursor-- m.cursor--
} }
case "down", "j": case "down", "j":
if m.cursor < len(m.plugins)-1 { if m.cursor < m.entries()-1 {
m.cursor++ m.cursor++
} }
case "enter": case "enter":
p := m.plugins[m.cursor] return m.selectEntry()
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 return m, nil
} }
// selectEntry launches whatever the cursor is on: an in-hub plugin or a session app.
func (m Model) selectEntry() (tea.Model, tea.Cmd) {
if m.cursor < len(m.plugins) {
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...)
}
app := m.apps[m.cursor-len(m.plugins)]
if app.Locked != "" {
m.note = app.Locked
return m, nil
}
return m, tea.Exec(app.Cmd, func(err error) tea.Msg { return appExitMsg{err} })
}
func (m Model) View() string { func (m Model) View() string {
if m.active != nil { if m.active != nil {
return m.active.View() return m.active.View()
} }
who := fmt.Sprintf("%s (%s)", m.user.Name, m.user.Kind) who := fmt.Sprintf("%s (%s)", m.user.Name, m.user.Kind)
s := titleStyle.Render("AgentBBS") + dimStyle.Render(" · "+who) + "\n\n" s := titleStyle.Render("AgentBBS") + dimStyle.Render(" · "+who) + "\n\n"
for i, p := range m.plugins { row := 0
cur := " " for _, p := range m.plugins {
if i == m.cursor {
cur = cursorStyle.Render("> ")
}
label := p.Title() label := p.Title()
if p.RequiresAuth() && m.user.Kind == auth.Guest { if p.RequiresAuth() && m.user.Kind == auth.Guest {
label += lockStyle.Render(" [members]") label += lockStyle.Render(" [members]")
} }
s += fmt.Sprintf("%s%s\n %s\n", cur, label, dimStyle.Render(p.Description())) s += m.renderRow(row, label, p.Description())
row++
}
for _, app := range m.apps {
label := app.Title
if app.Locked != "" {
label += lockStyle.Render(" [locked]")
}
s += m.renderRow(row, label, app.Description)
row++
} }
s += "\n" + dimStyle.Render("↑/↓ move · enter select · q quit") s += "\n" + dimStyle.Render("↑/↓ move · enter select · q quit")
if m.note != "" { if m.note != "" {
@ -114,3 +172,12 @@ func (m Model) View() string {
} }
return frameStyle.Render(s) return frameStyle.Render(s)
} }
// renderRow renders one menu line with the cursor and dimmed description.
func (m Model) renderRow(i int, label, desc string) string {
cur := " "
if i == m.cursor {
cur = cursorStyle.Render("> ")
}
return fmt.Sprintf("%s%s\n %s\n", cur, label, dimStyle.Render(desc))
}