Files
base/plugins/waitlist/plugin.go
T
a2e65cce28 feat(waitlist): unify position on points (supersedes #44); boost via the award seam (#49)
* chore(notice): pin PocketBase provenance and deviations; sweep product branding (#48)

* feat(waitlist): unify position on points; boost awards through the seam

Reconcile the points->position engine with #43's boost + waitlisted-access
gate onto one mechanic: POINTS are the single position number. An entry's
rank is its competition rank by points DESC (tied entries share a rank,
earlier joiners break ties); points move only through award(), the one
dedup'd choke point (UNIQUE(entry, dedupKey)) that also feeds the append-only
waitlist_events ledger, activity feed, and per-source breakdown.

- schema: waitlist_entries carries points (indexed rank) + breakdown, and
  keeps #43's sticky accessGranted; new waitlist_events ledger. Upgrade-safe
  ensureSchema reconciles fields in place and still seeds DefaultSlugs.
- access gate kept exactly as #43: grantsAccess/hasAccess over the Open and
  AccessCapacity knobs, sticky capacity grant, superuser/AdminSecret model.
- /boost stays service-authed (superuser/AdminSecret, e.g. hanzod) but is
  reimplemented as a caller-amount award through award() — no parallel boost
  store. Empty dedupKey accumulates; a keyed boost is replay-safe.
- /status returns points-derived rank + access; /neighborhood is the keyset
  view; /export includes points. /award stays the AwardSecret connector seam.
- tests: #43 gate tests adapted to points (competition-rank ordering, sticky
  access, default slugs); points_integration + the 10M rankbench carried over,
  the bench gated behind WL_BENCH and capped under -short; new boost seam test.

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-14 12:05:56 -07:00

98 lines
2.6 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 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 points-based waitlist plugin.
//
// On OnBootstrap it auto-creates/upgrades three collections (`waitlists`,
// `waitlist_entries`, `waitlist_events`) and seeds any configured default
// waitlists. On OnServe it mounts the REST surface under /v1/waitlist. The
// plugin owns its collections (no public CRUD); the dashboard remains
// available to superusers.
func Register(app core.App, cfg Config) error {
if !cfg.Enabled {
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")
// public
g.POST("/join", p.handleJoin)
g.GET("/status", p.handleStatus)
g.GET("/neighborhood", p.handleNeighborhood)
g.GET("/list", p.handleList)
g.GET("/activity", p.handleActivity)
g.POST("/track-share", p.handleTrackShare)
g.POST("/invite", p.handleInvite)
// service-authed (superuser / AdminSecret) — the caller-amount boost seam
g.POST("/boost", p.handleBoost)
// server-to-server (AwardSecret) — the verified-event award seam
g.POST("/award", p.handleAward)
// admin
g.GET("/export", p.handleExport)
}