mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
Autonomous deploy + free-pod/Premium-email membership
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:
parent
1086d57a4d
commit
3230807421
11 changed files with 900 additions and 101 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue