Files
base/plugins/waitlist/util.go
zeekay a47ee006c6 feat(plugins/waitlist): viral waitlist plugin
Adds plugins/waitlist/ — a Base Go plugin that exposes a viral waitlist
under /v1/waitlist on any Base process. Replaces the standalone Next.js
+ Upstash Redis surface that lived in github.com/hanzoai/waitlist.

Surface
- POST /v1/waitlist/join     register, atomically credit referrer
- GET  /v1/waitlist/status   look up rank + share URL
- GET  /v1/waitlist/export   admin-only CSV (superuser or shared secret)

Storage
- Two collections auto-created on bootstrap: waitlists, waitlist_entries
- Indexes: UNIQUE(waitlist,email), UNIQUE(waitlist,refCode),
  (waitlist, referralCount DESC, createdAt ASC) for rank queries
- All mutations inside RunInTransaction — idempotent join, atomic
  referral counts, collision-safe refCode allocation

Anti-abuse
- crypto/rand 8-char refCode from 36-char alphabet (no 0/O/1/l/I/etc)
- Optional Cloudflare Turnstile verify (no-op when secret unset)
- In-process sliding-window IP rate limit (default 5/IP/hour, tested)
- Disposable email domain blocklist (overridable via Config)

Registration
- waitlist.MustRegister(app, waitlist.Config{Enabled: true, ...})
- TURNSTILE_SECRET_KEY and WAITLIST_ADMIN_SECRET resolved from env

Tests
- plugin_test.go: refCode uniqueness (5000 codes), email validation,
  disposable-domain check, sliding limiter (window expiry + disable),
  config defaults, prefix validation
2026-06-09 22:58:01 -07:00

78 lines
1.9 KiB
Go

// Copyright (C) 2020-2026, Hanzo AI Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package waitlist
import (
"crypto/rand"
"math/big"
"regexp"
"strings"
)
// refCodeAlphabet excludes confusing chars (0/O/1/l/I/5/S/2/Z/9/g/v/V).
const refCodeAlphabet = "6789BCDFGHJKLMNPQRTWbcdfghjkmnpqrtwz"
// generateRefCode returns a cryptographically random 8-char referral code.
// 36^8 ≈ 2.8e12 — collision rate is negligible at any realistic list size.
func generateRefCode() string {
const n = 8
max := big.NewInt(int64(len(refCodeAlphabet)))
out := make([]byte, n)
for i := 0; i < n; i++ {
v, err := rand.Int(rand.Reader, max)
if err != nil {
// crypto/rand should never fail; fall back rather than panic so
// a transient entropy hiccup doesn't 500 the join endpoint.
return strings.Repeat(string(refCodeAlphabet[0]), n)
}
out[i] = refCodeAlphabet[v.Int64()]
}
return string(out)
}
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
func isValidEmail(s string) bool {
if len(s) > 254 {
return false
}
return emailRe.MatchString(s)
}
// defaultDisposableDomains is the built-in blocklist used when Config
// does not override it. Kept short on purpose — operators who need a
// big list pass their own.
var defaultDisposableDomains = []string{
"tempmail.com",
"guerrillamail.com",
"10minutemail.com",
"mailinator.com",
"trashmail.com",
"getairmail.com",
"yopmail.com",
"maildrop.cc",
"throwaway.email",
"fakeinbox.com",
}
func newDomainSet(list []string) map[string]struct{} {
out := make(map[string]struct{}, len(list))
for _, d := range list {
out[strings.ToLower(strings.TrimSpace(d))] = struct{}{}
}
return out
}
func emailDomain(email string) string {
i := strings.LastIndexByte(email, '@')
if i < 0 || i == len(email)-1 {
return ""
}
return strings.ToLower(email[i+1:])
}
func normalizeEmail(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}