✨ feat(user): magic-link expired-token cleanup loop (ADR-0028 Phase A consequence)
Periodically delete magic-link tokens past their expires_at — the rows accumulate without cleanup since the consume endpoint only marks them consumed but never removes them, and DeleteExpiredMagicLinkTokens (added in Phase A.3, PR #61) was never wired up. Mirrors the JWT secret cleanup pattern (ADR-0021). - pkg/user/magic_link_cleanup.go: MagicLinkCleanupRunner with StartCleanupLoop(ctx, interval) and an inner runOnce(ctx) for testability - pkg/user/magic_link_cleanup_test.go: unit tests cover happy path, error propagation, ctx-cancel termination, zero-interval no-op - pkg/server/server.go: start the loop right after the JWT cleanup loop - pkg/config/config.go: auth.magic_link.cleanup_interval (default 1h), env DLC_AUTH_MAGIC_LINK_CLEANUP_INTERVAL, getter GetMagicLinkCleanupInterval Mostly authored by Mistral Vibe in batch1-task-b worktree (parallel with batch1-task-a OIDC config). Mistral hit --max-price 1.50 right before the final commit step, Claude finished the ship via trainer takeover (Q-045 pattern, 2nd recurrence in two batches — to be addressed in Phase 1bis).
This commit is contained in:
@@ -113,8 +113,9 @@ type AuthConfig struct {
|
||||
|
||||
// MagicLinkConfig holds passwordless-auth magic-link parameters (ADR-0028 Phase A).
|
||||
type MagicLinkConfig struct {
|
||||
TTL time.Duration `mapstructure:"ttl"`
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
TTL time.Duration `mapstructure:"ttl"`
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
CleanupInterval time.Duration `mapstructure:"cleanup_interval"`
|
||||
}
|
||||
|
||||
// EmailConfig holds outgoing email transport configuration.
|
||||
@@ -286,6 +287,7 @@ func LoadConfig() (*Config, error) {
|
||||
// Magic-link defaults (ADR-0028 Phase A).
|
||||
v.SetDefault("auth.magic_link.ttl", 15*time.Minute)
|
||||
v.SetDefault("auth.magic_link.base_url", "http://localhost:8080")
|
||||
v.SetDefault("auth.magic_link.cleanup_interval", 1*time.Hour)
|
||||
|
||||
// Check for custom config file path via environment variable
|
||||
if configFile := os.Getenv("DLC_CONFIG_FILE"); configFile != "" {
|
||||
@@ -343,6 +345,7 @@ func LoadConfig() (*Config, error) {
|
||||
// Magic-link environment variables (ADR-0028 Phase A).
|
||||
v.BindEnv("auth.magic_link.ttl", "DLC_AUTH_MAGIC_LINK_TTL")
|
||||
v.BindEnv("auth.magic_link.base_url", "DLC_AUTH_MAGIC_LINK_BASE_URL")
|
||||
v.BindEnv("auth.magic_link.cleanup_interval", "DLC_AUTH_MAGIC_LINK_CLEANUP_INTERVAL")
|
||||
v.BindEnv("telemetry.sampler.type", "DLC_TELEMETRY_SAMPLER_TYPE")
|
||||
v.BindEnv("telemetry.sampler.ratio", "DLC_TELEMETRY_SAMPLER_RATIO")
|
||||
|
||||
@@ -494,6 +497,14 @@ func (c *Config) GetMagicLinkConfig() MagicLinkConfig {
|
||||
return out
|
||||
}
|
||||
|
||||
// GetMagicLinkCleanupInterval returns the magic-link cleanup interval (ADR-0028 Phase A consequence).
|
||||
func (c *Config) GetMagicLinkCleanupInterval() time.Duration {
|
||||
if c.Auth.MagicLink.CleanupInterval <= 0 {
|
||||
return 1 * time.Hour
|
||||
}
|
||||
return c.Auth.MagicLink.CleanupInterval
|
||||
}
|
||||
|
||||
// GetJWTTTL returns the JWT TTL
|
||||
func (c *Config) GetJWTTTL() time.Duration {
|
||||
if c.Auth.JWT.TTL == 0 {
|
||||
|
||||
@@ -764,6 +764,12 @@ func (s *Server) Run() error {
|
||||
s.userService.StartJWTSecretCleanupLoop(rootCtx, s.config.GetJWTSecretCleanupInterval())
|
||||
}
|
||||
|
||||
// Start the magic-link expired-token cleanup loop (ADR-0028 Phase A consequence).
|
||||
if mlRepo, ok := s.userRepo.(user.MagicLinkRepository); ok {
|
||||
runner := user.NewMagicLinkCleanupRunner(mlRepo)
|
||||
runner.StartCleanupLoop(rootCtx, s.config.GetMagicLinkCleanupInterval())
|
||||
}
|
||||
|
||||
// Wire the sampler hot-reload callback (ADR-0023 Phase 3, sub-phase 3.3).
|
||||
// telemetrySetup is non-nil only when telemetry was successfully initialized
|
||||
// at startup — hot-reloading telemetry-on is out of scope (see ADR-0023).
|
||||
|
||||
56
pkg/user/magic_link_cleanup.go
Normal file
56
pkg/user/magic_link_cleanup.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// MagicLinkCleanupRunner periodically deletes expired magic-link tokens
|
||||
// (ADR-0028 Phase A consequence — the rows accumulate without cleanup
|
||||
// otherwise, and stale rows are pure overhead since the token plaintext
|
||||
// is never stored).
|
||||
type MagicLinkCleanupRunner struct {
|
||||
repo MagicLinkRepository
|
||||
}
|
||||
|
||||
// NewMagicLinkCleanupRunner creates a new cleanup runner.
|
||||
func NewMagicLinkCleanupRunner(repo MagicLinkRepository) *MagicLinkCleanupRunner {
|
||||
return &MagicLinkCleanupRunner{repo: repo}
|
||||
}
|
||||
|
||||
// StartCleanupLoop runs the cleanup pass every `interval`. Stops when ctx
|
||||
// is cancelled. interval <= 0 disables the loop.
|
||||
func (r *MagicLinkCleanupRunner) StartCleanupLoop(ctx context.Context, interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_, _ = r.runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// runOnce performs a single cleanup pass. Returns the count of deleted rows.
|
||||
// Exposed for testing — tests drive runOnce directly instead of waiting on
|
||||
// the ticker.
|
||||
func (r *MagicLinkCleanupRunner) runOnce(ctx context.Context) (int64, error) {
|
||||
n, err := r.repo.DeleteExpiredMagicLinkTokens(ctx, time.Now())
|
||||
if err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("magic-link cleanup: delete failed")
|
||||
return 0, err
|
||||
}
|
||||
if n > 0 {
|
||||
log.Trace().Ctx(ctx).Int64("deleted", n).Msg("magic-link cleanup: removed expired tokens")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
64
pkg/user/magic_link_cleanup_test.go
Normal file
64
pkg/user/magic_link_cleanup_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeMLRepo struct {
|
||||
deleteN int64
|
||||
deleteErr error
|
||||
cutoffSeen time.Time
|
||||
}
|
||||
|
||||
func (r *fakeMLRepo) CreateMagicLinkToken(_ context.Context, _ *MagicLinkToken) error { return nil }
|
||||
func (r *fakeMLRepo) GetMagicLinkTokenByHash(_ context.Context, _ string) (*MagicLinkToken, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *fakeMLRepo) MarkMagicLinkTokenConsumed(_ context.Context, _ uint, _ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (r *fakeMLRepo) DeleteExpiredMagicLinkTokens(_ context.Context, before time.Time) (int64, error) {
|
||||
r.cutoffSeen = before
|
||||
return r.deleteN, r.deleteErr
|
||||
}
|
||||
|
||||
func TestRunOnce_ReturnsCount(t *testing.T) {
|
||||
repo := &fakeMLRepo{deleteN: 7}
|
||||
r := NewMagicLinkCleanupRunner(repo)
|
||||
n, err := r.runOnce(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 7, n)
|
||||
assert.WithinDuration(t, time.Now(), repo.cutoffSeen, time.Second)
|
||||
}
|
||||
|
||||
func TestRunOnce_PropagatesError(t *testing.T) {
|
||||
repo := &fakeMLRepo{deleteErr: errors.New("simulated")}
|
||||
r := NewMagicLinkCleanupRunner(repo)
|
||||
_, err := r.runOnce(context.Background())
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestStartCleanupLoop_StopsOnContextCancel(t *testing.T) {
|
||||
repo := &fakeMLRepo{}
|
||||
r := NewMagicLinkCleanupRunner(repo)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r.StartCleanupLoop(ctx, 10*time.Millisecond)
|
||||
time.Sleep(25 * time.Millisecond) // 2 ticks
|
||||
cancel()
|
||||
time.Sleep(15 * time.Millisecond) // give the goroutine time to exit
|
||||
// Implicit assertion: no goroutine leak (test would hang in -race mode otherwise).
|
||||
}
|
||||
|
||||
func TestStartCleanupLoop_NoOpWhenIntervalZero(t *testing.T) {
|
||||
repo := &fakeMLRepo{}
|
||||
r := NewMagicLinkCleanupRunner(repo)
|
||||
r.StartCleanupLoop(context.Background(), 0)
|
||||
// Just make sure no goroutine is started ; nothing observable to assert
|
||||
// beyond "no panic, returns immediately".
|
||||
}
|
||||
Reference in New Issue
Block a user