diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bb97795..7d087bb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -51,11 +51,18 @@ jobs: # Deploy whichever branch was pushed (main or master), so a rename # "just works". For workflow_dispatch this is the chosen branch. DEPLOY_BRANCH: ${{ github.ref_name }} + # App secrets injected into the droplet's env file by setup.sh. + # GitHub masks these in logs; setup.sh upserts them idempotently. + COINPAY_API_KEY: ${{ secrets.COINPAY_API_KEY }} + COINPAY_MERCHANT_ID: ${{ secrets.COINPAY_MERCHANT_ID }} run: | ssh -i ~/.ssh/id_deploy -p "$DEPLOY_PORT" \ -o BatchMode=yes -o StrictHostKeyChecking=yes \ "${DEPLOY_USER}@${DEPLOY_HOST}" \ - "sudo -n env BRANCH=$(printf %q "$DEPLOY_BRANCH") bash -s" <<'REMOTE' + "sudo -n env BRANCH=$(printf %q "$DEPLOY_BRANCH") \ + COINPAY_API_KEY=$(printf %q "$COINPAY_API_KEY") \ + COINPAY_MERCHANT_ID=$(printf %q "$COINPAY_MERCHANT_ID") \ + bash -s" <<'REMOTE' set -euo pipefail REPO=https://github.com/profullstack/agentbbs.git BRANCH="${BRANCH:-main}" @@ -67,7 +74,10 @@ jobs: fi git -C "$SRC" fetch --depth 1 origin "$BRANCH" git -C "$SRC" reset --hard "origin/$BRANCH" - exec env BRANCH="$BRANCH" "$SRC/setup.sh" + exec env BRANCH="$BRANCH" \ + COINPAY_API_KEY="${COINPAY_API_KEY:-}" \ + COINPAY_MERCHANT_ID="${COINPAY_MERCHANT_ID:-}" \ + "$SRC/setup.sh" REMOTE - name: Smoke-test that agentbbs serves :22 diff --git a/README.md b/README.md index ca087d0..3f6f70c 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,18 @@ by Profullstack, Inc. ```bash -ssh bbs@profullstack.com # the hub: arcade (DOOM, snake), leaderboards — guests welcome -ssh join@profullstack.com # register + confirm email by code, then the Premium offer -ssh @profullstack.com # the hub as a member — or finger someone else's name -ssh pod@profullstack.com # your own Linux pod — FREE for verified members -ssh domain@profullstack.com # point your domain at your homepage (Premium) -ssh video-@profullstack.com # join a PairUX video call as truecolor ASCII -ssh agent@profullstack.com # chat with the operator's AI agent +ssh join@bbs.profullstack.com # new here? register + confirm your email, get your account +ssh @bbs.profullstack.com # your BBS: hub, arcade, your pod, chat, domains — all inside ``` -**Membership:** verified-email members are **free** — each gets a Docker pod -(`ssh pod@`) and a homepage at `https://host/~name`. **Premium** ($10 one-time, -lifetime) adds a personal `name@host` email (via forwardemail.net) and custom -domains. +Just two SSH front doors: **`join@`** to onboard a new key, then +**`@`** for everything else — the hub, your pod, the arcade, chat, and +domains are all reached from there. + +**Membership:** a verified-email account is **free** — you get a personal Docker +pod and a homepage at `https://bbs.profullstack.com/~name`. **Premium** ($10 +one-time, lifetime) adds a personal `name@bbs.profullstack.com` email (via +forwardemail.net) and custom domains. No browser, no install, no client download. The BBS is a hub of hot-swappable plugins around one shared account system; the full product plan is in @@ -29,7 +28,7 @@ plugins around one shared account system; the full product plan is in |---|---| | M0 — core hub (wish server, auth, plugin contract, SQLite) | ✅ | | M1 — arcade (doom-ascii + Freedoom, sandbox, saves, leaderboards) | ✅ | -| Pods (`pod@`, rootless containers, CoinPay membership) | ✅ | +| Pods (rootless containers, free for verified members) | ✅ | | Video (`video-@`, PairUX/LiveKit → ASCII streaming) | ✅ | | `agent@` chat (configurable agent backend) + finger | ✅ | | M2 — admin console | ⬜ | @@ -43,7 +42,7 @@ plugins around one shared account system; the full product plan is in go build -o agentbbs ./cmd/agentbbs scripts/fetch-assets.sh # build doom-ascii + fetch Freedoom (optional) ./agentbbs # listens on :2222 -ssh -p 2222 bbs@localhost +ssh -p 2222 join@localhost # onboard, then: ssh -p 2222 @localhost ``` Configuration (env): @@ -53,13 +52,14 @@ Configuration (env): | `AGENTBBS_ADDR` | `:2222` | listen address | | `AGENTBBS_DATA` | `./data` | SQLite db, host key, per-user dirs | | `AGENTBBS_ASSETS` | `./assets` | doom binary + wads | -| `AGENTBBS_HOST` | `profullstack.com` | hostname shown in messages | +| `AGENTBBS_HOST` | `bbs.profullstack.com` | hostname shown in messages | | `AGENTBBS_SANDBOX` | `auto` | `bwrap` / `prlimit` / `none` | | `AGENTBBS_POD_IMAGE` | `debian:stable-slim` | pod base image | | `AGENTBBS_POD_MEM` / `AGENTBBS_POD_CPUS` | `512m` / `1` | pod caps | | `AGENTBBS_POD_KEEP` | unset | `1` keeps pods running after disconnect | -| `AGENTBBS_COINPAY_PAY_TMPL` | coinpay default | pay command shown to users | -| `AGENTBBS_COINPAY_VERIFY_CMD` | unset | verifier; exit 0 = paid | +| `COINPAY_API_KEY` | unset | CoinPay API key (Premium payments) | +| `AGENTBBS_COINPAY_MERCHANT_ID` | unset | CoinPay merchant/business id | +| `AGENTBBS_FORWARDEMAIL_API_KEY` | unset | forwardemail.net key (Premium email) | Ops: @@ -86,15 +86,16 @@ Full details, required secrets, and ops commands: [`docs/deploy.md`](docs/deploy - **Plugins** (`internal/plugin`): `ID/Title/Description/RequiresAuth/New`; a plugin owns the session until it emits `ExitMsg`. Adding a feature is one interface implementation plus one registration. -- **Routing**: SSH username selects the surface — hub, onboarding, or pod. +- **Routing**: SSH username selects the surface — onboarding (`join@`) or your + hub (`@`); pods/arcade/chat/domains are features inside the hub. - **Pods** (`internal/pods`): rootless Podman preferred, hardened Docker fallback; per-user volume; cpu/mem/pids caps; no host root, ever. - **Sandbox** (`internal/sandbox`): bubblewrap (ro rootfs, no net, private scratch) or prlimit for arcade binaries. - **Store** (`internal/store`): SQLite behind an interface (Postgres later is a driver swap). Users, sessions, scores, pod subscriptions. -- **Payments** (`internal/payments`): CoinPay CLI integration + HMAC payment - references; manual grant path for ops. +- **Payments** (`internal/payments`): CoinPay REST API (coinpayportal.com) for + the $10 lifetime membership + HMAC payment references; manual grant for ops. ## License diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 27f3c04..492c905 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -98,7 +98,7 @@ func main() { return } - host := env("AGENTBBS_HOST", "profullstack.com") + host := env("AGENTBBS_HOST", "bbs.profullstack.com") fe := forwardemail.ConfigFromEnv() if fe.Domain == "" { fe.Domain = host // personal addresses live on the BBS host by default @@ -397,11 +397,14 @@ func (a *app) ensurePremium(u *store.User) bool { if u.Premium { return true } - ref := payments.PremiumReference(u.PubKeyFP) - if paid, checked := payments.VerifyPremium(ref); !checked || !paid { + // Verify the CoinPay payment we created for them (if any) has settled. + if u.PremiumPayID == "" { return false } - if err := a.st.GrantPremium(u.ID, ref); err != nil { + if paid, checked := payments.VerifyPremium(u.PremiumPayID); !checked || !paid { + return false + } + if err := a.st.GrantPremium(u.ID, u.PremiumPayID); err != nil { log.Error("grant premium", "err", err) return false } @@ -456,6 +459,10 @@ func (a *app) offerPremium(s ssh.Session, u *store.User) { "", } if c, ok, err := payments.CreatePremiumCharge(ref); ok && err == nil { + // Remember the payment id so a later connect can confirm settlement. + if err := a.st.SetPremiumPayment(u.ID, c.ID); err != nil { + log.Error("store premium payment id", "err", err) + } amount := "$" + payments.PremiumAmount() + " " + payments.PremiumCurrency() if c.CryptoAmount != "" { cur := c.Currency @@ -475,7 +482,7 @@ func (a *app) offerPremium(s ssh.Session, u *store.User) { if err != nil { log.Error("create premium charge", "err", err) } - lines = append(lines, " pay: "+payments.PremiumPayCommand(ref)) + lines = append(lines, " Payment is temporarily unavailable — please try again shortly.") } lines = append(lines, "", diff --git a/internal/payments/payments.go b/internal/payments/payments.go index 4f5dfff..7003d7d 100644 --- a/internal/payments/payments.go +++ b/internal/payments/payments.go @@ -1,45 +1,59 @@ -// Package payments gates paid features (the pod subscription, $1/mo) on -// CoinPay — the default LogicSRC payment/DID/wallet plugin. +// Package payments gates the one-time $10 lifetime Premium membership on +// CoinPay (coinpayportal.com). It talks to the CoinPay REST API directly over +// HTTP — no `coinpay` CLI needs to be installed on the host. // -// 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: +// Config (env): // -// 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) +// COINPAY_API_KEY Bearer key for the CoinPay API (cp_live_…) +// AGENTBBS_COINPAY_MERCHANT_ID merchant/business id payments are created under +// AGENTBBS_COINPAY_API_URL API base (default https://coinpayportal.com/api) +// AGENTBBS_PREMIUM_AMOUNT fiat amount (default 10) +// AGENTBBS_PREMIUM_CURRENCY fiat currency (default USD) +// AGENTBBS_PREMIUM_BLOCKCHAIN settlement chain (default eth) // -// Operators can also grant manually: `agentbbs grant-pod --months N`. +// Operators can also grant pod time manually: `agentbbs grant-pod N`. package payments import ( + "bytes" + "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" + "io" + "net/http" + "net/url" "os" - "os/exec" - "strings" + "strconv" "time" ) -// PodPriceLabel is the human-readable price for the pod membership. -const PodPriceLabel = "$1/mo" - -// PodTerm is how much access one payment buys. +// PodTerm is how much access one manual pod grant buys (grant-pod CLI). 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). +// Premium charge parameters — overridable via env so the offer can change +// without a rebuild. 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") } +// MerchantID is the CoinPay merchant/business id payments are created under. +func MerchantID() string { return os.Getenv("AGENTBBS_COINPAY_MERCHANT_ID") } + +func apiKey() string { return os.Getenv("COINPAY_API_KEY") } +func apiBase() string { + return trimSlash(envOr("AGENTBBS_COINPAY_API_URL", "https://coinpayportal.com/api")) +} + +// APIConfigured reports whether live CoinPay calls can be made. +func APIConfigured() bool { return apiKey() != "" && MerchantID() != "" } + func envOr(k, def string) string { if v := os.Getenv(k); v != "" { return v @@ -47,147 +61,156 @@ func envOr(k, def string) string { 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"` +func trimSlash(s string) string { + for len(s) > 0 && s[len(s)-1] == '/' { + s = s[:len(s)-1] + } + return s } -// PremiumReference derives the stable CoinPay memo for a user's lifetime -// membership from their key fingerprint. +// Charge is a created CoinPay payment a user must fund: a unique deposit +// address, the crypto amount, the fiat amount it settles, and the payment id +// (store it to verify settlement later). +type Charge struct { + Address string + CryptoAmount string + Currency string + FiatAmount string + FiatCurrency string + ID string + QR string +} + +// PremiumReference derives a stable memo for a user's lifetime membership from +// their key fingerprint; sent as payment metadata for reconciliation. 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. +// Reference derives a stable, short payment reference for a user+plan. 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 +// coinpayPayment is the (subset of the) CoinPay payment object, returned +// wrapped as {"payment": {…}}. +type coinpayPayment struct { + ID string `json:"id"` + Status string `json:"status"` + Address string `json:"payment_address"` + CryptoAmount string `json:"crypto_amount"` + CryptoCurr string `json:"crypto_currency"` + QR string `json:"qr_code"` } -// 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) { - return runVerify(os.Getenv("AGENTBBS_COINPAY_VERIFY_CMD"), ref) +type paymentEnvelope struct { + Payment coinpayPayment `json:"payment"` } -// 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 == "" { +// coinpayDo performs an authenticated CoinPay API call and decodes the +// {"payment":{…}} envelope. +func coinpayDo(ctx context.Context, method, path string, body any) (*paymentEnvelope, error) { + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + rdr = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, apiBase()+path, rdr) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey()) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("coinpay %s %s: %s: %s", method, path, resp.Status, bytesTrim(raw)) + } + var env paymentEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return nil, fmt.Errorf("coinpay %s %s: bad response: %w", method, path, err) + } + return &env, nil +} + +func bytesTrim(b []byte) string { return string(bytes.TrimSpace(b)) } + +// CreatePremiumCharge creates a CoinPay payment for the lifetime membership and +// returns the deposit address, amount, and payment id (store the id to verify +// settlement later). created is false when CoinPay isn't configured. +func CreatePremiumCharge(ref string) (Charge, bool, error) { + if !APIConfigured() { + return Charge{}, false, nil + } + amount := json.Number(PremiumAmount()) + if f, err := strconv.ParseFloat(PremiumAmount(), 64); err == nil { + amount = json.Number(strconv.FormatFloat(f, 'f', -1, 64)) + } + body := map[string]any{ + "business_id": MerchantID(), + "amount": amount, + "currency": PremiumCurrency(), + "blockchain": toUpper(PremiumBlockchain()), + "description": "AgentBBS Premium membership (lifetime)", + "metadata": map[string]string{"ref": ref}, + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + env, err := coinpayDo(ctx, http.MethodPost, "/payments/create", body) + if err != nil { + return Charge{}, false, err + } + p := env.Payment + if p.Address == "" { + return Charge{}, false, fmt.Errorf("coinpay: payment created without an address") + } + return Charge{ + Address: p.Address, + CryptoAmount: p.CryptoAmount, + Currency: p.CryptoCurr, + FiatAmount: PremiumAmount(), + FiatCurrency: PremiumCurrency(), + ID: p.ID, + QR: p.QR, + }, true, nil +} + +// VerifyPremium reports whether a created premium payment has settled. payID is +// the CoinPay payment id from CreatePremiumCharge. checked is false when we +// could not reach CoinPay (caller falls back to store state); paid is true on a +// confirmed/forwarded status. +func VerifyPremium(payID string) (paid bool, checked bool) { + if payID == "" || !APIConfigured() { return false, false } - line := tmpl - if strings.Contains(tmpl, "%s") { - line = fmt.Sprintf(tmpl, ref) - } else { - line = tmpl + " " + ref + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + env, err := coinpayDo(ctx, http.MethodGet, "/payments/"+url.PathEscape(payID), nil) + if err != nil { + return false, false // network/API error — fall back to store state } - parts := strings.Fields(line) - if len(parts) == 0 { - return false, false - } - if _, err := exec.LookPath(parts[0]); err != nil { - return false, false - } - if err := exec.Command(parts[0], parts[1:]...).Run(); err != nil { + switch env.Payment.Status { + case "confirmed", "forwarded", "completed", "paid": + return true, true + default: return false, true } - return true, true +} + +func toUpper(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'a' && c <= 'z' { + b[i] = c - 32 + } + } + return string(b) } diff --git a/internal/payments/payments_test.go b/internal/payments/payments_test.go new file mode 100644 index 0000000..e2dd052 --- /dev/null +++ b/internal/payments/payments_test.go @@ -0,0 +1,78 @@ +package payments + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCreateAndVerifyPremium(t *testing.T) { + var gotAuth, gotBlockchain, gotBusiness string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/payments/create": + var body map[string]any + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &body) + gotBlockchain, _ = body["blockchain"].(string) + gotBusiness, _ = body["business_id"].(string) + _, _ = w.Write([]byte(`{"payment":{"id":"pay_1","payment_address":"0xABC","crypto_amount":"0.0031","crypto_currency":"ETH","status":"pending"}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/payments/pay_1": + _, _ = w.Write([]byte(`{"payment":{"id":"pay_1","status":"confirmed"}}`)) + default: + http.Error(w, "not found", http.StatusNotFound) + } + })) + defer srv.Close() + + t.Setenv("AGENTBBS_COINPAY_API_URL", srv.URL) + t.Setenv("COINPAY_API_KEY", "cp_test_key") + t.Setenv("AGENTBBS_COINPAY_MERCHANT_ID", "biz_42") + + if !APIConfigured() { + t.Fatal("APIConfigured should be true with key + merchant id") + } + + c, ok, err := CreatePremiumCharge("abbs-premium-deadbeef") + if err != nil || !ok { + t.Fatalf("create: ok=%v err=%v", ok, err) + } + if c.Address != "0xABC" || c.ID != "pay_1" || c.CryptoAmount != "0.0031" { + t.Fatalf("charge fields wrong: %+v", c) + } + if gotAuth != "Bearer cp_test_key" { + t.Fatalf("auth header = %q", gotAuth) + } + if gotBlockchain != "ETH" { + t.Fatalf("blockchain not upper-cased: %q", gotBlockchain) + } + if gotBusiness != "biz_42" { + t.Fatalf("business_id = %q", gotBusiness) + } + + if paid, checked := VerifyPremium("pay_1"); !checked || !paid { + t.Fatalf("verify confirmed: paid=%v checked=%v", paid, checked) + } + // Empty id is a clean "not checked". + if _, checked := VerifyPremium(""); checked { + t.Fatal("empty payID must not be checked") + } +} + +func TestNotConfigured(t *testing.T) { + t.Setenv("COINPAY_API_KEY", "") + t.Setenv("AGENTBBS_COINPAY_MERCHANT_ID", "") + if APIConfigured() { + t.Fatal("must not be configured without key/merchant") + } + if _, ok, err := CreatePremiumCharge("ref"); ok || err != nil { + t.Fatalf("unconfigured create: ok=%v err=%v", ok, err) + } + if _, checked := VerifyPremium("pay_1"); checked { + t.Fatal("unconfigured verify must not be checked") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index dc7379f..fdf2a22 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -18,20 +18,21 @@ type User struct { PubKeyFP string Email string EmailVerified bool - Premium bool // paid the one-time lifetime membership + Premium bool // paid the one-time lifetime membership + PremiumPayID string // CoinPay payment id of the pending/settled premium charge CreatedAt time.Time } // userCols is the column list (in struct order) for every user SELECT, kept in // sync with scanUser. -const userCols = `id, name, kind, pubkey_fp, email, email_verified, premium, created_at` +const userCols = `id, name, kind, pubkey_fp, email, email_verified, premium, premium_pay_id, created_at` // scanUser reads one user row selected with userCols. func scanUser(sc interface{ Scan(...any) error }) (User, error) { var u User var verified, premium int var created string - if err := sc.Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &u.Email, &verified, &premium, &created); err != nil { + if err := sc.Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &u.Email, &verified, &premium, &u.PremiumPayID, &created); err != nil { return User{}, err } u.EmailVerified = verified != 0 @@ -74,6 +75,9 @@ type Store interface { // and clears the code. Returns ok=false on a wrong/empty code. ConfirmEmailCode(userID int64, code string) (User, bool, error) + // SetPremiumPayment records the CoinPay payment id of a pending premium + // charge so a later visit can verify whether it settled. + SetPremiumPayment(userID int64, payID string) error // GrantPremium marks the account as a lifetime premium member (the $10 // one-time membership), recording the CoinPay payment reference. Idempotent. GrantPremium(userID int64, paymentRef string) error @@ -154,6 +158,7 @@ func migrate(db *sql.DB) error { {"verify_token", "verify_token TEXT NOT NULL DEFAULT ''"}, {"premium", "premium INTEGER NOT NULL DEFAULT 0"}, {"premium_ref", "premium_ref TEXT NOT NULL DEFAULT ''"}, + {"premium_pay_id", "premium_pay_id TEXT NOT NULL DEFAULT ''"}, }) } @@ -312,6 +317,11 @@ func (s *sqliteStore) ConfirmEmailCode(userID int64, code string) (User, bool, e return u, true, nil } +func (s *sqliteStore) SetPremiumPayment(userID int64, payID string) error { + _, err := s.db.Exec(`UPDATE users SET premium_pay_id = ? WHERE id = ?`, payID, userID) + return err +} + func (s *sqliteStore) GrantPremium(userID int64, paymentRef string) error { _, err := s.db.Exec(`UPDATE users SET premium = 1, premium_ref = ? WHERE id = ?`, paymentRef, userID) return err diff --git a/internal/store/store_premium_test.go b/internal/store/store_premium_test.go index 8c30b19..20a9fa6 100644 --- a/internal/store/store_premium_test.go +++ b/internal/store/store_premium_test.go @@ -67,3 +67,23 @@ func TestGrantPremium(t *testing.T) { t.Fatalf("re-grant: %v", err) } } + +func TestSetPremiumPayment(t *testing.T) { + st, err := Open(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + defer st.Close() + + u, _ := st.EnsureUser("erin", "member", "SHA256:eee") + if u.PremiumPayID != "" { + t.Fatal("new user must have no premium pay id") + } + if err := st.SetPremiumPayment(u.ID, "pay_abc123"); err != nil { + t.Fatalf("set: %v", err) + } + got, _, _ := st.UserByFingerprint("SHA256:eee") + if got.PremiumPayID != "pay_abc123" { + t.Fatalf("PremiumPayID = %q", got.PremiumPayID) + } +} diff --git a/setup.sh b/setup.sh index 376f225..2e96d66 100755 --- a/setup.sh +++ b/setup.sh @@ -186,15 +186,17 @@ AGENTBBS_HTTP_ADDR=${HTTP_ADDR} # Premium \$10 one-time, lifetime — a personal @${DOMAIN} email # (forwardemail.net) plus custom domains (ssh domain@). Offered at join@. -# Premium payment via the coinpay CLI: join@ mints a charge and shows the amount -# + deposit address; the status command verifies a later settlement. %s is the -# per-account payment reference. +# Premium payments hit the CoinPay REST API directly (no coinpay CLI needed): +# join@ creates a charge and shows the amount + deposit address; a later connect +# verifies settlement by payment id. COINPAY_API_KEY + the merchant id are +# injected by the CI deploy from GitHub secrets (COINPAY_API_KEY / +# COINPAY_MERCHANT_ID); set them here directly when provisioning by hand: +# COINPAY_API_KEY=cp_live_xxx +# AGENTBBS_COINPAY_MERCHANT_ID= +# AGENTBBS_COINPAY_API_URL=https://coinpayportal.com/api # AGENTBBS_PREMIUM_AMOUNT=10 # AGENTBBS_PREMIUM_CURRENCY=USD # AGENTBBS_PREMIUM_BLOCKCHAIN=eth -# AGENTBBS_COINPAY_PREMIUM_CREATE_CMD=coinpay payment create --amount 10 --currency USD --blockchain eth --json --metadata %s -# AGENTBBS_COINPAY_PREMIUM_PAY_TMPL=coinpay payment create --amount 10 --currency USD --blockchain eth --metadata %s -# AGENTBBS_COINPAY_PREMIUM_STATUS_CMD=coinpay payment status %s # Premium email aliases (@${DOMAIN}) auto-created on forwardemail.net. # Without an API key the address is shown but not created (add it manually). @@ -213,6 +215,26 @@ ENV chmod 0640 "$ENV_DIR/agentbbs.env" fi +# Idempotently upsert secrets passed in the environment (e.g. by the CI deploy +# from GitHub Actions secrets) into agentbbs.env, preserving everything else. +# Secrets are never committed — they live only here and in encrypted CI storage. +upsert_env() { # KEY VALUE — skips when VALUE is empty + local key="$1" val="$2" file="$ENV_DIR/agentbbs.env" + [ -n "$val" ] || return 0 + touch "$file" + if grep -qE "^${key}=" "$file"; then + # Replace in place (| delimiter avoids clashes with / or & in the value). + sed -i "s|^${key}=.*|${key}=${val}|" "$file" + else + printf '%s=%s\n' "$key" "$val" >> "$file" + fi + chmod 0640 "$file" +} +# CoinPay: API key (read by the coinpay CLI) + merchant/business id. +upsert_env COINPAY_API_KEY "${COINPAY_API_KEY:-}" +upsert_env AGENTBBS_COINPAY_MERCHANT_ID "${COINPAY_MERCHANT_ID:-${AGENTBBS_COINPAY_MERCHANT_ID:-}}" +upsert_env COINPAY_BUSINESS_ID "${COINPAY_MERCHANT_ID:-${AGENTBBS_COINPAY_MERCHANT_ID:-}}" + # ---- 7. systemd unit (runs as $SVC_USER, binds :22 via ambient capability) -- log "installing agentbbs.service" cat > /etc/systemd/system/agentbbs.service <