mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
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
96 lines
2.5 KiB
Go
96 lines
2.5 KiB
Go
// Copyright (C) 2020-2026, Hanzo AI Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package waitlist
|
|
|
|
import (
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/tools/hook"
|
|
"github.com/hanzoai/base/tools/router"
|
|
luxlog "github.com/luxfi/log"
|
|
)
|
|
|
|
// MustRegister installs the waitlist plugin on the given app and panics
|
|
// on error. Suitable for use in a Base process's startup wiring.
|
|
func MustRegister(app core.App, cfg Config) {
|
|
if err := Register(app, cfg); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
// Register installs the waitlist plugin.
|
|
//
|
|
// On OnBootstrap the plugin auto-creates two collections (`waitlists`,
|
|
// `waitlist_entries`) if they don't exist. On OnServe it mounts three
|
|
// REST endpoints under /v1/waitlist:
|
|
//
|
|
// POST /v1/waitlist/join - create an entry (Turnstile + rate-limit gated)
|
|
// GET /v1/waitlist/status - look up rank, share URL, referral count
|
|
// GET /v1/waitlist/export - admin-only CSV dump
|
|
//
|
|
// The plugin owns its collections and never exposes them as direct CRUD
|
|
// — the dashboard remains available for admins.
|
|
func Register(app core.App, cfg Config) error {
|
|
if !cfg.Enabled {
|
|
// default-enabled to avoid surprise: a zero-value Config disables.
|
|
// Callers must set Enabled:true explicitly to opt in.
|
|
return nil
|
|
}
|
|
|
|
cfg.resolve()
|
|
if err := cfg.validate(); err != nil {
|
|
return err
|
|
}
|
|
|
|
domains := cfg.DisposableDomains
|
|
if domains == nil {
|
|
domains = defaultDisposableDomains
|
|
}
|
|
|
|
p := &plugin{
|
|
app: app,
|
|
config: cfg,
|
|
logger: luxlog.New("component", "waitlist"),
|
|
limiter: newSlidingLimiter(cfg.JoinRateLimit, cfg.JoinRateWindow),
|
|
turnstile: newTurnstileVerifier(cfg.TurnstileSecret),
|
|
disposable: newDomainSet(domains),
|
|
}
|
|
|
|
app.OnBootstrap().Bind(&hook.Handler[*core.BootstrapEvent]{
|
|
Id: "__waitlistBootstrap__",
|
|
Func: func(e *core.BootstrapEvent) error {
|
|
if err := e.Next(); err != nil {
|
|
return err
|
|
}
|
|
return p.ensureSchema()
|
|
},
|
|
})
|
|
|
|
app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
|
|
Id: "__waitlistServe__",
|
|
Func: func(e *core.ServeEvent) error {
|
|
app.Store().Set("waitlist", p)
|
|
p.registerRoutes(e.Router)
|
|
return e.Next()
|
|
},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
type plugin struct {
|
|
app core.App
|
|
config Config
|
|
logger luxlog.Logger
|
|
limiter *slidingLimiter
|
|
turnstile *turnstileVerifier
|
|
disposable map[string]struct{}
|
|
}
|
|
|
|
func (p *plugin) registerRoutes(r *router.Router[*core.RequestEvent]) {
|
|
g := r.Group("/v1/waitlist")
|
|
g.POST("/join", p.handleJoin)
|
|
g.GET("/status", p.handleStatus)
|
|
g.GET("/export", p.handleExport)
|
|
}
|