forked from Nixius/authelia
1
0
Fork 0

Path-based routing, tiered pricing, customer details, Stripe MCP

- Path-based routing: bc.a250.ca/login, /dashboard, /activate, etc.
- Tiered pricing: first 10 free (3mo), next 40 $20/yr->$100/mo, 51+ $200/mo
- Success page: instructs to check inbox for password + 2FA setup
- Messaging: LANDING_TAGLINE, LANDING_FEATURES env vars
- Domain validation: required, must resolve via DNS
- Customer details: phone required, sanitized email/phone, stored in LLDAP
- Stripe MCP: .cursor/mcp.json, stripe-setup.sh for products/prices
- Invoice webhook: migrate $20/yr subs to $100/mo on renewal

Made-with: Cursor
This commit is contained in:
Leopere 2026-03-04 17:05:42 -05:00
parent 7e40fea6f3
commit 4ac4de9df2
Signed by: colin
SSH Key Fingerprint: SHA256:nRPCQTeMFLdGytxRQmPVK9VXY3/ePKQ5lGRyJhT5DY8
25 changed files with 1008 additions and 374 deletions

11
.cursor/mcp.json Normal file
View File

@ -0,0 +1,11 @@
{
"mcpServers": {
"stripe": {
"command": "sh",
"args": [
"-c",
"set -a && [ -f .env ] && . ./.env; set +a; exec npx -y @stripe/mcp@latest"
]
}
}
}

View File

@ -58,8 +58,8 @@ session:
remember_me: 1M remember_me: 1M
cookies: cookies:
- domain: {{ env "TRAEFIK_DOMAIN" }} - domain: {{ env "TRAEFIK_DOMAIN" }}
authelia_url: 'https://login.{{ env "TRAEFIK_DOMAIN" }}' authelia_url: 'https://{{ env "TRAEFIK_DOMAIN" }}/login'
default_redirection_url: 'https://app.{{ env "TRAEFIK_DOMAIN" }}/dashboard' default_redirection_url: 'https://{{ env "TRAEFIK_DOMAIN" }}/dashboard'
name: 'authelia_session' name: 'authelia_session'
same_site: 'lax' same_site: 'lax'
inactivity: '5m' inactivity: '5m'

View File

@ -1,15 +1,27 @@
package config package config
import "os" import (
"fmt"
"os"
"strings"
)
type Config struct { type Config struct {
Port string Port string
AppURL string AppURL string
AutheliaURL string AutheliaURL string
AutheliaInternalURL string AutheliaInternalURL string
StripeSecretKey string StripeSecretKey string
StripeWebhookSecret string StripeWebhookSecret string
StripePriceID string StripePriceID string // Fallback when tier prices not set
StripePaymentLink string // Optional: legacy Payment Link for $0
StripePriceIDFree string // $0/3mo, auto-cancel (first 10)
StripePriceIDYear string // $20/year (customers 1150)
StripePriceIDMonth100 string // $100/month (after year for 1150)
StripePriceIDMonth200 string // $200/month (customers 51+)
FreeTierLimit int // First N get free tier (default 10)
YearTierLimit int // Up to this count get year tier (default 50)
MaxSignups int // Cap on new signups (0 = no limit)
LDAPUrl string LDAPUrl string
LDAPAdminDN string LDAPAdminDN string
LDAPAdminPassword string LDAPAdminPassword string
@ -18,31 +30,45 @@ type Config struct {
DockerHost string DockerHost string
TraefikDomain string TraefikDomain string
TraefikNetwork string TraefikNetwork string
TemplatePath string TemplatePath string
CustomerDomain string CustomerDomain string
ArchivePath string ArchivePath string
LandingTagline string // Main tagline under logo
LandingFeatures []string // Bullet points on subscribe card (comma-separated in env)
} }
func Load() *Config { func Load() *Config {
return &Config{ return &Config{
Port: envOrDefault("PORT", "8080"), Port: envOrDefault("PORT", "8080"),
AppURL: envOrDefault("APP_URL", "http://app.bc.a250.ca"), AppURL: envOrDefault("APP_URL", "https://bc.a250.ca"),
AutheliaURL: envOrDefault("AUTHELIA_URL", "http://login.bc.a250.ca"), AutheliaURL: envOrDefault("AUTHELIA_URL", "https://bc.a250.ca/login"),
AutheliaInternalURL: envOrDefault("AUTHELIA_INTERNAL_URL", "http://authelia_dev_main:9091"), AutheliaInternalURL: envOrDefault("AUTHELIA_INTERNAL_URL", "http://authelia_dev_main:9091/login"),
StripeSecretKey: envOrDefault("STRIPE_SECRET_KEY", ""), StripeSecretKey: envOrDefault("STRIPE_SECRET_KEY", ""),
StripeWebhookSecret: envOrDefault("STRIPE_WEBHOOK_SECRET", ""), StripeWebhookSecret: envOrDefault("STRIPE_WEBHOOK_SECRET", ""),
StripePriceID: envOrDefault("STRIPE_PRICE_ID", ""), StripePriceID: envOrDefault("STRIPE_PRICE_ID", ""),
LDAPUrl: envOrDefault("LLDAP_URL", "ldap://lldap_lldap:3890"), StripePaymentLink: envOrDefault("STRIPE_PAYMENT_LINK", ""),
LDAPAdminDN: envOrDefault("LLDAP_ADMIN_DN", "uid=admin,ou=people,dc=a250,dc=ca"), StripePriceIDFree: envOrDefault("STRIPE_PRICE_ID_FREE", ""),
LDAPAdminPassword: envOrDefault("LLDAP_ADMIN_PASSWORD", ""), StripePriceIDYear: envOrDefault("STRIPE_PRICE_ID_YEAR", ""),
LDAPBaseDN: envOrDefault("LLDAP_BASE_DN", "dc=a250,dc=ca"), StripePriceIDMonth100: envOrDefault("STRIPE_PRICE_ID_MONTH_100", ""),
LLDAPHttpURL: envOrDefault("LLDAP_HTTP_URL", "http://lldap:17170"), StripePriceIDMonth200: envOrDefault("STRIPE_PRICE_ID_MONTH_200", ""),
DockerHost: envOrDefault("DOCKER_HOST", "unix:///var/run/docker.sock"), FreeTierLimit: envIntOrDefault("FREE_TIER_LIMIT", 10),
TraefikDomain: envOrDefault("TRAEFIK_DOMAIN", "bc.a250.ca"), YearTierLimit: envIntOrDefault("YEAR_TIER_LIMIT", 50),
TraefikNetwork: envOrDefault("TRAEFIK_NETWORK", "authelia_dev"), MaxSignups: envIntOrDefault("MAX_SIGNUPS", 0),
LDAPUrl: envOrDefault("LLDAP_URL", "ldap://lldap_lldap:3890"),
LDAPAdminDN: envOrDefault("LLDAP_ADMIN_DN", "uid=admin,ou=people,dc=a250,dc=ca"),
LDAPAdminPassword: envOrDefault("LLDAP_ADMIN_PASSWORD", ""),
LDAPBaseDN: envOrDefault("LLDAP_BASE_DN", "dc=a250,dc=ca"),
LLDAPHttpURL: envOrDefault("LLDAP_HTTP_URL", "http://lldap:17170"),
DockerHost: envOrDefault("DOCKER_HOST", "unix:///var/run/docker.sock"),
TraefikDomain: envOrDefault("TRAEFIK_DOMAIN", "bc.a250.ca"),
TraefikNetwork: envOrDefault("TRAEFIK_NETWORK", "authelia_dev"),
TemplatePath: envOrDefault("TEMPLATE_PATH", "/app/templates"), TemplatePath: envOrDefault("TEMPLATE_PATH", "/app/templates"),
CustomerDomain: envOrDefault("CUSTOMER_DOMAIN", "app.a250.ca"), CustomerDomain: envOrDefault("CUSTOMER_DOMAIN", "bc.a250.ca"),
ArchivePath: envOrDefault("ARCHIVE_PATH", "/archives"), ArchivePath: envOrDefault("ARCHIVE_PATH", "/archives"),
LandingTagline: envOrDefault("LANDING_TAGLINE",
"Your own workspace, ready in minutes."),
LandingFeatures: envListOrDefault("LANDING_FEATURES",
[]string{"Dedicated environment", "Secure single sign-on", "Automatic provisioning", "Manage subscription anytime"}),
} }
} }
@ -53,3 +79,29 @@ func envOrDefault(key, fallback string) string {
return fallback return fallback
} }
func envIntOrDefault(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err == nil {
return n
}
}
return fallback
}
func envListOrDefault(key string, fallback []string) []string {
if v := os.Getenv(key); v != "" {
parts := strings.Split(v, "|")
out := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
}
}
if len(out) > 0 {
return out
}
}
return fallback
}

View File

@ -26,8 +26,8 @@ func TestEnvOrDefault(t *testing.T) {
func TestLoadDefaults(t *testing.T) { func TestLoadDefaults(t *testing.T) {
// Clear env vars that Load uses // Clear env vars that Load uses
envKeys := []string{ envKeys := []string{
"PORT", "APP_URL", "AUTHELIA_URL", "STRIPE_SECRET_KEY", "PORT", "APP_URL", "AUTHELIA_URL", "STRIPE_SECRET_KEY",
"STRIPE_WEBHOOK_SECRET", "STRIPE_PRICE_ID", "LLDAP_URL", "STRIPE_WEBHOOK_SECRET", "STRIPE_PRICE_ID", "STRIPE_PAYMENT_LINK", "MAX_SIGNUPS", "LLDAP_URL",
"LLDAP_ADMIN_DN", "LLDAP_ADMIN_PASSWORD", "LLDAP_BASE_DN", "LLDAP_ADMIN_DN", "LLDAP_ADMIN_PASSWORD", "LLDAP_BASE_DN",
"DOCKER_HOST", "TRAEFIK_DOMAIN", "TRAEFIK_NETWORK", "TEMPLATE_PATH", "CUSTOMER_DOMAIN", "DOCKER_HOST", "TRAEFIK_DOMAIN", "TRAEFIK_NETWORK", "TEMPLATE_PATH", "CUSTOMER_DOMAIN",
} }
@ -48,8 +48,8 @@ func TestLoadDefaults(t *testing.T) {
want string want string
}{ }{
{"Port", cfg.Port, "8080"}, {"Port", cfg.Port, "8080"},
{"AppURL", cfg.AppURL, "http://app.bc.a250.ca"}, {"AppURL", cfg.AppURL, "https://bc.a250.ca"},
{"AutheliaURL", cfg.AutheliaURL, "http://login.bc.a250.ca"}, {"AutheliaURL", cfg.AutheliaURL, "https://bc.a250.ca/login"},
{"LDAPUrl", cfg.LDAPUrl, "ldap://lldap_lldap:3890"}, {"LDAPUrl", cfg.LDAPUrl, "ldap://lldap_lldap:3890"},
{"LDAPAdminDN", cfg.LDAPAdminDN, "uid=admin,ou=people,dc=a250,dc=ca"}, {"LDAPAdminDN", cfg.LDAPAdminDN, "uid=admin,ou=people,dc=a250,dc=ca"},
{"LDAPBaseDN", cfg.LDAPBaseDN, "dc=a250,dc=ca"}, {"LDAPBaseDN", cfg.LDAPBaseDN, "dc=a250,dc=ca"},
@ -57,13 +57,22 @@ func TestLoadDefaults(t *testing.T) {
{ "TraefikDomain", cfg.TraefikDomain, "bc.a250.ca"}, { "TraefikDomain", cfg.TraefikDomain, "bc.a250.ca"},
{"TraefikNetwork", cfg.TraefikNetwork, "authelia_dev"}, {"TraefikNetwork", cfg.TraefikNetwork, "authelia_dev"},
{"TemplatePath", cfg.TemplatePath, "/app/templates"}, {"TemplatePath", cfg.TemplatePath, "/app/templates"},
{"CustomerDomain", cfg.CustomerDomain, "app.a250.ca"}, {"CustomerDomain", cfg.CustomerDomain, "bc.a250.ca"},
} }
for _, tt := range tests { for _, tt := range tests {
if tt.got != tt.want { if tt.got != tt.want {
t.Errorf("Load().%s = %q, want %q", tt.field, tt.got, tt.want) t.Errorf("Load().%s = %q, want %q", tt.field, tt.got, tt.want)
} }
} }
if cfg.MaxSignups != 0 {
t.Errorf("Load().MaxSignups = %d, want 0", cfg.MaxSignups)
}
if cfg.FreeTierLimit != 10 {
t.Errorf("Load().FreeTierLimit = %d, want 10", cfg.FreeTierLimit)
}
if cfg.YearTierLimit != 50 {
t.Errorf("Load().YearTierLimit = %d, want 50", cfg.YearTierLimit)
}
} }
func TestLoadFromEnv(t *testing.T) { func TestLoadFromEnv(t *testing.T) {

View File

@ -27,6 +27,14 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
stackDeployed := false stackDeployed := false
stackRunning := false stackRunning := false
var subStatus *ssstripe.SubscriptionStatus var subStatus *ssstripe.SubscriptionStatus
paidNotActivated := false
if remoteUser != "" {
cid, _ := a.ldap.GetStripeCustomerID(remoteUser)
if cid != "" && !isSubscribed {
paidNotActivated = true
}
}
if isSubscribed && remoteUser != "" { if isSubscribed && remoteUser != "" {
cid, err := a.ldap.GetStripeCustomerID(remoteUser) cid, err := a.ldap.GetStripeCustomerID(remoteUser)
@ -53,20 +61,27 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
} }
} }
customerDomain := ""
if remoteUser != "" {
customerDomain, _ = a.ldap.GetCustomerDomain(remoteUser)
}
data := map[string]any{ data := map[string]any{
"AppURL": a.cfg.AppURL, "AppURL": a.cfg.AppURL,
"AutheliaURL": a.cfg.AutheliaURL, "AutheliaURL": a.cfg.AutheliaURL,
"User": remoteUser, "User": remoteUser,
"Email": remoteEmail, "Email": remoteEmail,
"Groups": remoteGroups, "Groups": remoteGroups,
"Domain": a.cfg.TraefikDomain, "Domain": a.cfg.TraefikDomain,
"IsSubscribed": isSubscribed, "IsSubscribed": isSubscribed,
"PaidNotActivated": paidNotActivated,
"CustomerID": customerID, "CustomerID": customerID,
"SubStatus": subStatus, "SubStatus": subStatus,
"StackDeployed": stackDeployed, "StackDeployed": stackDeployed,
"StackRunning": stackRunning, "StackRunning": stackRunning,
"Commit": version.Commit, "CustomerDomain": customerDomain,
"BuildTime": version.BuildTime, "Commit": version.Commit,
"BuildTime": version.BuildTime,
} }
if err := a.tmpl.ExecuteTemplate(w, "dashboard.html", data); err != nil { if err := a.tmpl.ExecuteTemplate(w, "dashboard.html", data); err != nil {

View File

@ -56,13 +56,14 @@ func TestSanitizeUsername(t *testing.T) {
email string email string
want string want string
}{ }{
{"user@example.com", "user"}, {"user@example.com", "user-example"},
{"User.Name@domain.com", "user-name"}, {"User.Name@domain.com", "user-name-domain"},
{"user_name@domain.com", "user_name"}, {"user_name@domain.com", "user_name-domain"},
{"user123@domain.com", "user123"}, {"user123@domain.com", "user123-domain"},
{"UPPER@domain.com", "upper"}, {"UPPER@domain.com", "upper-domain"},
{"a.b.c@x.com", "a-b-c"}, {"a.b.c@x.com", "a-b-c-x"},
{"spécial@x.com", "sp-cial"}, {"spécial@x.com", "sp-cial-x"},
{"alice@nixc.us", "alice-nixc"},
} }
for _, tt := range tests { for _, tt := range tests {
if got := sanitizeUsername(tt.email); got != tt.want { if got := sanitizeUsername(tt.email); got != tt.want {

View File

@ -1,11 +1,16 @@
package handlers package handlers
import ( import (
"errors"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"strings" "strings"
"time"
"git.nixc.us/a250/ss-atlas/internal/pricing"
"git.nixc.us/a250/ss-atlas/internal/stripe"
"git.nixc.us/a250/ss-atlas/internal/validation"
"git.nixc.us/a250/ss-atlas/internal/version" "git.nixc.us/a250/ss-atlas/internal/version"
) )
@ -26,10 +31,22 @@ func (a *App) handleLanding(w http.ResponseWriter, r *http.Request) {
} }
} }
count, _ := a.ldap.CountCustomers()
soldOut := a.cfg.MaxSignups > 0 && count >= a.cfg.MaxSignups
tier := pricing.ForCustomer(count, a.cfg.FreeTierLimit, a.cfg.YearTierLimit)
useForm := a.cfg.StripePriceID != "" || a.cfg.StripePriceIDFree != "" ||
a.cfg.StripePriceIDYear != "" || a.cfg.StripePriceIDMonth200 != ""
data := map[string]any{ data := map[string]any{
"AppURL": a.cfg.AppURL, "AppURL": a.cfg.AppURL,
"Commit": version.Commit, "Commit": version.Commit,
"BuildTime": version.BuildTime, "BuildTime": version.BuildTime,
"StripePaymentLink": a.cfg.StripePaymentLink,
"SoldOut": soldOut,
"PricingTier": int(tier),
"UseCheckoutForm": useForm,
"Tagline": a.cfg.LandingTagline,
"Features": a.cfg.LandingFeatures,
} }
if err := a.tmpl.ExecuteTemplate(w, "landing.html", data); err != nil { if err := a.tmpl.ExecuteTemplate(w, "landing.html", data); err != nil {
log.Printf("template error: %v", err) log.Printf("template error: %v", err)
@ -38,14 +55,43 @@ func (a *App) handleLanding(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) handleCreateCheckout(w http.ResponseWriter, r *http.Request) { func (a *App) handleCreateCheckout(w http.ResponseWriter, r *http.Request) {
email := r.FormValue("email") if a.cfg.MaxSignups > 0 {
count, err := a.ldap.CountCustomers()
if err == nil && count >= a.cfg.MaxSignups {
http.Error(w, "signup limit reached, try again later", http.StatusServiceUnavailable)
return
}
}
rawEmail := r.FormValue("email")
email := validation.SanitizeEmail(rawEmail)
if email == "" { if email == "" {
http.Error(w, "email required", http.StatusBadRequest) http.Error(w, "valid email required", http.StatusBadRequest)
return
}
domain := strings.TrimSpace(r.FormValue("domain"))
if domain == "" {
http.Error(w, "domain required", http.StatusBadRequest)
return
}
if !validation.DomainResolves(domain, 5*time.Second) {
http.Error(w, "domain does not resolve; please enter a valid domain", http.StatusBadRequest)
return
}
rawPhone := r.FormValue("phone")
phone := validation.SanitizePhone(rawPhone)
if phone == "" {
http.Error(w, "valid phone number required", http.StatusBadRequest)
return return
} }
sess, err := a.stripe.CreateCheckoutSession(email) count, _ := a.ldap.CountCustomers()
sess, err := a.stripe.CreateCheckoutSession(email, domain, phone, count)
if err != nil { if err != nil {
if errors.Is(err, stripe.ErrNoPriceForTier) {
http.Error(w, "pricing not configured for current tier", http.StatusServiceUnavailable)
return
}
log.Printf("stripe checkout error: %v", err) log.Printf("stripe checkout error: %v", err)
http.Error(w, "failed to create checkout", http.StatusInternalServerError) http.Error(w, "failed to create checkout", http.StatusInternalServerError)
return return
@ -61,6 +107,14 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
return return
} }
if a.cfg.MaxSignups > 0 {
count, err := a.ldap.CountCustomers()
if err == nil && count >= a.cfg.MaxSignups {
http.Error(w, "signup limit reached, contact support", http.StatusServiceUnavailable)
return
}
}
sess, err := a.stripe.GetCheckoutSession(sessionID) sess, err := a.stripe.GetCheckoutSession(sessionID)
if err != nil { if err != nil {
log.Printf("stripe get session error: %v", err) log.Printf("stripe get session error: %v", err)
@ -76,32 +130,32 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
email := sess.CustomerDetails.Email email := sess.CustomerDetails.Email
customerID := sess.Customer.ID customerID := sess.Customer.ID
username := sanitizeUsername(email) username := sanitizeUsername(email)
phone := ""
if sess.Metadata != nil {
phone = sess.Metadata["customer_phone"]
}
result, err := a.ldap.ProvisionUser(username, email, customerID) result, err := a.ldap.ProvisionUser(username, email, customerID, phone)
if err != nil { if err != nil {
log.Printf("ldap provision failed for %s: %v", email, err) log.Printf("ldap provision failed for %s: %v", email, err)
http.Error(w, "account creation failed, contact support", http.StatusInternalServerError) http.Error(w, "account creation failed, contact support", http.StatusInternalServerError)
return return
} }
if sess.Metadata != nil && sess.Metadata["customer_domain"] != "" {
if err := a.ldap.SetCustomerDomain(result.Username, sess.Metadata["customer_domain"]); err != nil {
log.Printf("ldap set customer domain failed for %s: %v", result.Username, err)
}
}
inGroup, _ := a.ldap.IsInGroup(result.Username, "customers") inGroup, _ := a.ldap.IsInGroup(result.Username, "customers")
if result.IsNew || !inGroup { if result.IsNew || !inGroup {
// New or lapsed customer: send password setup email, show onboarding. // New or lapsed: send password email, show success page.
// Group membership and stack deploy happen on /activate after they log in.
if err := a.triggerPasswordReset(result.Username); err != nil { if err := a.triggerPasswordReset(result.Username); err != nil {
log.Printf("authelia reset trigger failed for %s: %v", username, err) log.Printf("authelia reset trigger failed for %s: %v", username, err)
} }
data := map[string]any{ if err := a.tmpl.ExecuteTemplate(w, "success.html", map[string]any{"AppURL": a.cfg.AppURL}); err != nil {
"Username": result.Username,
"IsNew": result.IsNew,
"Email": email,
"LoginURL": a.cfg.AutheliaURL,
"ActivateURL": a.cfg.AppURL + "/activate",
"DashboardURL": a.cfg.AppURL + "/dashboard",
"InstanceURL": "https://" + result.Username + "." + a.cfg.CustomerDomain,
}
if err := a.tmpl.ExecuteTemplate(w, "welcome.html", data); err != nil {
log.Printf("template error: %v", err) log.Printf("template error: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
} }
@ -150,8 +204,13 @@ func (a *App) handleResubscribe(w http.ResponseWriter, r *http.Request) {
return return
} }
sess, err := a.stripe.CreateCheckoutForCustomer(customerID) count, _ := a.ldap.CountCustomers()
sess, err := a.stripe.CreateCheckoutForCustomer(customerID, count)
if err != nil { if err != nil {
if errors.Is(err, stripe.ErrNoPriceForTier) {
http.Error(w, "pricing not configured for current tier", http.StatusServiceUnavailable)
return
}
log.Printf("stripe resubscribe error: %v", err) log.Printf("stripe resubscribe error: %v", err)
http.Error(w, "failed to create checkout session", http.StatusInternalServerError) http.Error(w, "failed to create checkout session", http.StatusInternalServerError)
return return

View File

@ -34,6 +34,8 @@ func (a *App) handleWebhook(w http.ResponseWriter, r *http.Request) {
a.onSubscriptionDeleted(event) a.onSubscriptionDeleted(event)
case "customer.subscription.updated": case "customer.subscription.updated":
a.onSubscriptionUpdated(event) a.onSubscriptionUpdated(event)
case "invoice.paid":
a.onInvoicePaid(event)
default: default:
log.Printf("unhandled webhook event: %s", event.Type) log.Printf("unhandled webhook event: %s", event.Type)
} }
@ -42,8 +44,7 @@ func (a *App) handleWebhook(w http.ResponseWriter, r *http.Request) {
} }
// Reconciliation backstop: ensures LLDAP user + Stripe ID are set. // Reconciliation backstop: ensures LLDAP user + Stripe ID are set.
// Does NOT send password reset — that's the success page's responsibility // Does NOT send password reset — that's the success page's responsibility.
// so it can reliably show the welcome/onboarding page.
func (a *App) onCheckoutCompleted(event stripego.Event) { func (a *App) onCheckoutCompleted(event stripego.Event) {
var sess stripego.CheckoutSession var sess stripego.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil { if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
@ -54,12 +55,48 @@ func (a *App) onCheckoutCompleted(event stripego.Event) {
email := sess.CustomerDetails.Email email := sess.CustomerDetails.Email
customerID := sess.Customer.ID customerID := sess.Customer.ID
username := sanitizeUsername(email) username := sanitizeUsername(email)
phone := ""
if sess.Metadata != nil {
phone = sess.Metadata["customer_phone"]
}
log.Printf("webhook: checkout completed email=%s customer=%s", email, customerID) log.Printf("webhook: checkout completed email=%s customer=%s", email, customerID)
if err := a.ldap.EnsureUser(username, email, customerID); err != nil { if a.cfg.MaxSignups > 0 {
count, err := a.ldap.CountCustomers()
if err == nil && count >= a.cfg.MaxSignups {
log.Printf("webhook: signup limit reached (%d), skipping provision for %s", a.cfg.MaxSignups, email)
return
}
}
if err := a.ldap.EnsureUser(username, email, customerID, phone); err != nil {
log.Printf("webhook: ldap ensure user failed: %v", err) log.Printf("webhook: ldap ensure user failed: %v", err)
} }
if sess.Metadata != nil {
if d := sess.Metadata["customer_domain"]; d != "" {
if err := a.ldap.SetCustomerDomain(username, d); err != nil {
log.Printf("webhook: ldap set customer domain failed for %s: %v", username, err)
}
}
}
subID := ""
if sess.Subscription != nil && sess.Subscription.ID != "" {
subID = sess.Subscription.ID
} else {
var raw struct {
Subscription interface{} `json:"subscription"`
}
if json.Unmarshal(event.Data.Raw, &raw) == nil {
if s, ok := raw.Subscription.(string); ok {
subID = s
}
}
}
if subID != "" {
a.maybeScheduleFreeTierCancel(subID)
}
} }
func (a *App) onSubscriptionDeleted(event stripego.Event) { func (a *App) onSubscriptionDeleted(event stripego.Event) {

View File

@ -0,0 +1,96 @@
package handlers
import (
"encoding/json"
"log"
"time"
stripego "github.com/stripe/stripe-go/v84"
"github.com/stripe/stripe-go/v84/subscription"
)
const freeTierMonths = 3
// maybeScheduleFreeTierCancel fetches the subscription and, if it uses the free-tier
// price, sets cancel_at to 3 months from now so it auto-cancels at end of term.
func (a *App) maybeScheduleFreeTierCancel(subID string) {
sub, err := a.stripe.GetSubscription(subID)
if err != nil {
log.Printf("webhook: could not fetch subscription %s: %v", subID, err)
return
}
if sub.Items == nil || len(sub.Items.Data) == 0 {
return
}
priceID := sub.Items.Data[0].Price.ID
if !a.stripe.IsFreeTierPrice(priceID) {
return
}
cancelAt := time.Now().AddDate(0, freeTierMonths, 0).Unix()
if err := a.stripe.ScheduleSubscriptionCancelAt(subID, cancelAt); err != nil {
log.Printf("webhook: failed to schedule free-tier cancel for sub %s: %v", subID, err)
return
}
log.Printf("webhook: scheduled free-tier sub %s to cancel at %d (3 months)", subID, cancelAt)
}
type invoicePaidPayload struct {
BillingReason stripego.InvoiceBillingReason `json:"billing_reason"`
Subscription interface{} `json:"subscription"`
Lines struct {
Data []struct {
Price struct {
ID string `json:"id"`
} `json:"price"`
} `json:"data"`
} `json:"lines"`
}
func (a *App) onInvoicePaid(event stripego.Event) {
var raw invoicePaidPayload
if err := json.Unmarshal(event.Data.Raw, &raw); err != nil {
log.Printf("invoice unmarshal error: %v", err)
return
}
if raw.BillingReason != stripego.InvoiceBillingReasonSubscriptionCycle {
return
}
subID := ""
switch v := raw.Subscription.(type) {
case string:
subID = v
case map[string]interface{}:
if id, ok := v["id"].(string); ok {
subID = id
}
}
if subID == "" || len(raw.Lines.Data) == 0 {
return
}
priceID := raw.Lines.Data[0].Price.ID
if priceID == "" || !a.stripe.IsYearTierPrice(priceID) {
return
}
price100 := a.cfg.StripePriceIDMonth100
if price100 == "" {
log.Printf("webhook: STRIPE_PRICE_ID_MONTH_100 not set, cannot migrate sub %s", subID)
return
}
sub, err := a.stripe.GetSubscription(subID)
if err != nil || sub.Items == nil || len(sub.Items.Data) == 0 {
log.Printf("webhook: could not get subscription %s for migration: %v", subID, err)
return
}
subItemID := sub.Items.Data[0].ID
params := &stripego.SubscriptionParams{
Items: []*stripego.SubscriptionItemsParams{
{ID: stripego.String(subItemID), Price: stripego.String(price100)},
},
ProrationBehavior: stripego.String("none"),
}
if _, err := subscription.Update(subID, params); err != nil {
log.Printf("webhook: failed to migrate sub %s to $100/mo: %v", subID, err)
return
}
log.Printf("webhook: migrated sub %s from $20/year to $100/month", subID)
}

View File

@ -42,7 +42,7 @@ func (c *Client) connect() (*goldap.Conn, error) {
return conn, nil return conn, nil
} }
func (c *Client) ProvisionUser(username, email, stripeCustomerID string) (*ProvisionResult, error) { func (c *Client) ProvisionUser(username, email, stripeCustomerID, phone string) (*ProvisionResult, error) {
conn, err := c.connect() conn, err := c.connect()
if err != nil { if err != nil {
return nil, err return nil, err
@ -55,6 +55,9 @@ func (c *Client) ProvisionUser(username, email, stripeCustomerID string) (*Provi
} }
if exists { if exists {
log.Printf("ldap user %s already exists", username) log.Printf("ldap user %s already exists", username)
if phone != "" {
_ = c.SetCustomerPhone(username, phone)
}
return &ProvisionResult{Username: username, IsNew: false}, nil return &ProvisionResult{Username: username, IsNew: false}, nil
} }
@ -67,6 +70,9 @@ func (c *Client) ProvisionUser(username, email, stripeCustomerID string) (*Provi
addReq.Attribute("sn", []string{username}) addReq.Attribute("sn", []string{username})
addReq.Attribute("uid", []string{username}) addReq.Attribute("uid", []string{username})
addReq.Attribute("mail", []string{email}) addReq.Attribute("mail", []string{email})
if phone != "" {
addReq.Attribute("telephoneNumber", []string{phone})
}
if err := conn.Add(addReq); err != nil { if err := conn.Add(addReq); err != nil {
return nil, fmt.Errorf("ldap add user %s: %w", username, err) return nil, fmt.Errorf("ldap add user %s: %w", username, err)
@ -82,13 +88,18 @@ func (c *Client) ProvisionUser(username, email, stripeCustomerID string) (*Provi
log.Printf("warning: failed to set stripe customer id for %s: %v", username, err) log.Printf("warning: failed to set stripe customer id for %s: %v", username, err)
} }
} }
if phone != "" {
if err := c.SetCustomerPhone(username, phone); err != nil {
log.Printf("warning: failed to set customer phone for %s: %v", username, err)
}
}
log.Printf("created ldap user %s (%s)", username, email) log.Printf("created ldap user %s (%s)", username, email)
return &ProvisionResult{Username: username, Password: password, IsNew: true}, nil return &ProvisionResult{Username: username, Password: password, IsNew: true}, nil
} }
func (c *Client) EnsureUser(username, email, stripeCustomerID string) error { func (c *Client) EnsureUser(username, email, stripeCustomerID, phone string) error {
_, err := c.ProvisionUser(username, email, stripeCustomerID) _, err := c.ProvisionUser(username, email, stripeCustomerID, phone)
return err return err
} }
@ -159,6 +170,59 @@ func (c *Client) SetStripeCustomerID(username, customerID string) error {
return err return err
} }
func (c *Client) SetCustomerPhone(username, phone string) error {
if phone == "" {
return nil
}
query := `mutation($userId: String!, $attrs: [AttributeValueInput!]!) {
updateUser(user: { id: $userId, insertAttributes: $attrs }) { ok }
}`
attrs := []map[string]any{
{"name": "customer-phone", "value": []string{phone}},
}
_, err := c.gql.exec(query, map[string]any{"userId": username, "attrs": attrs})
return err
}
func (c *Client) SetCustomerDomain(username, domain string) error {
if domain == "" {
return nil
}
query := `mutation($userId: String!, $attrs: [AttributeValueInput!]!) {
updateUser(user: { id: $userId, insertAttributes: $attrs }) { ok }
}`
attrs := []map[string]any{
{"name": "customer-domain", "value": []string{domain}},
}
_, err := c.gql.exec(query, map[string]any{"userId": username, "attrs": attrs})
return err
}
func (c *Client) GetCustomerDomain(username string) (string, error) {
query := `query($userId: String!) { user(userId: $userId) { attributes { name value } } }`
data, err := c.gql.exec(query, map[string]any{"userId": username})
if err != nil {
return "", err
}
var result struct {
User struct {
Attributes []struct {
Name string `json:"name"`
Value []string `json:"value"`
} `json:"attributes"`
} `json:"user"`
}
if err := json.Unmarshal(data, &result); err != nil {
return "", err
}
for _, attr := range result.User.Attributes {
if attr.Name == "customer-domain" && len(attr.Value) > 0 {
return attr.Value[0], nil
}
}
return "", nil
}
func (c *Client) GetStripeCustomerID(username string) (string, error) { func (c *Client) GetStripeCustomerID(username string) (string, error) {
query := `query($userId: String!) { user(userId: $userId) { attributes { name value } } }` query := `query($userId: String!) { user(userId: $userId) { attributes { name value } } }`
data, err := c.gql.exec(query, map[string]any{"userId": username}) data, err := c.gql.exec(query, map[string]any{"userId": username})
@ -216,6 +280,39 @@ func (c *Client) FindUserByStripeID(stripeCustomerID string) (string, error) {
return "", fmt.Errorf("no user found with stripe customer %s", stripeCustomerID) return "", fmt.Errorf("no user found with stripe customer %s", stripeCustomerID)
} }
// CountCustomers returns the number of users who have completed checkout (have stripe-customer-id).
// Used to enforce signup limits.
func (c *Client) CountCustomers() (int, error) {
query := `query { users(filters: {}) { id attributes { name value } } }`
data, err := c.gql.exec(query, nil)
if err != nil {
return 0, err
}
var result struct {
Users []struct {
Attributes []struct {
Name string `json:"name"`
Value []string `json:"value"`
} `json:"attributes"`
} `json:"users"`
}
if err := json.Unmarshal(data, &result); err != nil {
return 0, err
}
n := 0
for _, u := range result.Users {
for _, attr := range u.Attributes {
if attr.Name == "stripe-customer-id" && len(attr.Value) > 0 && attr.Value[0] != "" {
n++
break
}
}
}
return n, nil
}
func (c *Client) getGroupID(groupName string) (int, error) { func (c *Client) getGroupID(groupName string) (int, error) {
query := `query { groups { id displayName } }` query := `query { groups { id displayName } }`
data, err := c.gql.exec(query, nil) data, err := c.gql.exec(query, nil)

View File

@ -0,0 +1,22 @@
package pricing
// Tier represents a pricing tier for new signups.
type Tier int
const (
TierFree Tier = iota // First 10: $0 for 3 months, auto-cancel
TierYear // Next 40: $20/year, then $100/month after
TierPremium // After 50: $200/month
)
// ForCustomer returns the tier for a new signup given current customer count.
// Count is the number of customers who have completed checkout (0-indexed slot).
func ForCustomer(count int, freeLimit, yearLimit int) Tier {
if count < freeLimit {
return TierFree
}
if count < yearLimit {
return TierYear
}
return TierPremium
}

View File

@ -1,16 +1,21 @@
package stripe package stripe
import ( import (
"errors"
"log" "log"
"strconv"
"time" "time"
"git.nixc.us/a250/ss-atlas/internal/config" "git.nixc.us/a250/ss-atlas/internal/config"
"git.nixc.us/a250/ss-atlas/internal/pricing"
stripego "github.com/stripe/stripe-go/v84" stripego "github.com/stripe/stripe-go/v84"
portalsession "github.com/stripe/stripe-go/v84/billingportal/session" portalsession "github.com/stripe/stripe-go/v84/billingportal/session"
checkoutsession "github.com/stripe/stripe-go/v84/checkout/session" checkoutsession "github.com/stripe/stripe-go/v84/checkout/session"
"github.com/stripe/stripe-go/v84/subscription" "github.com/stripe/stripe-go/v84/subscription"
) )
var ErrNoPriceForTier = errors.New("no Stripe price configured for this tier")
type SubscriptionStatus struct { type SubscriptionStatus struct {
Label string // "Active", "Cancels soon", etc. Label string // "Active", "Cancels soon", etc.
Badge string // "badge-active", "badge-inactive", etc. Badge string // "badge-active", "badge-inactive", etc.
@ -26,33 +31,63 @@ func New(cfg *config.Config) *Client {
return &Client{cfg: cfg} return &Client{cfg: cfg}
} }
func (c *Client) CreateCheckoutSession(email string) (*stripego.CheckoutSession, error) { func (c *Client) priceForTier(t pricing.Tier) string {
switch t {
case pricing.TierFree:
if c.cfg.StripePriceIDFree != "" {
return c.cfg.StripePriceIDFree
}
case pricing.TierYear:
if c.cfg.StripePriceIDYear != "" {
return c.cfg.StripePriceIDYear
}
case pricing.TierPremium:
if c.cfg.StripePriceIDMonth200 != "" {
return c.cfg.StripePriceIDMonth200
}
}
return c.cfg.StripePriceID
}
func (c *Client) CreateCheckoutSession(email, customerDomain, customerPhone string, customerCount int) (*stripego.CheckoutSession, error) {
t := pricing.ForCustomer(customerCount, c.cfg.FreeTierLimit, c.cfg.YearTierLimit)
priceID := c.priceForTier(t)
if priceID == "" {
return nil, ErrNoPriceForTier
}
params := &stripego.CheckoutSessionParams{ params := &stripego.CheckoutSessionParams{
Mode: stripego.String(string(stripego.CheckoutSessionModeSubscription)), Mode: stripego.String(string(stripego.CheckoutSessionModeSubscription)),
LineItems: []*stripego.CheckoutSessionLineItemParams{ LineItems: []*stripego.CheckoutSessionLineItemParams{
{ {Price: stripego.String(priceID), Quantity: stripego.Int64(1)},
Price: stripego.String(c.cfg.StripePriceID),
Quantity: stripego.Int64(1),
},
}, },
CustomerEmail: stripego.String(email), CustomerEmail: stripego.String(email),
SuccessURL: stripego.String(c.cfg.AppURL + "/success?session_id={CHECKOUT_SESSION_ID}"), SuccessURL: stripego.String(c.cfg.AppURL + "/success?session_id={CHECKOUT_SESSION_ID}"),
CancelURL: stripego.String(c.cfg.AppURL + "/"), CancelURL: stripego.String(c.cfg.AppURL + "/"),
} }
if customerDomain != "" {
params.AddMetadata("customer_domain", customerDomain)
}
if customerPhone != "" {
params.AddMetadata("customer_phone", customerPhone)
}
params.AddMetadata("pricing_tier", strconv.Itoa(int(t)))
return checkoutsession.New(params) return checkoutsession.New(params)
} }
// CreateCheckoutForCustomer creates a new subscription checkout for an existing // CreateCheckoutForCustomer creates a new subscription checkout for an existing
// Stripe customer (e.g. resubscribe after expiry). The new sub is linked to the // Stripe customer (e.g. resubscribe after expiry). Uses current tier by customer count.
// same customer record so payment methods and history are preserved. func (c *Client) CreateCheckoutForCustomer(customerID string, customerCount int) (*stripego.CheckoutSession, error) {
func (c *Client) CreateCheckoutForCustomer(customerID string) (*stripego.CheckoutSession, error) { t := pricing.ForCustomer(customerCount, c.cfg.FreeTierLimit, c.cfg.YearTierLimit)
priceID := c.priceForTier(t)
if priceID == "" {
return nil, ErrNoPriceForTier
}
params := &stripego.CheckoutSessionParams{ params := &stripego.CheckoutSessionParams{
Mode: stripego.String(string(stripego.CheckoutSessionModeSubscription)), Mode: stripego.String(string(stripego.CheckoutSessionModeSubscription)),
LineItems: []*stripego.CheckoutSessionLineItemParams{ LineItems: []*stripego.CheckoutSessionLineItemParams{
{ {Price: stripego.String(priceID), Quantity: stripego.Int64(1)},
Price: stripego.String(c.cfg.StripePriceID),
Quantity: stripego.Int64(1),
},
}, },
Customer: stripego.String(customerID), Customer: stripego.String(customerID),
SuccessURL: stripego.String(c.cfg.AppURL + "/success?session_id={CHECKOUT_SESSION_ID}"), SuccessURL: stripego.String(c.cfg.AppURL + "/success?session_id={CHECKOUT_SESSION_ID}"),
@ -80,6 +115,23 @@ func (c *Client) GetSubscription(subID string) (*stripego.Subscription, error) {
return subscription.Get(subID, nil) return subscription.Get(subID, nil)
} }
// ScheduleSubscriptionCancelAt sets the subscription to cancel at the given unix timestamp.
// Used for free-tier subs that should auto-cancel after 3 months.
func (c *Client) ScheduleSubscriptionCancelAt(subID string, cancelAt int64) error {
_, err := subscription.Update(subID, &stripego.SubscriptionParams{
CancelAt: stripego.Int64(cancelAt),
})
return err
}
func (c *Client) IsFreeTierPrice(priceID string) bool {
return c.cfg.StripePriceIDFree != "" && priceID == c.cfg.StripePriceIDFree
}
func (c *Client) IsYearTierPrice(priceID string) bool {
return c.cfg.StripePriceIDYear != "" && priceID == c.cfg.StripePriceIDYear
}
func (c *Client) GetCustomerSubscriptionStatus(customerID string) *SubscriptionStatus { func (c *Client) GetCustomerSubscriptionStatus(customerID string) *SubscriptionStatus {
if customerID == "" { if customerID == "" {
return &SubscriptionStatus{Label: "Active", Badge: "badge-active"} return &SubscriptionStatus{Label: "Active", Badge: "badge-active"}

View File

@ -0,0 +1,39 @@
package validation
import (
"regexp"
"strings"
)
var emailRe = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
// SanitizeEmail trims, lowercases, and validates email format. Returns empty if invalid.
func SanitizeEmail(input string) string {
s := strings.TrimSpace(strings.ToLower(input))
if s == "" || !emailRe.MatchString(s) || len(s) > 254 {
return ""
}
return s
}
// SanitizePhone normalizes to E.164-like format: + followed by 10-15 digits.
// Accepts (555) 123-4567, +1 555 123 4567, etc.
func SanitizePhone(input string) string {
var digits strings.Builder
hasPlus := false
for _, r := range input {
if r == '+' {
hasPlus = true
} else if r >= '0' && r <= '9' {
digits.WriteRune(r)
}
}
d := digits.String()
if len(d) < 10 || len(d) > 15 {
return ""
}
if hasPlus {
return "+" + d
}
return "+" + d
}

View File

@ -0,0 +1,42 @@
package validation
import (
"context"
"net"
"net/url"
"strings"
"time"
)
// DomainResolves checks that the given domain or URL has a DNS record (A or AAAA).
// Input can be "git.example.com" or "https://git.example.com".
func DomainResolves(input string, timeout time.Duration) bool {
host := extractHost(input)
if host == "" {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var r net.Resolver
addrs, err := r.LookupHost(ctx, host)
return err == nil && len(addrs) > 0
}
func extractHost(input string) string {
s := strings.TrimSpace(strings.ToLower(input))
if s == "" {
return ""
}
if !strings.Contains(s, "//") {
s = "//" + s
}
u, err := url.Parse(s)
if err != nil {
return ""
}
host := u.Hostname()
if host == "" || host == "." {
return ""
}
return host
}

View File

@ -181,9 +181,15 @@
<span class="badge badge-inactive">Not deployed</span> <span class="badge badge-inactive">Not deployed</span>
{{end}} {{end}}
</div> </div>
{{if .CustomerDomain}}
<div class="status-row">
<span class="status-label">Domain to manage</span>
<span class="status-value">{{.CustomerDomain}}</span>
</div>
{{end}}
{{if .StackRunning}} {{if .StackRunning}}
<p style="color: var(--muted); font-size: 0.9rem; margin-top: 0.75rem;">Your dedicated environment is accessible at:</p> <p style="color: var(--muted); font-size: 0.9rem; margin-top: 0.75rem;">Your dedicated environment is accessible at:</p>
<a class="stack-link" href="https://{{.User}}.{{.Domain}}">{{.User}}.{{.Domain}}</a> <a class="stack-link" href="https://{{.Domain}}/i/{{.User}}">{{.Domain}}/i/{{.User}}</a>
{{else if and .StackDeployed (not .StackRunning)}} {{else if and .StackDeployed (not .StackRunning)}}
<p style="color: var(--muted); font-size: 0.9rem; margin-top: 0.75rem;">Your stack is stopped. Start it to access your environment.</p> <p style="color: var(--muted); font-size: 0.9rem; margin-top: 0.75rem;">Your stack is stopped. Start it to access your environment.</p>
{{end}} {{end}}
@ -249,6 +255,14 @@
<a href="{{.AutheliaURL}}/settings/security" class="btn btn-outline btn-sm">Change Password</a> <a href="{{.AutheliaURL}}/settings/security" class="btn btn-outline btn-sm">Change Password</a>
</div> </div>
</div> </div>
{{else if .PaidNotActivated}}
<div class="card">
<div class="empty-state">
<h2>Activate Your Stack</h2>
<p>You've completed checkout. Click below to provision your dedicated environment.</p>
<a href="{{.AppURL}}/activate" class="btn">Activate Now</a>
</div>
</div>
{{else}} {{else}}
<div class="card"> <div class="card">
<div class="empty-state"> <div class="empty-state">
@ -258,7 +272,7 @@
<a href="/" class="btn">Subscribe Now</a> <a href="/" class="btn">Subscribe Now</a>
{{else}} {{else}}
<h2>Sign In Required</h2> <h2>Sign In Required</h2>
<p>Sign in to access your dashboard.</p> <p>Check your email for the password setup link. Once you've set your password and signed in, you can activate your stack here.</p>
<a href="{{.AutheliaURL}}" class="btn">Sign In</a> <a href="{{.AutheliaURL}}" class="btn">Sign In</a>
{{end}} {{end}}
</div> </div>

View File

@ -52,7 +52,7 @@
.features li { padding: 0.4rem 0; color: var(--muted); font-size: 0.95rem; } .features li { padding: 0.4rem 0; color: var(--muted); font-size: 0.95rem; }
.features li::before { content: "\2713"; color: var(--accent); font-weight: 700; margin-right: 0.75rem; } .features li::before { content: "\2713"; color: var(--accent); font-weight: 700; margin-right: 0.75rem; }
form { display: flex; flex-direction: column; gap: 0.75rem; } form { display: flex; flex-direction: column; gap: 0.75rem; }
input[type="email"] { input[type="email"], input[type="text"] {
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
@ -62,8 +62,8 @@
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
input[type="email"]:focus { border-color: var(--accent); } input[type="email"]:focus, input[type="text"]:focus { border-color: var(--accent); }
input[type="email"]::placeholder { color: var(--muted); } input[type="email"]::placeholder, input[type="text"]::placeholder { color: var(--muted); }
button { button {
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
@ -76,6 +76,19 @@
transition: background 0.2s; transition: background 0.2s;
} }
button:hover { background: var(--accent-hover); } button:hover { background: var(--accent-hover); }
.btn-primary {
display: inline-block;
background: var(--accent);
color: #fff;
text-decoration: none;
border-radius: 8px;
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 600;
transition: background 0.2s;
text-align: center;
}
.btn-primary:hover { background: var(--accent-hover); }
.footer { margin-top: 2rem; color: var(--muted); font-size: 0.8rem; text-align: center; } .footer { margin-top: 2rem; color: var(--muted); font-size: 0.8rem; text-align: center; }
.footer a { color: var(--accent); text-decoration: none; } .footer a { color: var(--accent); text-decoration: none; }
.version-badge { .version-badge {
@ -95,20 +108,42 @@
<body> <body>
<div class="container"> <div class="container">
<div class="logo">a250.ca</div> <div class="logo">a250.ca</div>
<p class="tagline">Your own managed infrastructure stack, provisioned instantly when you subscribe.</p> <p class="tagline">{{.Tagline}}</p>
<div class="card"> <div class="card">
<h2>Monthly Plan</h2> {{if .SoldOut}}
<div class="price">$20.00 <span>/ month</span></div> <h2>Signups Full</h2>
<p style="color: var(--muted); margin-bottom: 1rem;">We've reached our limit for new signups. Check back later.</p>
{{else if and .StripePaymentLink (eq .PricingTier 0) (not .UseCheckoutForm)}}
<h2>Free Plan</h2>
<div class="price">$0 <span>/ one-time</span></div>
<ul class="features"> <ul class="features">
<li>Dedicated Docker stack</li> {{range .Features}}<li>{{.}}</li>{{end}}
<li>Secure single sign-on</li> </ul>
<li>Automatic provisioning</li> <a href="{{.StripePaymentLink}}" class="btn-primary">Sign Up Free</a>
<li>Manage subscription anytime</li> {{else if .UseCheckoutForm}}
{{if eq .PricingTier 0}}
<h2>Launch Offer</h2>
<div class="price">$0 <span>/ 3 months, then ends</span></div>
{{else if eq .PricingTier 1}}
<h2>Founder Plan</h2>
<div class="price">$20 <span>/ year, then $100/month</span></div>
{{else}}
<h2>Pro Plan</h2>
<div class="price">$200 <span>/ month</span></div>
{{end}}
<ul class="features">
{{range .Features}}<li>{{.}}</li>{{end}}
</ul> </ul>
<form method="POST" action="/subscribe"> <form method="POST" action="/subscribe">
<input type="email" name="email" placeholder="you@example.com" required> <input type="email" name="email" placeholder="you@example.com" required>
<input type="tel" name="phone" placeholder="+1 555 123 4567" autocomplete="tel" required>
<input type="text" name="domain" placeholder="Domain you want to manage (e.g. git.mycompany.com)" autocomplete="off" required>
<button type="submit">Subscribe Now</button> <button type="submit">Subscribe Now</button>
</form> </form>
{{else}}
<h2>Subscribe</h2>
<p style="color: var(--muted); margin-bottom: 1rem;">Pricing is being configured. Check back soon.</p>
{{end}}
</div> </div>
<div class="footer"> <div class="footer">
Already subscribed? <a href="/dashboard">Go to Dashboard</a> Already subscribed? <a href="/dashboard">Go to Dashboard</a>

View File

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>a250.ca - Welcome</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2d3a;
--text: #e4e4e7;
--muted: #a1a1aa;
--accent: #6366f1;
--accent-hover: #818cf8;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
.container { max-width: 480px; width: 100%; }
.logo { font-size: 2.5rem; font-weight: 800; letter-spacing: -0.04em; margin-bottom: 0.5rem; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2rem;
}
.card h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem; }
.card p { color: var(--muted); line-height: 1.6; margin-bottom: 1rem; }
.card ul { color: var(--muted); line-height: 1.7; margin: 1rem 0; padding-left: 1.25rem; }
.footer { margin-top: 2rem; color: var(--muted); font-size: 0.8rem; text-align: center; }
.footer a { color: var(--accent); text-decoration: none; }
</style>
{{template "analytics"}}
</head>
<body>
<div class="container">
<div class="logo">a250.ca</div>
<div class="card">
<h2>Check your inbox</h2>
<p>We've sent a password set email to your address. Use the link in that email to create your password and sign in.</p>
<p><strong>You'll be required to:</strong></p>
<ul>
<li>Set a password</li>
<li>Enable two-factor authentication or a passkey</li>
</ul>
<p>Once you've signed in, you can activate your workspace from the dashboard.</p>
</div>
<div class="footer">
<a href="{{.AppURL}}/dashboard">Go to Dashboard</a>
</div>
</div>
</body>
</html>

View File

@ -1,239 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>a250.ca - Welcome</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2d3a;
--text: #e4e4e7;
--muted: #a1a1aa;
--accent: #6366f1;
--accent-hover: #818cf8;
--green: #22c55e;
--yellow: #eab308;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
}
.container { max-width: 560px; width: 100%; }
.logo { font-size: 2rem; font-weight: 800; letter-spacing: -0.04em; margin-bottom: 0.5rem; }
.subtitle { color: var(--green); font-size: 1.1rem; font-weight: 600; margin-bottom: 2rem; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem 2rem;
margin-bottom: 1.5rem;
}
.card h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: 1rem; }
.cred-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 0;
border-bottom: 1px solid var(--border);
}
.cred-row:last-child { border-bottom: none; }
.cred-label { color: var(--muted); font-size: 0.9rem; }
.cred-value {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 0.9rem;
font-weight: 600;
background: var(--bg);
padding: 0.3rem 0.7rem;
border-radius: 6px;
border: 1px solid var(--border);
user-select: all;
}
.warning {
background: rgba(234, 179, 8, 0.1);
border: 1px solid rgba(234, 179, 8, 0.3);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
color: var(--yellow);
font-size: 0.9rem;
line-height: 1.5;
}
.warning strong { color: var(--yellow); }
.steps { list-style: none; counter-reset: step; }
.steps li {
counter-increment: step;
padding: 0.6rem 0;
color: var(--muted);
font-size: 0.95rem;
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.steps li::before {
content: counter(step);
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.6rem;
height: 1.6rem;
background: var(--accent);
color: #fff;
border-radius: 50%;
font-size: 0.8rem;
font-weight: 700;
}
.btn {
display: inline-block;
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.7rem 1.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: background 0.2s;
}
.btn:hover { background: var(--accent-hover); }
.btn-outline {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
}
.btn-outline:hover { border-color: var(--accent); color: var(--accent); background: transparent; }
.actions { margin-top: 1.5rem; display: flex; flex-wrap: wrap; gap: 0.75rem; }
.returning { text-align: center; padding: 2rem; color: var(--muted); }
.returning p { margin-bottom: 1rem; }
.download-gate {
background: rgba(99, 102, 241, 0.08);
border: 1px solid rgba(99, 102, 241, 0.35);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
font-size: 0.9rem;
line-height: 1.5;
color: var(--text);
}
.download-gate strong { color: var(--accent-hover); }
.btn-download {
background: var(--green);
color: #fff;
border: none;
}
.btn-download:hover { background: #16a34a; }
.btn-download.downloaded {
background: #166534;
cursor: default;
opacity: 0.7;
}
.nav-actions { margin-top: 1rem; display: flex; flex-wrap: wrap; gap: 0.75rem; }
.nav-actions a.locked {
pointer-events: none;
opacity: 0.35;
cursor: not-allowed;
}
</style>
{{template "analytics"}}
</head>
<body>
<div class="container">
<div class="logo">a250.ca</div>
{{if .IsNew}}
<div class="subtitle">Payment successful — your account is ready!</div>
<div class="card">
<h2>Check your email</h2>
<p style="color:var(--muted);font-size:0.95rem;line-height:1.6;margin-bottom:1rem;">
We've sent a password setup link to <strong style="color:var(--text)">{{.Email}}</strong>.<br>
Click the link in that email to set your password and log in.
</p>
<div class="cred-row">
<span class="cred-label">Username</span>
<span class="cred-value">{{.Username}}</span>
</div>
<div class="cred-row">
<span class="cred-label">Your Instance</span>
<span class="cred-value">{{.InstanceURL}}</span>
</div>
</div>
<div class="card">
<h2>Next Steps</h2>
<ol class="steps">
<li>Open the email sent to <strong>{{.Email}}</strong> and click the link</li>
<li>Set your password</li>
<li>Return here and click <strong>Activate Stack</strong> to go live</li>
<li>Your instance will be at <strong>{{.InstanceURL}}</strong></li>
</ol>
</div>
<div class="actions">
<button type="button" class="btn" id="reset-btn-new"
onclick="sendReset(this,'{{.Username}}')">Resend Set Password Email</button>
<a href="{{.ActivateURL}}" class="btn btn-outline">Activate Stack</a>
</div>
{{else}}
<div class="subtitle">Welcome back!</div>
<div class="warning">
<strong>Credentials not shown.</strong> Your account <strong>{{.Username}}</strong> already exists — we cannot display your original password again. Use the link below if you need to reset it.
</div>
<div class="card">
<h2>Account Details</h2>
<div class="cred-row">
<span class="cred-label">Username</span>
<span class="cred-value">{{.Username}}</span>
</div>
<div class="cred-row">
<span class="cred-label">Email</span>
<span class="cred-value">{{.Email}}</span>
</div>
<div class="cred-row">
<span class="cred-label">Your Instance</span>
<span class="cred-value">{{.InstanceURL}}</span>
</div>
</div>
<div class="actions">
<button type="button" class="btn" id="reset-btn-returning"
onclick="sendReset(this,'{{.Username}}')">Resend Set Password Email</button>
<a href="{{.LoginURL}}" class="btn btn-outline">Sign In</a>
<a href="{{.DashboardURL}}" class="btn btn-outline">Dashboard</a>
</div>
{{end}}
</div>
<script>
function sendReset(btn, username) {
btn.disabled = true;
btn.textContent = 'Sending…';
fetch('/resend-reset', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'username=' + encodeURIComponent(username)
}).then(function(r) {
if (r.ok) {
btn.textContent = 'Email sent — check your inbox';
btn.style.background = 'var(--green)';
} else {
btn.textContent = 'Failed — try again';
btn.disabled = false;
}
}).catch(function() {
btn.textContent = 'Failed — try again';
btn.disabled = false;
});
}
</script>
</body>
</html>

View File

@ -5,7 +5,7 @@
# It defines what product/service they receive when they subscribe. # It defines what product/service they receive when they subscribe.
# #
# PRODUCT: Gitea — a self-hosted Git service, backed by PostgreSQL. # PRODUCT: Gitea — a self-hosted Git service, backed by PostgreSQL.
# Each customer gets their own isolated instance at their subdomain. # Each customer gets their own isolated instance at a sub-path.
# #
# Structure: # Structure:
# web — the application, exposed via Traefik behind Authelia auth # web — the application, exposed via Traefik behind Authelia auth
@ -16,11 +16,11 @@
# #
# Template variables (injected at deploy time by swarm/client.go): # Template variables (injected at deploy time by swarm/client.go):
# {{.ID}} - customer's username (unique resource naming) # {{.ID}} - customer's username (unique resource naming)
# {{.Subdomain}} - customer's subdomain (same as ID by default) # {{.Subdomain}} - customer's username (used in path: /i/{subdomain})
# {{.Domain}} - base domain (e.g. bc.a250.ca) # {{.Domain}} - base domain (e.g. bc.a250.ca)
# {{.TraefikNetwork}} - Traefik overlay network name # {{.TraefikNetwork}} - Traefik overlay network name
# #
# Each customer gets their stack at: https://{{.Subdomain}}.{{.Domain}} # Each customer gets their stack at: https://{{.Domain}}/i/{{.Subdomain}}
# Access is restricted to the owning user via Authelia forward-auth. # Access is restricted to the owning user via Authelia forward-auth.
# ============================================================================= # =============================================================================
services: services:
@ -32,8 +32,8 @@ services:
GITEA__database__NAME: gitea GITEA__database__NAME: gitea
GITEA__database__USER: gitea GITEA__database__USER: gitea
GITEA__database__PASSWD: gitea GITEA__database__PASSWD: gitea
GITEA__server__DOMAIN: "{{.Subdomain}}.{{.Domain}}" GITEA__server__DOMAIN: "{{.Domain}}"
GITEA__server__ROOT_URL: "https://{{.Subdomain}}.{{.Domain}}" GITEA__server__ROOT_URL: "https://{{.Domain}}/i/{{.Subdomain}}/"
GITEA__server__HTTP_PORT: "3000" GITEA__server__HTTP_PORT: "3000"
GITEA__security__INSTALL_LOCK: "true" GITEA__security__INSTALL_LOCK: "true"
volumes: volumes:
@ -47,10 +47,11 @@ services:
labels: labels:
traefik.enable: "true" traefik.enable: "true"
traefik.docker.network: "atlas_{{.TraefikNetwork}}" traefik.docker.network: "atlas_{{.TraefikNetwork}}"
traefik.http.routers.customer-{{.ID}}-web.rule: "Host(`{{.Subdomain}}.{{.Domain}}`)" traefik.http.routers.customer-{{.ID}}-web.rule: "Host(`{{.Domain}}`) && PathPrefix(`/i/{{.Subdomain}}`)"
traefik.http.routers.customer-{{.ID}}-web.entrypoints: "websecure" traefik.http.routers.customer-{{.ID}}-web.entrypoints: "websecure"
traefik.http.routers.customer-{{.ID}}-web.tls: "true" traefik.http.routers.customer-{{.ID}}-web.tls: "true"
traefik.http.routers.customer-{{.ID}}-web.middlewares: "authelia-auth@swarm" traefik.http.routers.customer-{{.ID}}-web.middlewares: "strip-customer-{{.ID}}@swarm,authelia-auth@swarm"
traefik.http.middlewares.strip-customer-{{.ID}}.stripprefix.prefixes: "/i/{{.Subdomain}}"
traefik.http.services.customer-{{.ID}}-web.loadbalancer.server.port: "3000" traefik.http.services.customer-{{.ID}}-web.loadbalancer.server.port: "3000"
restart_policy: restart_policy:
condition: on-failure condition: on-failure

View File

@ -0,0 +1,29 @@
# Stripe Payment Link for $0 Signups
For a free trial or limited beta, use a Stripe Payment Link instead of a checkout session.
## Create the Payment Link
1. **Stripe Dashboard** → Products → Add product
- Name: e.g. "Atlas Instance (Free)"
- Price: $0 one-time (or $0/month subscription)
- Save
2. **Payment Links** → Create payment link
- Select the $0 product/price
- After payment: "Customer goes to a page on your website"
- URL: `https://bc.a250.ca/success?session_id={CHECKOUT_SESSION_ID}`
- (Use your actual `APP_URL` + `/success?session_id={CHECKOUT_SESSION_ID}`)
- Create link
3. **Configure ss-atlas** via environment:
```
STRIPE_PAYMENT_LINK=https://buy.stripe.com/your_link_id
MAX_SIGNUPS=10
```
## Behavior
- Landing page shows "Sign Up Free" button → Payment Link
- Limit enforced at: landing, checkout form, success page, webhook
- Set `MAX_SIGNUPS=0` to disable limit

View File

@ -0,0 +1,77 @@
# Tiered Pricing Setup
Tiered pricing assigns different Stripe prices based on customer count.
## Stripe MCP (Cursor)
Stripe MCP is configured in `.cursor/mcp.json`. It loads `STRIPE_SECRET_KEY` from `.env` so you can use Stripe tools in Cursor chat (create customers, prices, search docs, etc.).
**Enable MCP:** [Stripe Dashboard → Settings → MCP](https://dashboard.stripe.com/settings/mcp) — enable for test and/or live mode.
Restart Cursor after adding/updating `.env` so the MCP picks up keys.
## Automated Setup Script
Create products and prices from your `.env` keys:
```bash
./scripts/stripe-setup.sh # uses .env, prints IDs
./scripts/stripe-setup.sh --apply # creates prices and updates .env
./scripts/stripe-setup.sh .env.prod --apply # prod: use prod keys, update .env.prod
```
Then redeploy: `export $(grep -v '^#' .env | xargs) && docker stack deploy -c stack.yml atlas`
- **First 10** (FREE_TIER_LIMIT): $0 for 3 months, auto-cancels at end
- **Next 40** (1150, YEAR_TIER_LIMIT): $20/year, then $100/month after first year
- **51+**: $200/month
## Stripe Prices to Create
In **Stripe Dashboard** → Products → create one product with four prices:
1. **$0/month** recurring monthly, amount 0
- Used for first 10 signups; subscription is scheduled to cancel in 3 months
2. **$20/year** recurring yearly, amount 2000
3. **$100/month** recurring monthly, amount 10000
4. **$200/month** recurring monthly, amount 20000
Copy each price ID (starts with `price_`).
## Messaging (env vars)
Customize the subscribe page copy without rebuilding:
```bash
LANDING_TAGLINE="Your own workspace, ready in minutes."
LANDING_FEATURES="Dedicated environment|Secure login|Set up automatically|Cancel anytime"
```
Features use `|` as separator.
## Environment Variables
```bash
STRIPE_PRICE_ID_FREE=price_xxx # $0/3mo
STRIPE_PRICE_ID_YEAR=price_xxx # $20/year
STRIPE_PRICE_ID_MONTH_100=price_xxx # $100/month
STRIPE_PRICE_ID_MONTH_200=price_xxx # $200/month
FREE_TIER_LIMIT=10
YEAR_TIER_LIMIT=50
MAX_SIGNUPS=0
```
`STRIPE_PRICE_ID` remains as a fallback if tier-specific prices are not set.
## Webhook Events
Enable these in **Stripe Dashboard** → Developers → Webhooks:
- `checkout.session.completed` provisions user, schedules free-tier cancel
- `customer.subscription.deleted` deprovisions stack
- `customer.subscription.updated` status changes
- `invoice.paid` migrates $20/year subscriptions to $100/month on renewal
## Migration Flow
When a customer with a $20/year subscription has their first renewal (after 1 year), the `invoice.paid` webhook switches the subscription to $100/month for the next billing cycle.

View File

@ -35,4 +35,4 @@ docker service update --force --image atlas-ss-atlas:latest atlas_ss-atlas
docker service update --force --image git.nixc.us/a250/authelia:dev-authelia atlas_authelia docker service update --force --image git.nixc.us/a250/authelia:dev-authelia atlas_authelia
echo "" echo ""
echo "=== Ready. Visit https://app.bc.a250.ca ===" echo "=== Ready. Visit https://bc.a250.ca ==="

View File

@ -1,22 +1,15 @@
#!/bin/sh #!/bin/sh
# Local DNS setup so *.app.a250.ca and *.bc.a250.ca resolve to 127.0.0.1 # Local DNS setup so bc.a250.ca resolves to 127.0.0.1
# For full wildcard support use dnsmasq. For quick test, use /etc/hosts (limited). #
# With path-based routing, only a single domain is needed.
# No wildcard DNS or per-customer entries required.
set -e set -e
echo "Option 1: dnsmasq (recommended - true wildcard)" echo "Add this line to /etc/hosts:"
echo " Add to /etc/dnsmasq.conf or /usr/local/etc/dnsmasq.conf:"
echo " address=/.app.a250.ca/127.0.0.1"
echo " address=/.bc.a250.ca/127.0.0.1"
echo " Then: brew services restart dnsmasq"
echo " And point your Mac to use 127.0.0.1 for DNS, or add to /etc/resolvers/app.a250.ca"
echo "" echo ""
echo "Option 2: /etc/hosts (manual per-subdomain)" echo " 127.0.0.1 bc.a250.ca"
echo " Add lines for each host you need:"
echo " 127.0.0.1 app.bc.a250.ca login.bc.a250.ca lldap.bc.a250.ca traefik.bc.a250.ca colin-nixc.bc.a250.ca"
echo " 127.0.0.1 testuser.app.a250.ca"
echo " /etc/hosts does NOT support wildcards."
echo "" echo ""
echo "Option 3: resolvers file for *.app.a250.ca only" echo "That's it — all services are path-based under bc.a250.ca:"
echo " mkdir -p /etc/resolver" echo " https://bc.a250.ca/ — landing / dashboard"
echo " echo 'nameserver 127.0.0.1' | sudo tee /etc/resolver/app.a250.ca" echo " https://bc.a250.ca/login/ — authentication"
echo " (requires dnsmasq listening on 127.0.0.1 with address=/.app.a250.ca/127.0.0.1)" echo " https://bc.a250.ca/i/{user}/ — customer instances"

111
scripts/stripe-setup.sh Executable file
View File

@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Create Atlas tiered pricing products and prices in Stripe.
# Usage: ./scripts/stripe-setup.sh [path/to/.env]
# Use sandbox .env for test mode, prod .env for live. Outputs env vars to add to .env.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
APPLY=false
ENV_FILE="$REPO_ROOT/.env"
for arg in "$@"; do
if [ "$arg" = "--apply" ]; then APPLY=true
elif [ -f "$arg" ]; then ENV_FILE="$arg"
fi
done
cd "$REPO_ROOT"
if [ -f "$ENV_FILE" ]; then
set -a
# shellcheck source=/dev/null
. "$ENV_FILE"
set +a
fi
if [ -z "${STRIPE_SECRET_KEY:-}" ]; then
echo "ERROR: STRIPE_SECRET_KEY not set. Usage: $0 [path/to/.env]" >&2
exit 1
fi
# Detect mode from key
if [[ "$STRIPE_SECRET_KEY" == sk_live_* ]]; then
MODE="live"
echo ">>> Stripe LIVE mode" >&2
else
MODE="test"
echo ">>> Stripe TEST mode" >&2
fi
BASE_URL="https://api.stripe.com/v1"
# Create product
echo "Creating product Atlas..." >&2
PRODUCT=$(curl -s -X POST -u "${STRIPE_SECRET_KEY}:" "$BASE_URL/products" \
-d "name=Atlas" \
-d "description=Managed workspace and infrastructure")
PRODUCT_ID=$(echo "$PRODUCT" | jq -r '.id // empty')
if [ -z "$PRODUCT_ID" ]; then
echo "Failed to create product. Response: $PRODUCT" >&2
exit 1
fi
echo "Product: $PRODUCT_ID" >&2
# Create prices (recurring)
create_price() {
local amount="$1"
local interval="$2"
local nickname="$3"
local out
out=$(curl -s -X POST -u "${STRIPE_SECRET_KEY}:" "$BASE_URL/prices" \
-d "product=$PRODUCT_ID" \
-d "currency=usd" \
-d "unit_amount=$amount" \
-d "recurring[interval]=$interval" \
-d "nickname=$nickname")
echo "$out" | jq -r '.id // empty'
}
echo "Creating prices..." >&2
PRICE_FREE=$(create_price 0 month "Atlas Free 3mo")
PRICE_YEAR=$(create_price 2000 year "Atlas Founder Year")
PRICE_100=$(create_price 10000 month "Atlas 100/mo")
PRICE_200=$(create_price 20000 month "Atlas 200/mo")
if [ -z "$PRICE_FREE" ] || [ -z "$PRICE_YEAR" ] || [ -z "$PRICE_100" ] || [ -z "$PRICE_200" ]; then
echo "Failed to create one or more prices" >&2
exit 1
fi
echo "" >&2
echo "=== Add these to your .env ($MODE mode) ===" >&2
echo "" >&2
cat << EOF
# Stripe tiered pricing ($MODE)
STRIPE_PRICE_ID_FREE=$PRICE_FREE
STRIPE_PRICE_ID_YEAR=$PRICE_YEAR
STRIPE_PRICE_ID_MONTH_100=$PRICE_100
STRIPE_PRICE_ID_MONTH_200=$PRICE_200
FREE_TIER_LIMIT=10
YEAR_TIER_LIMIT=50
EOF
# If --apply passed, merge into .env
if [ "$APPLY" = "true" ]; then
ENV_TARGET="$ENV_FILE"
[ -f "$ENV_TARGET" ] || { echo "No $ENV_TARGET to update" >&2; exit 0; }
update_var() { local k="$1" v="$2"
if grep -q "^${k}=" "$ENV_TARGET" 2>/dev/null; then
sed -i.bak "s|^${k}=.*|${k}=${v}|" "$ENV_TARGET"
else
echo "${k}=${v}" >> "$ENV_TARGET"
fi
}
update_var "STRIPE_PRICE_ID_FREE" "$PRICE_FREE"
update_var "STRIPE_PRICE_ID_YEAR" "$PRICE_YEAR"
update_var "STRIPE_PRICE_ID_MONTH_100" "$PRICE_100"
update_var "STRIPE_PRICE_ID_MONTH_200" "$PRICE_200"
rm -f "${ENV_TARGET}.bak"
echo "" >&2
echo "Updated $ENV_TARGET. Redeploy: export \$(grep -v '^#' .env | xargs) && docker stack deploy -c stack.yml atlas" >&2
fi

View File

@ -46,7 +46,9 @@ services:
deploy: deploy:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.lldap.rule=Host(`lldap.bc.a250.ca`)" - "traefik.http.routers.lldap.rule=Host(`bc.a250.ca`) && PathPrefix(`/admin/lldap`)"
- "traefik.http.routers.lldap.middlewares=strip-lldap@swarm"
- "traefik.http.middlewares.strip-lldap.stripprefix.prefixes=/admin/lldap"
- "traefik.http.routers.lldap.entrypoints=websecure" - "traefik.http.routers.lldap.entrypoints=websecure"
- "traefik.http.routers.lldap.tls=true" - "traefik.http.routers.lldap.tls=true"
- "traefik.http.services.lldap.loadbalancer.server.port=17170" - "traefik.http.services.lldap.loadbalancer.server.port=17170"
@ -77,9 +79,10 @@ services:
echo "$${CLIENT_SECRET_HEADADMIN}" > /run/secrets/CLIENT_SECRET_HEADADMIN echo "$${CLIENT_SECRET_HEADADMIN}" > /run/secrets/CLIENT_SECRET_HEADADMIN
echo "$${CLIENT_SECRET_PORTAINER}" > /run/secrets/CLIENT_SECRET_PORTAINER echo "$${CLIENT_SECRET_PORTAINER}" > /run/secrets/CLIENT_SECRET_PORTAINER
echo "$${CLIENT_SECRET_GITEA}" > /run/secrets/CLIENT_SECRET_GITEA echo "$${CLIENT_SECRET_GITEA}" > /run/secrets/CLIENT_SECRET_GITEA
{ echo 'access_control:'; echo ' default_policy: deny'; echo ' rules:'; echo ' - domain: login.bc.a250.ca'; echo ' policy: bypass'; echo ' - domain: app.bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/$$'"; echo " - '^/subscribe$$'"; echo " - '^/success(\\?.*)?$$'"; echo " - '^/webhook/stripe$$'"; echo " - '^/resend-reset$$'"; echo " - '^/health$$'"; echo " - '^/version$$'"; echo ' - domain: app.bc.a250.ca'; echo ' policy: one_factor'; echo ' resources:'; echo " - '^/dashboard$$'"; echo " - '^/activate$$'"; echo " - '^/portal$$'"; echo " - '^/resubscribe$$'"; echo " - '^/stack-manage$$'"; echo ' - domain:'; echo ' - lldap.bc.a250.ca'; echo ' - whoami.bc.a250.ca'; echo ' policy: bypass'; echo ' - domain: "{user}.bc.a250.ca"'; echo ' policy: two_factor'; echo ' - domain: "*.bc.a250.ca"'; echo ' policy: deny'; } > /config/configuration.acl.yml { echo 'access_control:'; echo ' default_policy: deny'; echo ' rules:'; echo ' - domain: bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/login(/.*)?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/$$'"; echo " - '^/subscribe/?$$'"; echo " - '^/success(/|\\?.*)?$$'"; echo " - '^/webhook/stripe/?$$'"; echo " - '^/resend-reset/?$$'"; echo " - '^/health/?$$'"; echo " - '^/version/?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: one_factor'; echo ' resources:'; echo " - '^/activate/?$$'"; echo " - '^/dashboard/?$$'"; echo ' - domain: bc.a250.ca'; echo ' subject:'; echo ' - group:customers'; echo ' policy: two_factor'; echo ' resources:'; echo " - '^/portal/?$$'"; echo " - '^/resubscribe/?$$'"; echo " - '^/stack-manage/?$$'"; echo " - '^/i/[a-z0-9-]+(/.*)?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/admin/lldap(/.*)?$$'"; echo " - '^/whoami(/.*)?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: deny'; } > /config/configuration.acl.yml
exec authelia --config=/config/configuration.server.yml --config=/config/configuration.ldap.yml --config=/config/configuration.acl.yml --config=/config/configuration.notifier.yml --config=/config/configuration.identity.providers.yml --config=/config/configuration.oidc.clients.yml exec authelia --config=/config/configuration.server.yml --config=/config/configuration.ldap.yml --config=/config/configuration.acl.yml --config=/config/configuration.notifier.yml --config=/config/configuration.identity.providers.yml --config=/config/configuration.oidc.clients.yml
environment: environment:
AUTHELIA_SERVER_ADDRESS: tcp://0.0.0.0:9091/login
X_AUTHELIA_EMAIL: authelia@a250.ca X_AUTHELIA_EMAIL: authelia@a250.ca
X_AUTHELIA_SITE_NAME: a250.ca X_AUTHELIA_SITE_NAME: a250.ca
X_AUTHELIA_CONFIG_FILTERS: template X_AUTHELIA_CONFIG_FILTERS: template
@ -132,15 +135,15 @@ services:
deploy: deploy:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.authelia.rule=Host(`login.bc.a250.ca`)" - "traefik.http.routers.authelia.rule=Host(`bc.a250.ca`) && PathPrefix(`/login`)"
- "traefik.http.routers.authelia.entrypoints=websecure" - "traefik.http.routers.authelia.entrypoints=websecure"
- "traefik.http.routers.authelia.tls=true" - "traefik.http.routers.authelia.tls=true"
- "traefik.http.services.authelia.loadbalancer.server.port=9091" - "traefik.http.services.authelia.loadbalancer.server.port=9091"
- "traefik.http.middlewares.authelia-auth.forwardauth.address=http://authelia:9091/api/verify?rd=https://login.bc.a250.ca/" - "traefik.http.middlewares.authelia-auth.forwardauth.address=http://authelia:9091/login/api/authz/forward-auth?rd=https://bc.a250.ca/login/"
- "traefik.http.middlewares.authelia-auth.forwardauth.trustForwardHeader=true" - "traefik.http.middlewares.authelia-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.authelia-auth.forwardauth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email" - "traefik.http.middlewares.authelia-auth.forwardauth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email"
healthcheck: healthcheck:
test: [ "CMD-SHELL", "/usr/bin/wget --spider --quiet http://localhost:9091/api/health || exit 1" ] test: [ "CMD-SHELL", "/usr/bin/wget --spider --quiet http://localhost:9091/login/api/health || exit 1" ]
start_period: 15s start_period: 15s
interval: 30s interval: 30s
timeout: 10s timeout: 10s
@ -170,10 +173,12 @@ services:
deploy: deploy:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.bc.a250.ca`)" - "traefik.http.routers.traefik-dashboard.rule=Host(`bc.a250.ca`) && PathPrefix(`/admin/traefik`)"
- "traefik.http.routers.traefik-dashboard.entrypoints=websecure" - "traefik.http.routers.traefik-dashboard.entrypoints=websecure"
- "traefik.http.routers.traefik-dashboard.tls=true" - "traefik.http.routers.traefik-dashboard.tls=true"
- "traefik.http.routers.traefik-dashboard.service=traefik-api" - "traefik.http.routers.traefik-dashboard.service=traefik-api"
- "traefik.http.routers.traefik-dashboard.middlewares=strip-traefik@swarm"
- "traefik.http.middlewares.strip-traefik.stripprefix.prefixes=/admin/traefik"
- "traefik.http.services.traefik-api.loadbalancer.server.port=8080" - "traefik.http.services.traefik-api.loadbalancer.server.port=8080"
ss-atlas: ss-atlas:
@ -181,21 +186,31 @@ services:
environment: environment:
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-sk_test_placeholder} - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-sk_test_placeholder}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-whsec_placeholder} - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-whsec_placeholder}
- STRIPE_PRICE_ID=${STRIPE_PRICE_ID:-price_placeholder} - STRIPE_PRICE_ID=${STRIPE_PRICE_ID:-}
- STRIPE_PRICE_ID_FREE=${STRIPE_PRICE_ID_FREE:-}
- STRIPE_PRICE_ID_YEAR=${STRIPE_PRICE_ID_YEAR:-}
- STRIPE_PRICE_ID_MONTH_100=${STRIPE_PRICE_ID_MONTH_100:-}
- STRIPE_PRICE_ID_MONTH_200=${STRIPE_PRICE_ID_MONTH_200:-}
- STRIPE_PAYMENT_LINK=${STRIPE_PAYMENT_LINK:-}
- FREE_TIER_LIMIT=${FREE_TIER_LIMIT:-10}
- YEAR_TIER_LIMIT=${YEAR_TIER_LIMIT:-50}
- MAX_SIGNUPS=${MAX_SIGNUPS:-0}
- LLDAP_URL=ldap://lldap:3890 - LLDAP_URL=ldap://lldap:3890
- LLDAP_ADMIN_DN=uid=admin,ou=people,dc=a250,dc=ca - LLDAP_ADMIN_DN=uid=admin,ou=people,dc=a250,dc=ca
- LLDAP_ADMIN_PASSWORD=/ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0= - LLDAP_ADMIN_PASSWORD=/ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0=
- LLDAP_BASE_DN=dc=a250,dc=ca - LLDAP_BASE_DN=dc=a250,dc=ca
- LLDAP_HTTP_URL=http://lldap:17170 - LLDAP_HTTP_URL=http://lldap:17170
- DOCKER_HOST=unix:///var/run/docker.sock - DOCKER_HOST=unix:///var/run/docker.sock
- APP_URL=https://app.bc.a250.ca - APP_URL=https://bc.a250.ca
- AUTHELIA_URL=https://login.bc.a250.ca - AUTHELIA_URL=https://bc.a250.ca/login
- AUTHELIA_INTERNAL_URL=http://authelia:9091 - AUTHELIA_INTERNAL_URL=http://authelia:9091/login
- TRAEFIK_DOMAIN=bc.a250.ca - TRAEFIK_DOMAIN=bc.a250.ca
- TRAEFIK_NETWORK=authelia_dev - TRAEFIK_NETWORK=authelia_dev
- CUSTOMER_DOMAIN=app.a250.ca - CUSTOMER_DOMAIN=bc.a250.ca
- TEMPLATE_PATH=/app/templates - TEMPLATE_PATH=/app/templates
- ARCHIVE_PATH=/archives - ARCHIVE_PATH=/archives
- LANDING_TAGLINE=${LANDING_TAGLINE:-Your own workspace, ready in minutes.}
- LANDING_FEATURES=${LANDING_FEATURES:-Dedicated environment|Secure single sign-on|Automatic provisioning|Manage subscription anytime}
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- atlas_archives:/archives - atlas_archives:/archives
@ -204,9 +219,10 @@ services:
deploy: deploy:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.ss-atlas.rule=Host(`app.bc.a250.ca`)" - "traefik.http.routers.ss-atlas.rule=Host(`bc.a250.ca`)"
- "traefik.http.routers.ss-atlas.entrypoints=websecure" - "traefik.http.routers.ss-atlas.entrypoints=websecure"
- "traefik.http.routers.ss-atlas.tls=true" - "traefik.http.routers.ss-atlas.tls=true"
- "traefik.http.routers.ss-atlas.priority=1"
- "traefik.http.routers.ss-atlas.middlewares=authelia-auth@swarm" - "traefik.http.routers.ss-atlas.middlewares=authelia-auth@swarm"
- "traefik.http.services.ss-atlas.loadbalancer.server.port=8080" - "traefik.http.services.ss-atlas.loadbalancer.server.port=8080"
@ -217,10 +233,12 @@ services:
deploy: deploy:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.whoami.rule=Host(`whoami.bc.a250.ca`)" - "traefik.http.routers.whoami.rule=Host(`bc.a250.ca`) && PathPrefix(`/whoami`)"
- "traefik.http.routers.whoami.entrypoints=websecure" - "traefik.http.routers.whoami.entrypoints=websecure"
- "traefik.http.routers.whoami.tls=true" - "traefik.http.routers.whoami.tls=true"
- "traefik.http.routers.whoami.middlewares=authelia-auth@swarm" - "traefik.http.routers.whoami.middlewares=strip-whoami@swarm,authelia-auth@swarm"
- "traefik.http.middlewares.strip-whoami.stripprefix.prefixes=/whoami"
- "traefik.http.services.whoami.loadbalancer.server.port=80"
networks: networks:
authelia_dev: authelia_dev: