Autonomous deploy + free-pod/Premium-email membership (#1)

Deploy automation (idempotent, runs on every deploy):
- .github/workflows/deploy.yml: push to main/master (or dispatch) SSHes to the
  droplet and re-runs setup.sh; deploys the pushed branch; smoke-tests :22.
- scripts/self-update.sh + agentbbs-update.timer: autonomous backstop that
  redeploys only when origin advances.
- setup.sh hardened: flock, fetch+reset (survives force-push), fixed the
  always-skipped arcade asset fetch path.

Membership model:
- Free, email-verified members get their own Docker pod (pod@ paywall removed)
  and a /~name homepage (seeded at join@).
- join@ is now interactive: email -> emailed 6-digit code -> enter code.
- Premium ($10 one-time, lifetime via CoinPay) grants a personal
  <name>@host email (new internal/forwardemail; forwardemail.net aliases) and
  custom domains (domain@ gated to Premium).
- ensurePremium() silently verifies/grants/provisions on hub login, join@, and
  domain@. New-signup details emailed to AGENTBBS_SIGNUP_NOTIFY (subject "bbs").

Store: User.Premium + premium/premium_ref cols, ConfirmEmailCode, GrantPremium.
Tests: store_premium_test.go, forwardemail_test.go. Build/vet/gofmt/test green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-14 00:59:52 -07:00 committed by GitHub
parent 1086d57a4d
commit 7a85f2dbbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 900 additions and 101 deletions

View file

@ -16,6 +16,7 @@ import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
@ -29,6 +30,114 @@ const PodPriceLabel = "$1/mo"
// PodTerm is how much access one payment buys.
const PodTerm = 31 * 24 * time.Hour
// PremiumPriceLabel is the human-readable price for the one-time lifetime
// membership offered at join@.
const PremiumPriceLabel = "$10 (lifetime)"
// premium charge defaults — all overridable via env so the CoinPay surface can
// change without a rebuild (mirrors the pod templates above).
func PremiumAmount() string { return envOr("AGENTBBS_PREMIUM_AMOUNT", "10") }
func PremiumCurrency() string { return envOr("AGENTBBS_PREMIUM_CURRENCY", "USD") }
func PremiumBlockchain() string { return envOr("AGENTBBS_PREMIUM_BLOCKCHAIN", "eth") }
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
// Charge is a created CoinPay payment a user must fund: a unique deposit
// address plus the crypto amount (and the fiat amount it settles).
type Charge struct {
Address string `json:"payment_address"`
CryptoAmount string `json:"crypto_amount"`
Currency string `json:"crypto_currency"`
FiatAmount string `json:"amount"`
FiatCurrency string `json:"currency"`
ID string `json:"id"`
QR string `json:"qr_code"`
}
// PremiumReference derives the stable CoinPay memo for a user's lifetime
// membership from their key fingerprint.
func PremiumReference(pubkeyFP string) string { return Reference("premium", pubkeyFP) }
// CreatePremiumCharge shells out to the CoinPay CLI to mint a payment address
// for the $10 lifetime membership and parses the JSON it prints. created is
// false when no create command is configured or the CLI is unavailable, so the
// caller can fall back to PremiumPayCommand. The reference is passed as the
// payment metadata/memo so the eventual settlement reconciles to the account.
//
// AGENTBBS_COINPAY_PREMIUM_CREATE_CMD
// default: coinpay payment create --amount 10 --currency USD --blockchain eth --json --metadata %s
func CreatePremiumCharge(ref string) (Charge, bool, error) {
tmpl := os.Getenv("AGENTBBS_COINPAY_PREMIUM_CREATE_CMD")
if tmpl == "" {
tmpl = "coinpay payment create --amount " + PremiumAmount() +
" --currency " + PremiumCurrency() +
" --blockchain " + PremiumBlockchain() + " --json --metadata %s"
}
line := tmpl
if strings.Contains(tmpl, "%s") {
line = fmt.Sprintf(tmpl, ref)
} else {
line = tmpl + " " + ref
}
parts := strings.Fields(line)
if len(parts) == 0 {
return Charge{}, false, nil
}
if _, err := exec.LookPath(parts[0]); err != nil {
return Charge{}, false, nil // CLI not installed — caller falls back
}
out, err := exec.Command(parts[0], parts[1:]...).Output()
if err != nil {
return Charge{}, false, err
}
var c Charge
if err := json.Unmarshal(out, &c); err != nil {
// Some CLIs wrap the payment under a top-level key, e.g. {"payment":{…}}.
var wrap struct {
Payment Charge `json:"payment"`
}
if json.Unmarshal(out, &wrap) == nil && wrap.Payment.Address != "" {
c = wrap.Payment
} else {
return Charge{}, false, err
}
}
if c.Address == "" {
return Charge{}, false, nil
}
return c, true, nil
}
// PremiumPayCommand is the manual fallback shown when no charge could be minted
// in-session: the command the user can run themselves to pay.
//
// AGENTBBS_COINPAY_PREMIUM_PAY_TMPL
func PremiumPayCommand(ref string) string {
tmpl := os.Getenv("AGENTBBS_COINPAY_PREMIUM_PAY_TMPL")
if tmpl == "" {
tmpl = "coinpay payment create --amount " + PremiumAmount() +
" --currency " + PremiumCurrency() +
" --blockchain " + PremiumBlockchain() + " --metadata %s"
}
if strings.Contains(tmpl, "%s") {
return fmt.Sprintf(tmpl, ref)
}
return tmpl + " " + ref
}
// VerifyPremium checks whether a premium charge has settled, via the CoinPay
// status command. Like Verify, checked is false when unconfigured/unavailable.
//
// AGENTBBS_COINPAY_PREMIUM_STATUS_CMD e.g. "coinpay payment status %s" (exit 0 == paid)
func VerifyPremium(payRef string) (paid bool, checked bool) {
return runVerify(os.Getenv("AGENTBBS_COINPAY_PREMIUM_STATUS_CMD"), payRef)
}
// 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 {
@ -54,11 +163,17 @@ func PayCommand(ref string) string {
// (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")
return runVerify(os.Getenv("AGENTBBS_COINPAY_VERIFY_CMD"), ref)
}
// runVerify runs a "%s"-templated verify command and maps its exit status to
// (paid, checked): checked is false when the template is empty or the binary is
// absent, so callers fall back to store state.
func runVerify(tmpl, ref string) (paid bool, checked bool) {
if tmpl == "" {
return false, false
}
var line string
line := tmpl
if strings.Contains(tmpl, "%s") {
line = fmt.Sprintf(tmpl, ref)
} else {
@ -71,8 +186,7 @@ func Verify(ref string) (paid bool, checked bool) {
if _, err := exec.LookPath(parts[0]); err != nil {
return false, false
}
cmd := exec.Command(parts[0], parts[1:]...)
if err := cmd.Run(); err != nil {
if err := exec.Command(parts[0], parts[1:]...).Run(); err != nil {
return false, true
}
return true, true