Files
base/plugins/waitlist/turnstile.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.8 KiB
Go

// Copyright (C) 2020-2026, Hanzo AI Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package waitlist
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
)
const turnstileEndpoint = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
type turnstileVerifyResp struct {
Success bool `json:"success"`
ErrorCodes []string `json:"error-codes,omitempty"`
}
// turnstileVerifier verifies a Cloudflare Turnstile token. If secret is
// empty, verify is a no-op (used in dev).
type turnstileVerifier struct {
secret string
client *http.Client
url string // injectable for tests
}
func newTurnstileVerifier(secret string) *turnstileVerifier {
return &turnstileVerifier{
secret: strings.TrimSpace(secret),
client: &http.Client{Timeout: 5 * time.Second},
url: turnstileEndpoint,
}
}
// verify returns nil on success, a non-nil error otherwise. Empty secret
// is treated as "verification disabled" and always succeeds.
func (v *turnstileVerifier) verify(ctx context.Context, token, remoteIP string) error {
if v == nil || v.secret == "" {
return nil
}
if token == "" {
return errors.New("MISSING_TOKEN")
}
body, _ := json.Marshal(map[string]string{
"secret": v.secret,
"response": token,
"remoteip": remoteIP,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, v.url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := v.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var out turnstileVerifyResp
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return err
}
if !out.Success {
if len(out.ErrorCodes) > 0 {
return errors.New(strings.Join(out.ErrorCodes, ","))
}
return errors.New("VERIFICATION_FAILED")
}
return nil
}