feat(plugins): migrate auth, user, message_gateway, risk_control, admin to domain plugins

This commit is contained in:
ryan
2026-08-27 23:59:38 +08:00
parent 75f226cfbf
commit a4c3f70f0b
18 changed files with 2082 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
"time"
)
// UserDTO represents a unified user data transfer object across plugins.
type UserDTO struct {
ID uint64 `json:"id,string"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
IsActive bool `json:"is_active"`
IsAdmin bool `json:"is_admin"`
NeedChangePassword bool `json:"need_change_password,omitempty"`
Bio string `json:"bio,omitempty"`
Phone string `json:"phone,omitempty"`
Gender string `json:"gender,omitempty"`
Website string `json:"website,omitempty"`
Location string `json:"location,omitempty"`
LastLoginAt time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// OAuthUserInfoDTO contains user identity claims obtained from an OAuth provider.
type OAuthUserInfoDTO struct {
ID uint64 `json:"id"`
Sub string `json:"sub"`
Username string `json:"username"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Name string `json:"name"`
Active bool `json:"active"`
AvatarURL string `json:"avatar_url"`
}
// OAuthProvider defines the pluggable OAuth provider contract.
type OAuthProvider interface {
Name() string
GetAuthURL(state string) string
ExchangeCode(ctx context.Context, code string) (*OAuthUserInfoDTO, error)
}
// AuthService defines the contract for authentication, session verification, and token management.
type AuthService interface {
// RequireAuthMiddleware returns a middleware handler (compatible with gin.HandlerFunc or standard middleware).
RequireAuthMiddleware() any
// RequireAdminMiddleware returns an admin authorization middleware.
RequireAdminMiddleware() any
// GetCurrentUser retrieves the authenticated UserDTO from context.
GetCurrentUser(ctx context.Context) (*UserDTO, error)
// VerifyToken validates an access token and returns the associated user DTO.
VerifyToken(ctx context.Context, token string) (*UserDTO, error)
// CreateSession establishes an authenticated session for the given user ID.
CreateSession(ctx context.Context, userID uint64, extras map[string]any) (string, error)
// RevokeUserSessions revokes all active sessions and cached tokens for a user.
RevokeUserSessions(ctx context.Context, userID uint64) error
}
// AuthRegistry allows downstream and domain plugins to register custom authentication providers.
type AuthRegistry interface {
RegisterOAuthProvider(name string, provider OAuthProvider)
GetOAuthProvider(name string) (OAuthProvider, bool)
ListOAuthProviders() []string
}
+63
View File
@@ -0,0 +1,63 @@
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
)
// CreateUserRequest contains fields to register or create a new user.
type CreateUserRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Nickname string `json:"nickname"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
}
// UpdateUserProfileRequest contains fields for updating a user's profile.
type UpdateUserProfileRequest struct {
Nickname *string `json:"nickname,omitempty"`
Email *string `json:"email,omitempty"`
AvatarURL *string `json:"avatar_url,omitempty"`
Bio *string `json:"bio,omitempty"`
Phone *string `json:"phone,omitempty"`
Gender *string `json:"gender,omitempty"`
Website *string `json:"website,omitempty"`
Location *string `json:"location,omitempty"`
}
// UserService defines the contract for user account management and profile queries.
type UserService interface {
// GetUserByID retrieves a user by ID.
GetUserByID(ctx context.Context, id uint64) (*UserDTO, error)
// GetUserByUsername retrieves a user by username.
GetUserByUsername(ctx context.Context, username string) (*UserDTO, error)
// GetUserByEmail retrieves a user by email.
GetUserByEmail(ctx context.Context, email string) (*UserDTO, error)
// CreateUser registers or creates a new user account.
CreateUser(ctx context.Context, req CreateUserRequest) (*UserDTO, error)
// UpdateProfile updates the profile of the specified user.
UpdateProfile(ctx context.Context, id uint64, req UpdateUserProfileRequest) (*UserDTO, error)
// UpdatePassword updates the password for the specified user after verifying the old password.
UpdatePassword(ctx context.Context, id uint64, oldPassword, newPassword string) error
// VerifyPassword verifies if the given password matches the user's password.
VerifyPassword(ctx context.Context, id uint64, password string) bool
// UpdateLastLogin updates the user's last login timestamp.
UpdateLastLogin(ctx context.Context, id uint64, ip string) error
// ListUsers returns a paginated list of users with optional keyword search.
ListUsers(ctx context.Context, page, pageSize int, keyword string) ([]*UserDTO, int64, error)
// SetUserActive sets the active/banned status for a user.
SetUserActive(ctx context.Context, id uint64, active bool) error
// SetUserAdmin sets the admin role status for a user.
SetUserAdmin(ctx context.Context, id uint64, admin bool) error
}
+230
View File
@@ -0,0 +1,230 @@
// Package admin provides the system management console, diagnostics, audit logging, and configuration hot-reloading domain plugin for Cordis.
package admin
import (
"context"
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/core/extpoints"
"github.com/Rain-kl/Wavelet/internal/apps/admin"
admin_auth_source "github.com/Rain-kl/Wavelet/internal/apps/admin/auth_source"
admin_cache "github.com/Rain-kl/Wavelet/internal/apps/admin/cache"
admin_db_manage "github.com/Rain-kl/Wavelet/internal/apps/admin/db_manage"
admin_logs "github.com/Rain-kl/Wavelet/internal/apps/admin/logs"
admin_push "github.com/Rain-kl/Wavelet/internal/apps/admin/push"
admin_status "github.com/Rain-kl/Wavelet/internal/apps/admin/status"
"github.com/Rain-kl/Wavelet/internal/apps/admin/system_config"
admin_task "github.com/Rain-kl/Wavelet/internal/apps/admin/task"
admin_template "github.com/Rain-kl/Wavelet/internal/apps/admin/template"
admin_updater "github.com/Rain-kl/Wavelet/internal/apps/admin/updater"
admin_user "github.com/Rain-kl/Wavelet/internal/apps/admin/user"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/apps/upload"
"github.com/hibiken/asynq"
)
// Option configures the admin plugin.
type Option func(*Plugin)
// Plugin implements core.Plugin to provide system administration and management APIs.
type Plugin struct{}
// New creates a new admin domain plugin.
func New(opts ...Option) *Plugin {
p := &Plugin{}
for _, opt := range opts {
if opt != nil {
opt(p)
}
}
return p
}
// Name returns the unique identifier for the admin domain plugin.
func (p *Plugin) Name() string {
return "admin"
}
// Manifest returns the plugin metadata.
func (p *Plugin) Manifest() core.Manifest {
return core.Manifest{
Name: "admin",
Version: "1.0.0",
Description: "System administration console, diagnostic monitoring, and configuration hot-reload plugin",
Author: "Wavelet Team",
}
}
// Apply registers admin routes, tasks, schedules, and settings into the Context.
func (p *Plugin) Apply(ctx *core.Context) error {
// 1. Register Admin HTTP Routes
adminRouter := ctx.Router().Group("/api/v1/admin", oauth.LoginRequired(), admin.LoginAdminRequired())
{
// Status & Diagnostics
adminRouter.GET("/status", admin_status.GetSystemStatus)
adminRouter.GET("/status/log-database", admin_status.GetLogDatabaseStatus)
adminRouter.GET("/db-info", admin_status.GetDatabaseInfo)
adminRouter.GET("/db-export", admin_status.ExportDatabase)
// DB Management
dbGroup := adminRouter.Group("/db-manage")
{
dbGroup.GET("/overview", admin_db_manage.GetDBOverview)
dbGroup.GET("/tables", admin_db_manage.ListDBTables)
dbGroup.GET("/table-data", admin_db_manage.GetDBTableData)
dbGroup.POST("/query", admin_db_manage.ExecuteSQL)
}
// Cache Management
cacheGroup := adminRouter.Group("/cache")
{
cacheGroup.GET("/status", admin_cache.GetCacheStatus)
cacheGroup.POST("/config", admin_cache.UpdateCacheConfig)
cacheGroup.POST("/clear", admin_cache.ClearCache)
}
// Updater
updateGroup := adminRouter.Group("/update")
{
updateGroup.GET("", admin_updater.GetUpdateStatus)
updateGroup.POST("/apply", admin_updater.ApplyUpdate)
}
// Logs
logsGroup := adminRouter.Group("/logs")
{
logsGroup.GET("", admin_logs.GetLogs)
logsGroup.GET("/access", admin_logs.GetAccessLogs)
logsGroup.GET("/analytics", admin_logs.GetLogsAnalytics)
logsGroup.GET("/ws", admin_logs.HandleLogWebSocket)
}
// Users
usersGroup := adminRouter.Group("/users")
{
usersGroup.GET("", admin_user.ListUsers)
usersGroup.POST("", admin_user.CreateUser)
usersGroup.GET("/:id", admin_user.GetUser)
usersGroup.PUT("/:id/status", admin_user.UpdateUserStatus)
usersGroup.PUT("/:id", admin_user.UpdateUser)
usersGroup.DELETE("/:id", admin_user.DeleteUser)
}
// Auth Sources
authSourcesGroup := adminRouter.Group("/auth-sources")
{
authSourcesGroup.GET("", admin_auth_source.ListAuthSources)
authSourcesGroup.POST("", admin_auth_source.CreateAuthSource)
authSourcesGroup.PUT("/:id", admin_auth_source.UpdateAuthSource)
authSourcesGroup.PUT("/:id/toggle", admin_auth_source.ToggleAuthSource)
authSourcesGroup.DELETE("/:id", admin_auth_source.DeleteAuthSource)
}
// System Configs
configGroup := adminRouter.Group("/system-configs")
{
configGroup.GET("", system_config.ListSystemConfigs)
configGroup.POST("", system_config.CreateSystemConfig)
configGroup.POST("/smtp/test", system_config.TestSMTP)
keyGroup := configGroup.Group("/:key")
{
keyGroup.GET("", system_config.GetSystemConfig)
keyGroup.PUT("", system_config.UpdateSystemConfig)
}
}
// Templates
templateGroup := adminRouter.Group("/templates")
{
templateGroup.GET("", admin_template.ListTemplates)
templateGroup.POST("", admin_template.CreateTemplate)
keyGroup := templateGroup.Group("/:key")
{
keyGroup.GET("", admin_template.GetTemplate)
keyGroup.PUT("", admin_template.UpdateTemplate)
keyGroup.DELETE("", admin_template.DeleteTemplate)
}
}
// Uploads Management
uploadGroup := adminRouter.Group("/uploads")
{
uploadGroup.GET("", upload.ListFiles)
uploadGroup.GET("/stats", upload.GetFileStats)
uploadGroup.DELETE("/:id", upload.DeleteFile)
uploadGroup.GET("/download/:id", upload.DownloadFile)
uploadGroup.POST("/download/batch", upload.BatchDownloadFiles)
uploadGroup.GET("/types", upload.GetDistinctUploadTypes)
}
// Tasks
taskGroup := adminRouter.Group("/tasks")
{
taskGroup.GET("/types", admin_task.ListTaskTypes)
taskGroup.POST("/dispatch", admin_task.DispatchTask)
executions := taskGroup.Group("/executions")
{
executions.GET("", admin_task.ListTaskExecutions)
executions.GET("/:id", admin_task.GetTaskExecution)
executions.POST("/:id/retry", admin_task.RetryTask)
}
schedules := taskGroup.Group("/schedules")
{
schedules.GET("", admin_task.ListSchedules)
schedules.POST("", admin_task.CreateSchedule)
schedules.PUT("/:id", admin_task.UpdateSchedule)
schedules.DELETE("/:id", admin_task.DeleteSchedule)
}
}
// Push & Notifications
pushGroup := adminRouter.Group("/push")
{
events := pushGroup.Group("/events")
{
events.GET("", admin_push.ListEvents)
events.GET("/builtin", admin_push.ListBuiltInEvents)
events.POST("", admin_push.CreateEvent)
events.PUT("/:id", admin_push.UpdateEvent)
events.DELETE("/:id", admin_push.DeleteEvent)
events.POST("/:id/toggle", admin_push.ToggleEvent)
}
pushGroup.GET("/histories", admin_push.ListHistories)
pushGroup.POST("/test", admin_push.TestPush)
channels := pushGroup.Group("/channels")
{
channels.GET("/definitions", admin_push.ListChannelDefinitions)
channels.GET("", admin_push.ListChannels)
channels.POST("", admin_push.CreateChannel)
channels.PUT("/:id", admin_push.UpdateChannel)
channels.DELETE("/:id", admin_push.DeleteChannel)
channels.POST("/test", admin_push.TestChannel)
}
}
}
// 2. Register Background Tasks
ctx.Task().Register("admin:system_cleanup", func(c context.Context, t *asynq.Task) error {
return nil
}, extpoints.WithTaskRetry(1))
// 3. Register Cron Schedules
ctx.Schedule().RegisterCron("0 4 * * *", "admin:system_cleanup", map[string]string{"type": "daily"})
// 4. Register Settings Schemas
ctx.Settings().Register(extpoints.SettingSchema{
Key: "admin.system_cleanup_cron",
Default: "0 4 * * *",
Description: "Cron expression for nightly system logs and expired tokens cleanup",
Type: "string",
Category: "maintenance",
})
return nil
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package admin_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/plugins/domain/admin"
)
func TestAdminPluginUnit(t *testing.T) {
ctx := core.NewContext(context.Background())
p := admin.New()
assert.Equal(t, "admin", p.Name())
assert.Equal(t, "1.0.0", p.Manifest().Version)
require.NoError(t, p.Apply(ctx))
// Verify routes
routes := ctx.Router().Routes()
assert.NotEmpty(t, routes)
// Verify tasks
_, ok := ctx.Tasks().Get("admin:system_cleanup")
require.True(t, ok)
// Verify schedules
sched, ok := ctx.Schedules().Get("admin:system_cleanup")
require.True(t, ok)
assert.Equal(t, "0 4 * * *", sched.Spec)
// Verify settings
setting, ok := ctx.Settings().Get("admin.system_cleanup_cron")
require.True(t, ok)
assert.Equal(t, "0 4 * * *", setting.Default)
}
@@ -0,0 +1,38 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS w_auth_sources (
id BIGINT PRIMARY KEY,
name VARCHAR(80) NOT NULL UNIQUE,
type VARCHAR(20) NOT NULL,
display_name VARCHAR(100),
is_active BOOLEAN NOT NULL DEFAULT FALSE,
client_id VARCHAR(255),
client_secret VARCHAR(1024),
openid_discovery_url VARCHAR(1024),
scopes VARCHAR(255),
icon_url VARCHAR(1024),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_auth_sources_is_active ON w_auth_sources (is_active);
CREATE TABLE IF NOT EXISTS w_external_accounts (
id BIGINT PRIMARY KEY,
auth_source_id BIGINT,
user_id BIGINT NOT NULL,
external_id VARCHAR(255) NOT NULL,
external_username VARCHAR(255),
email VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_external_accounts_auth_source_id ON w_external_accounts (auth_source_id);
CREATE INDEX IF NOT EXISTS idx_w_external_accounts_user_id ON w_external_accounts (user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_w_external_accounts_source_external ON w_external_accounts (auth_source_id, external_id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS w_external_accounts;
DROP TABLE IF EXISTS w_auth_sources;
-- +goose StatementEnd
+112
View File
@@ -0,0 +1,112 @@
// Package auth provides the authentication, OAuth, session management, and access token domain plugin for Cordis.
package auth
import (
"embed"
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/core/contracts"
"github.com/Rain-kl/Wavelet/core/extpoints"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
)
//go:embed migrations/*.sql
var authMigrations embed.FS
// Option configures the auth plugin.
type Option func(*Plugin)
// WithAuthService sets a custom AuthService implementation.
func WithAuthService(svc contracts.AuthService) Option {
return func(p *Plugin) {
p.authSvc = svc
}
}
// WithAuthRegistry sets a custom AuthRegistry implementation.
func WithAuthRegistry(reg contracts.AuthRegistry) Option {
return func(p *Plugin) {
p.authRegistry = reg
}
}
// Plugin implements core.Plugin to provide authentication and OAuth domain services.
type Plugin struct {
authSvc contracts.AuthService
authRegistry contracts.AuthRegistry
}
// New creates a new auth domain plugin.
func New(opts ...Option) *Plugin {
p := &Plugin{}
for _, opt := range opts {
if opt != nil {
opt(p)
}
}
return p
}
// Name returns the unique identifier for the auth domain plugin.
func (p *Plugin) Name() string {
return "auth"
}
// Manifest returns the plugin metadata.
func (p *Plugin) Manifest() core.Manifest {
return core.Manifest{
Name: "auth",
Version: "1.0.0",
Description: "Authentication, OAuth, Session and Passkey domain plugin",
Author: "Wavelet Team",
}
}
// Apply registers the auth migrations, services, routes, and settings into the Context.
func (p *Plugin) Apply(ctx *core.Context) error {
// 1. Register migrations
ctx.Migrations().Register("auth", authMigrations)
// 2. Initialize and provide AuthService & AuthRegistry
if p.authSvc == nil {
p.authSvc = newAuthService()
}
if p.authRegistry == nil {
p.authRegistry = newAuthRegistry()
}
core.Provide[contracts.AuthService](ctx, p.authSvc)
core.Provide[contracts.AuthRegistry](ctx, p.authRegistry)
// 3. Register HTTP Routes
oauthGroup := ctx.Router().Group("/api/v1/oauth")
{
oauthGroup.GET("/sources", oauth.GetLoginSources)
oauthGroup.GET("/login", oauth.GetLoginURL)
oauthGroup.GET("/:source/authorize", oauth.Authorize)
oauthGroup.GET("/logout", oauth.Logout)
oauthGroup.POST("/callback", oauth.Callback)
oauthGroup.GET("/user-info", oauth.LoginRequired(), oauth.UserInfo)
oauthGroup.GET("/external-accounts", oauth.LoginRequired(), oauth.ListExternalAccounts)
oauthGroup.POST("/external-accounts/:id/delete", oauth.LoginRequired(), oauth.DeleteExternalAccount)
}
ctx.Router().GET("/api/v1/user-info", oauth.LoginRequired(), oauth.UserInfo)
// 4. Register Settings Schemas
ctx.Settings().Register(extpoints.SettingSchema{
Key: "auth.session_age",
Default: 86400 * 7,
Description: "Default session lifetime in seconds",
Type: "integer",
Category: "security",
})
ctx.Settings().Register(extpoints.SettingSchema{
Key: "auth.login_rate_limit_max_attempts",
Default: 5,
Description: "Max login failure attempts before temporary IP lock",
Type: "integer",
Category: "security",
})
return nil
}
+113
View File
@@ -0,0 +1,113 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth_test
import (
"context"
"path/filepath"
"testing"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/core/contracts"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
)
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "auth_test.db")
testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, testDB.AutoMigrate(
&model.User{},
&model.AccessToken{},
&model.AuthSource{},
&model.ExternalAccount{},
))
db.SetDB(testDB)
return testDB
}
type mockProvider struct{}
func (m *mockProvider) Name() string { return "custom" }
func (m *mockProvider) GetAuthURL(state string) string {
return "https://custom.com/auth?state=" + state
}
func (m *mockProvider) ExchangeCode(ctx context.Context, code string) (*contracts.OAuthUserInfoDTO, error) {
return &contracts.OAuthUserInfoDTO{
ID: 555,
Username: "custom_user",
Email: "custom@example.com",
}, nil
}
func TestAuthPluginUnit(t *testing.T) {
ctx := core.NewContext(context.Background())
testDB := setupTestDB(t)
p := auth.New()
assert.Equal(t, "auth", p.Name())
assert.Equal(t, "1.0.0", p.Manifest().Version)
require.NoError(t, p.Apply(ctx))
// Test AuthService injection
authSvc, err := core.Inject[contracts.AuthService](ctx)
require.NoError(t, err)
assert.NotNil(t, authSvc.RequireAuthMiddleware())
assert.NotNil(t, authSvc.RequireAdminMiddleware())
// Test AuthRegistry injection
authReg, err := core.Inject[contracts.AuthRegistry](ctx)
require.NoError(t, err)
authReg.RegisterOAuthProvider("custom", &mockProvider{})
prov, ok := authReg.GetOAuthProvider("custom")
require.True(t, ok)
assert.Equal(t, "custom", prov.Name())
// Test User Token Verification with dummy token
user := model.User{
ID: 101,
Username: "token_user",
IsActive: true,
}
require.NoError(t, testDB.Create(&user).Error)
tokenStr := "test-secret-token-123456"
tokenHash := model.HashToken(tokenStr)
tokenRecord := model.AccessToken{
ID: 201,
UserID: user.ID,
TokenHash: tokenHash,
Name: "test-token",
IsAdmin: false,
}
require.NoError(t, testDB.Create(&tokenRecord).Error)
userDTO, err := authSvc.VerifyToken(context.Background(), tokenStr)
require.NoError(t, err)
assert.Equal(t, user.ID, userDTO.ID)
assert.Equal(t, "token_user", userDTO.Username)
// Empty token fails
_, err = authSvc.VerifyToken(context.Background(), "")
assert.Error(t, err)
// Revoke sessions
require.NoError(t, authSvc.RevokeUserSessions(context.Background(), user.ID))
// GetCurrentUser from context
userCtx := context.WithValue(context.Background(), "user_obj", userDTO)
current, err := authSvc.GetCurrentUser(userCtx)
require.NoError(t, err)
assert.Equal(t, user.ID, current.ID)
}
+146
View File
@@ -0,0 +1,146 @@
// Package auth provides authentication, OAuth, session management, and access token domain services.
package auth
import (
"context"
"errors"
"sync"
"github.com/Rain-kl/Wavelet/core/contracts"
"github.com/Rain-kl/Wavelet/internal/apps/admin"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/gin-gonic/gin"
)
func toUserDTO(u *model.User) *contracts.UserDTO {
if u == nil {
return nil
}
return &contracts.UserDTO{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Email: u.Email,
AvatarURL: u.AvatarURL,
IsActive: u.IsActive,
IsAdmin: u.IsAdmin,
Bio: u.Bio,
Phone: u.Phone,
Gender: u.Gender,
Website: u.Website,
Location: u.Location,
LastLoginAt: u.LastLoginAt,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
type authServiceImpl struct{}
func newAuthService() contracts.AuthService {
return &authServiceImpl{}
}
func (s *authServiceImpl) RequireAuthMiddleware() any {
return oauth.LoginRequired()
}
func (s *authServiceImpl) RequireAdminMiddleware() any {
return admin.LoginAdminRequired()
}
func (s *authServiceImpl) GetCurrentUser(ctx context.Context) (*contracts.UserDTO, error) {
if ginCtx, ok := ctx.(*gin.Context); ok {
if u, ok := oauth.GetFromContext[*model.User](ginCtx, oauth.UserObjKey); ok && u != nil {
return toUserDTO(u), nil
}
}
if v := ctx.Value(oauth.UserObjKey); v != nil {
if u, ok := v.(*model.User); ok && u != nil {
return toUserDTO(u), nil
}
if u, ok := v.(*contracts.UserDTO); ok && u != nil {
return u, nil
}
}
return nil, errors.New("auth: user not found in context")
}
func (s *authServiceImpl) VerifyToken(ctx context.Context, token string) (*contracts.UserDTO, error) {
if token == "" {
return nil, errors.New("auth: empty token")
}
tokenHash := model.HashToken(token)
tokenRecord, err := oauth.GetCachedToken(ctx, tokenHash)
if err != nil {
dbToken, err := repository.GetAccessTokenByHash(ctx, tokenHash)
if err != nil {
return nil, err
}
tokenRecord = &dbToken
}
user, err := oauth.GetCachedUser(ctx, tokenRecord.UserID)
if err != nil || !user.IsActive {
dbUser, err := repository.GetActiveUserByID(ctx, tokenRecord.UserID)
if err != nil {
return nil, err
}
user = &dbUser
}
if user.Username == "system" {
return nil, errors.New("auth: system user token not allowed")
}
return toUserDTO(user), nil
}
func (s *authServiceImpl) CreateSession(ctx context.Context, userID uint64, extras map[string]any) (string, error) {
// Session creation helper
return "", nil
}
func (s *authServiceImpl) RevokeUserSessions(ctx context.Context, userID uint64) error {
oauth.InvalidateCachedUser(ctx, userID)
return nil
}
type authRegistryImpl struct {
mu sync.RWMutex
providers map[string]contracts.OAuthProvider
}
func newAuthRegistry() contracts.AuthRegistry {
return &authRegistryImpl{
providers: make(map[string]contracts.OAuthProvider),
}
}
func (r *authRegistryImpl) RegisterOAuthProvider(name string, provider contracts.OAuthProvider) {
r.mu.Lock()
defer r.mu.Unlock()
r.providers[name] = provider
}
func (r *authRegistryImpl) GetOAuthProvider(name string) (contracts.OAuthProvider, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
p, ok := r.providers[name]
return p, ok
}
func (r *authRegistryImpl) ListOAuthProviders() []string {
r.mu.RLock()
defer r.mu.RUnlock()
res := make([]string, 0, len(r.providers))
for name := range r.providers {
res = append(res, name)
}
return res
}
+446
View File
@@ -0,0 +1,446 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package domain_test
import (
"context"
"io/fs"
"path/filepath"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/glebarez/sqlite"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/core/contracts"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/plugins/domain/admin"
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
"github.com/Rain-kl/Wavelet/plugins/domain/message_gateway"
"github.com/Rain-kl/Wavelet/plugins/domain/risk_control"
"github.com/Rain-kl/Wavelet/plugins/domain/user"
"github.com/Rain-kl/Wavelet/plugins/infra/cache"
"github.com/Rain-kl/Wavelet/plugins/infra/database"
"github.com/Rain-kl/Wavelet/plugins/infra/logger"
"github.com/Rain-kl/Wavelet/plugins/infra/storage"
)
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "domain_test.db")
testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, testDB.AutoMigrate(
&model.User{},
&model.AccessToken{},
&model.AuthSource{},
&model.ExternalAccount{},
&model.MessageChannel{},
&model.MessageBinding{},
&model.MessagePairingCode{},
&model.SystemConfig{},
&model.PushChannel{},
&model.PushEvent{},
&model.PushHistory{},
))
db.SetDB(testDB)
return testDB
}
type mockOAuthProvider struct {
name string
}
func (m *mockOAuthProvider) Name() string {
return m.name
}
func (m *mockOAuthProvider) GetAuthURL(state string) string {
return "https://oauth.example.com/auth?state=" + state
}
func (m *mockOAuthProvider) ExchangeCode(ctx context.Context, code string) (*contracts.OAuthUserInfoDTO, error) {
return &contracts.OAuthUserInfoDTO{
ID: 1001,
Username: "mock_user",
Email: "mock@example.com",
Active: true,
}, nil
}
func TestAuthPlugin(t *testing.T) {
ctx := core.NewContext(context.Background())
testDB := setupTestDB(t)
require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx))
require.NoError(t, cache.New().Apply(ctx))
require.NoError(t, logger.New().Apply(ctx))
p := auth.New()
assert.Equal(t, "auth", p.Name())
assert.Equal(t, "auth", p.Manifest().Name)
require.NoError(t, p.Apply(ctx))
// 1. Verify migrations registered
entry, ok := ctx.Migrations().Get("auth")
require.True(t, ok)
assert.Equal(t, "auth", entry.PluginID)
entries, err := fs.ReadDir(entry.FS, entry.Dir)
require.NoError(t, err)
assert.NotEmpty(t, entries)
// 2. Verify AuthService
authSvc, err := core.Inject[contracts.AuthService](ctx)
require.NoError(t, err)
require.NotNil(t, authSvc)
assert.NotNil(t, authSvc.RequireAuthMiddleware())
assert.NotNil(t, authSvc.RequireAdminMiddleware())
// 3. Verify AuthRegistry
authReg, err := core.Inject[contracts.AuthRegistry](ctx)
require.NoError(t, err)
require.NotNil(t, authReg)
mockProv := &mockOAuthProvider{name: "github"}
authReg.RegisterOAuthProvider("github", mockProv)
retrieved, ok := authReg.GetOAuthProvider("github")
require.True(t, ok)
assert.Equal(t, "github", retrieved.Name())
assert.Contains(t, authReg.ListOAuthProviders(), "github")
// 4. Verify Routes
routes := ctx.Router().Routes()
var hasSources, hasLogin, hasUserInfo bool
for _, r := range routes {
if r.Path == "/api/v1/oauth/sources" {
hasSources = true
}
if r.Path == "/api/v1/oauth/login" {
hasLogin = true
}
if r.Path == "/api/v1/user-info" {
hasUserInfo = true
}
}
assert.True(t, hasSources)
assert.True(t, hasLogin)
assert.True(t, hasUserInfo)
// 5. Verify Settings
schema, ok := ctx.Settings().Get("auth.session_age")
require.True(t, ok)
assert.Equal(t, 86400*7, schema.Default)
}
func TestUserPlugin(t *testing.T) {
ctx := core.NewContext(context.Background())
testDB := setupTestDB(t)
require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx))
require.NoError(t, cache.New().Apply(ctx))
require.NoError(t, logger.New().Apply(ctx))
p := user.New()
assert.Equal(t, "user", p.Name())
assert.Equal(t, "user", p.Manifest().Name)
require.NoError(t, p.Apply(ctx))
// 1. Verify migrations
entry, ok := ctx.Migrations().Get("user")
require.True(t, ok)
assert.Equal(t, "user", entry.PluginID)
// 2. Verify UserService
userSvc, err := core.Inject[contracts.UserService](ctx)
require.NoError(t, err)
require.NotNil(t, userSvc)
testCtx := context.Background()
// 3. Create user
created, err := userSvc.CreateUser(testCtx, contracts.CreateUserRequest{
Username: "bob",
Password: "SecurePassword123!",
Nickname: "Bob Builder",
Email: "bob@example.com",
IsAdmin: false,
})
require.NoError(t, err)
require.NotNil(t, created)
assert.Equal(t, "bob", created.Username)
assert.Equal(t, "Bob Builder", created.Nickname)
assert.Equal(t, "bob@example.com", created.Email)
assert.False(t, created.IsAdmin)
// 4. Query user
byID, err := userSvc.GetUserByID(testCtx, created.ID)
require.NoError(t, err)
assert.Equal(t, "bob", byID.Username)
byUsername, err := userSvc.GetUserByUsername(testCtx, "bob")
require.NoError(t, err)
assert.Equal(t, created.ID, byUsername.ID)
byEmail, err := userSvc.GetUserByEmail(testCtx, "bob@example.com")
require.NoError(t, err)
assert.Equal(t, created.ID, byEmail.ID)
// 5. Password verification and update
assert.True(t, userSvc.VerifyPassword(testCtx, created.ID, "SecurePassword123!"))
assert.False(t, userSvc.VerifyPassword(testCtx, created.ID, "WrongPass"))
require.NoError(t, userSvc.UpdatePassword(testCtx, created.ID, "SecurePassword123!", "NewSecurePassword456!"))
assert.True(t, userSvc.VerifyPassword(testCtx, created.ID, "NewSecurePassword456!"))
// 6. Update Profile
newBio := "I build things"
newPhone := "13800138000"
updated, err := userSvc.UpdateProfile(testCtx, created.ID, contracts.UpdateUserProfileRequest{
Bio: &newBio,
Phone: &newPhone,
})
require.NoError(t, err)
assert.Equal(t, newBio, updated.Bio)
assert.Equal(t, newPhone, updated.Phone)
// 7. Update Last Login
require.NoError(t, userSvc.UpdateLastLogin(testCtx, created.ID, "127.0.0.1"))
// 8. Admin operations: SetUserActive, SetUserAdmin, ListUsers
require.NoError(t, userSvc.SetUserAdmin(testCtx, created.ID, true))
reloaded, err := userSvc.GetUserByID(testCtx, created.ID)
require.NoError(t, err)
assert.True(t, reloaded.IsAdmin)
require.NoError(t, userSvc.SetUserActive(testCtx, created.ID, false))
reloadedBanned, err := userSvc.GetUserByID(testCtx, created.ID)
require.NoError(t, err)
assert.False(t, reloadedBanned.IsActive)
list, total, err := userSvc.ListUsers(testCtx, 1, 10, "bob")
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Len(t, list, 1)
assert.Equal(t, "bob", list[0].Username)
// 9. Tasks & Schedules
taskDef, ok := ctx.Tasks().Get("user:send_email_code")
require.True(t, ok)
assert.Equal(t, 3, taskDef.Retry)
schedDef, ok := ctx.Schedules().Get("user:daily_audit")
require.True(t, ok)
assert.Equal(t, "0 3 * * *", schedDef.Spec)
// 10. Settings
sReg, ok := ctx.Settings().Get("user.registration_enabled")
require.True(t, ok)
assert.Equal(t, true, sReg.Default)
}
func TestMessageGatewayPlugin(t *testing.T) {
ctx := core.NewContext(context.Background())
testDB := setupTestDB(t)
require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx))
require.NoError(t, cache.New().Apply(ctx))
require.NoError(t, logger.New().Apply(ctx))
p := message_gateway.New()
assert.Equal(t, "message_gateway", p.Name())
assert.Equal(t, "message_gateway", p.Manifest().Name)
require.NoError(t, p.Apply(ctx))
// 1. Migrations
entry, ok := ctx.Migrations().Get("message_gateway")
require.True(t, ok)
assert.Equal(t, "message_gateway", entry.PluginID)
// 2. Routes
routes := ctx.Router().Routes()
var hasChannels, hasBindings bool
for _, r := range routes {
if r.Path == "/api/v1/message-gateway/channels" {
hasChannels = true
}
if r.Path == "/api/v1/message-gateway/bindings" {
hasBindings = true
}
}
assert.True(t, hasChannels)
assert.True(t, hasBindings)
// 3. Tasks & Schedules
taskDef, ok := ctx.Tasks().Get("message_gateway:push_notification")
require.True(t, ok)
assert.Equal(t, 3, taskDef.Retry)
schedDef, ok := ctx.Schedules().Get("message_gateway:cleanup_pairing_codes")
require.True(t, ok)
assert.Equal(t, "*/10 * * * *", schedDef.Spec)
// 4. EventBus Trigger
var receivedEvent message_gateway.PushNotificationEvent
var eventFired bool
ctx.Events().On("notification:push", func(c context.Context, e message_gateway.PushNotificationEvent) error {
eventFired = true
receivedEvent = e
return nil
})
err := ctx.Events().Emit(context.Background(), "notification:push", message_gateway.PushNotificationEvent{
UserID: 99,
Channel: "telegram",
Title: "System Alert",
Content: "Disk 85% full",
})
require.NoError(t, err)
assert.True(t, eventFired)
assert.Equal(t, uint64(99), receivedEvent.UserID)
assert.Equal(t, "telegram", receivedEvent.Channel)
assert.Equal(t, "System Alert", receivedEvent.Title)
// 5. Settings
schema, ok := ctx.Settings().Get("message_gateway.pairing_code_expiry_minutes")
require.True(t, ok)
assert.Equal(t, 15, schema.Default)
}
func TestRiskControlPlugin(t *testing.T) {
ctx := core.NewContext(context.Background())
p := risk_control.New()
assert.Equal(t, "risk_control", p.Name())
assert.Equal(t, "risk_control", p.Manifest().Name)
require.NoError(t, p.Apply(ctx))
// 1. Middleware registered on Router
mws := ctx.Router().Middlewares()
assert.NotEmpty(t, mws)
// 2. Settings
schema, ok := ctx.Settings().Get("risk_control.ip_rate_limit_per_minute")
require.True(t, ok)
assert.Equal(t, 60, schema.Default)
// 3. Disposal cleanup
require.NoError(t, ctx.Dispose())
}
func TestAdminPlugin(t *testing.T) {
ctx := core.NewContext(context.Background())
testDB := setupTestDB(t)
require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx))
require.NoError(t, cache.New().Apply(ctx))
require.NoError(t, logger.New().Apply(ctx))
p := admin.New()
assert.Equal(t, "admin", p.Name())
assert.Equal(t, "admin", p.Manifest().Name)
require.NoError(t, p.Apply(ctx))
// 1. Admin Routes
routes := ctx.Router().Routes()
var hasStatus, hasDBOverview, hasUsers, hasTasks, hasPushEvents bool
for _, r := range routes {
if r.Path == "/api/v1/admin/status" {
hasStatus = true
}
if r.Path == "/api/v1/admin/db-manage/overview" {
hasDBOverview = true
}
if r.Path == "/api/v1/admin/users" {
hasUsers = true
}
if r.Path == "/api/v1/admin/tasks/types" {
hasTasks = true
}
if r.Path == "/api/v1/admin/push/events" {
hasPushEvents = true
}
}
assert.True(t, hasStatus)
assert.True(t, hasDBOverview)
assert.True(t, hasUsers)
assert.True(t, hasTasks)
assert.True(t, hasPushEvents)
// 2. Task & Schedule
_, ok := ctx.Tasks().Get("admin:system_cleanup")
require.True(t, ok)
sched, ok := ctx.Schedules().Get("admin:system_cleanup")
require.True(t, ok)
assert.Equal(t, "0 4 * * *", sched.Spec)
// 3. Settings
schema, ok := ctx.Settings().Get("admin.system_cleanup_cron")
require.True(t, ok)
assert.Equal(t, "0 4 * * *", schema.Default)
}
func TestAllDomainPluginsCombined(t *testing.T) {
mr, err := miniredis.Run()
require.NoError(t, err)
defer mr.Close()
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
defer func() { _ = rdb.Close() }()
ctx := core.NewContext(context.Background())
testDB := setupTestDB(t)
// Apply Infra plugins
require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx))
require.NoError(t, cache.New(cache.WithRedis(rdb)).Apply(ctx))
require.NoError(t, logger.New().Apply(ctx))
require.NoError(t, storage.New().Apply(ctx))
// Apply Domain plugins
require.NoError(t, auth.New().Apply(ctx))
require.NoError(t, user.New().Apply(ctx))
require.NoError(t, message_gateway.New().Apply(ctx))
require.NoError(t, risk_control.New().Apply(ctx))
require.NoError(t, admin.New().Apply(ctx))
// Verify cross-plugin service injection via Using3
var resolved bool
err = core.Using3(ctx, func(authSvc contracts.AuthService, userSvc contracts.UserService, authReg contracts.AuthRegistry) {
resolved = true
assert.NotNil(t, authSvc)
assert.NotNil(t, userSvc)
assert.NotNil(t, authReg)
})
require.NoError(t, err)
assert.True(t, resolved)
// Verify all migration entries
allMigrations := ctx.Migrations().Entries()
assert.GreaterOrEqual(t, len(allMigrations), 3)
// Verify total routes registered
allRoutes := ctx.Router().Routes()
assert.GreaterOrEqual(t, len(allRoutes), 20)
// Verify total tasks registered
allTasks := ctx.Tasks().Tasks()
assert.GreaterOrEqual(t, len(allTasks), 4)
// Verify total schedules registered
allSchedules := ctx.Schedules().Schedules()
assert.GreaterOrEqual(t, len(allSchedules), 3)
// Verify total settings schemas registered
allSettings := ctx.Settings().Schemas()
assert.GreaterOrEqual(t, len(allSettings), 7)
// Clean shutdown
require.NoError(t, ctx.Dispose())
}
@@ -0,0 +1,44 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS w_message_channels (
id BIGINT PRIMARY KEY,
name VARCHAR(128) NOT NULL,
type VARCHAR(32) NOT NULL,
owner_scope VARCHAR(16) NOT NULL DEFAULT 'system',
owner_id BIGINT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
credentials TEXT NOT NULL DEFAULT '',
extra TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_message_channels_type ON w_message_channels (type);
CREATE TABLE IF NOT EXISTS w_message_bindings (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
channel_id BIGINT NOT NULL,
platform_user_id VARCHAR(128) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_w_message_bindings_channel_platform
ON w_message_bindings (channel_id, platform_user_id);
CREATE INDEX IF NOT EXISTS idx_w_message_bindings_user ON w_message_bindings (user_id);
CREATE TABLE IF NOT EXISTS w_message_pairing_codes (
code VARCHAR(16) PRIMARY KEY,
channel_id BIGINT NOT NULL,
platform_user_id VARCHAR(128) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_message_pairing_lookup
ON w_message_pairing_codes (channel_id, platform_user_id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS w_message_pairing_codes;
DROP TABLE IF EXISTS w_message_bindings;
DROP TABLE IF EXISTS w_message_channels;
-- +goose StatementEnd
+108
View File
@@ -0,0 +1,108 @@
// Package message_gateway provides the Bot gateway, multi-channel notification dispatching, and asynchronous push worker domain plugin for Cordis.
package message_gateway
import (
"context"
"embed"
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/core/extpoints"
appgw "github.com/Rain-kl/Wavelet/internal/apps/message_gateway"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/hibiken/asynq"
)
//go:embed migrations/*.sql
var mgMigrations embed.FS
// Option configures the message_gateway plugin.
type Option func(*Plugin)
// Plugin implements core.Plugin to provide Bot gateway and notification dispatch domain services.
type Plugin struct{}
// New creates a new message_gateway domain plugin.
func New(opts ...Option) *Plugin {
p := &Plugin{}
for _, opt := range opts {
if opt != nil {
opt(p)
}
}
return p
}
// Name returns the unique identifier for the message_gateway domain plugin.
func (p *Plugin) Name() string {
return "message_gateway"
}
// Manifest returns the plugin metadata.
func (p *Plugin) Manifest() core.Manifest {
return core.Manifest{
Name: "message_gateway",
Version: "1.0.0",
Description: "Bot gateway, multi-channel notification push, and async worker dispatch plugin",
Author: "Wavelet Team",
}
}
// PushNotificationEvent defines the payload for eventbus notification trigger.
type PushNotificationEvent struct {
UserID uint64 `json:"user_id"`
Channel string `json:"channel"`
Title string `json:"title"`
Content string `json:"content"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// Apply registers message_gateway migrations, routes, tasks, schedules, events, and settings into the Context.
func (p *Plugin) Apply(ctx *core.Context) error {
// 1. Register migrations
ctx.Migrations().Register("message_gateway", mgMigrations)
// 2. Register HTTP Routes
mgGroup := ctx.Router().Group("/api/v1/message-gateway", oauth.LoginRequired())
{
mgGroup.GET("/channels", appgw.ListChannels)
mgGroup.GET("/bindings", appgw.ListBindings)
mgGroup.POST("/bindings", appgw.BindBinding)
mgGroup.DELETE("/bindings/:id", appgw.UnbindBinding)
}
// 3. Register Asynq background tasks
ctx.Task().Register("message_gateway:push_notification", func(c context.Context, t *asynq.Task) error {
return nil
}, extpoints.WithTaskRetry(3))
ctx.Task().Register("message_gateway:dispatch_bot_msg", func(c context.Context, t *asynq.Task) error {
return nil
})
// 4. Register Cron Schedules
ctx.Schedule().RegisterCron("*/10 * * * *", "message_gateway:cleanup_pairing_codes", map[string]any{"action": "cleanup"})
// 5. Register EventBus listeners for decoupled push triggers
ctx.Events().On("notification:push", func(c context.Context, e PushNotificationEvent) error {
// Event triggered push handling
return nil
})
// 6. Register Settings Schemas
ctx.Settings().Register(extpoints.SettingSchema{
Key: "message_gateway.pairing_code_expiry_minutes",
Default: 15,
Description: "Expiry duration for bot pairing codes in minutes",
Type: "integer",
Category: "messaging",
})
ctx.Settings().Register(extpoints.SettingSchema{
Key: "message_gateway.max_bindings_per_user",
Default: 5,
Description: "Maximum platform bot bindings per user",
Type: "integer",
Category: "messaging",
})
return nil
}
@@ -0,0 +1,46 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway_test
import (
"context"
"io/fs"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/plugins/domain/message_gateway"
)
func TestMessageGatewayPluginUnit(t *testing.T) {
ctx := core.NewContext(context.Background())
p := message_gateway.New()
assert.Equal(t, "message_gateway", p.Name())
assert.Equal(t, "1.0.0", p.Manifest().Version)
require.NoError(t, p.Apply(ctx))
// Verify migrations
entry, ok := ctx.Migrations().Get("message_gateway")
require.True(t, ok)
entries, err := fs.ReadDir(entry.FS, entry.Dir)
require.NoError(t, err)
assert.NotEmpty(t, entries)
// Verify tasks
task, ok := ctx.Tasks().Get("message_gateway:push_notification")
require.True(t, ok)
assert.Equal(t, 3, task.Retry)
// Verify schedules
sched, ok := ctx.Schedules().Get("message_gateway:cleanup_pairing_codes")
require.True(t, ok)
assert.Equal(t, "*/10 * * * *", sched.Spec)
// Verify settings
setting, ok := ctx.Settings().Get("message_gateway.max_bindings_per_user")
require.True(t, ok)
assert.Equal(t, 5, setting.Default)
}
+88
View File
@@ -0,0 +1,88 @@
// Package risk_control provides the access control, IP rate limiting, and telemetry risk analysis domain plugin for Cordis.
package risk_control
import (
"context"
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/core/extpoints"
"github.com/Rain-kl/Wavelet/internal/apps/risk_control"
"github.com/gin-gonic/gin"
)
// Option configures the risk_control plugin.
type Option func(*Plugin)
// WithMiddleware configures a custom risk control middleware.
func WithMiddleware(mw gin.HandlerFunc) Option {
return func(p *Plugin) {
p.middleware = mw
}
}
// Plugin implements core.Plugin to provide risk control and access logging middleware.
type Plugin struct {
middleware gin.HandlerFunc
}
// New creates a new risk_control domain plugin.
func New(opts ...Option) *Plugin {
p := &Plugin{}
for _, opt := range opts {
if opt != nil {
opt(p)
}
}
return p
}
// Name returns the unique identifier for the risk_control domain plugin.
func (p *Plugin) Name() string {
return "risk_control"
}
// Manifest returns the plugin metadata.
func (p *Plugin) Manifest() core.Manifest {
return core.Manifest{
Name: "risk_control",
Version: "1.0.0",
Description: "Access control, IP rate limiting, and access log telemetry domain plugin",
Author: "Wavelet Team",
}
}
// Apply registers risk control middlewares, settings, and cleanup hooks into the Context.
func (p *Plugin) Apply(ctx *core.Context) error {
// 1. Initialize LogWriter if needed
risk_control.InitLogWriter(ctx.GoContext())
// 2. Register router middleware
mw := p.middleware
if mw == nil {
mw = risk_control.RiskControlMiddleware()
}
ctx.Router().Use(mw)
// 3. Register Settings Schemas
ctx.Settings().Register(extpoints.SettingSchema{
Key: "risk_control.ip_rate_limit_per_minute",
Default: 60,
Description: "Maximum requests allowed per IP per minute",
Type: "integer",
Category: "security",
})
ctx.Settings().Register(extpoints.SettingSchema{
Key: "risk_control.enable_access_log",
Default: true,
Description: "Enable structured access log auditing and backpressure queueing",
Type: "boolean",
Category: "security",
})
// 4. Register lifecycle disposal cleanup
ctx.OnDispose(func() error {
return risk_control.StopLogWriter(context.Background())
})
return nil
}
@@ -0,0 +1,42 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package risk_control_test
import (
"context"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/plugins/domain/risk_control"
)
func TestRiskControlPluginUnit(t *testing.T) {
ctx := core.NewContext(context.Background())
var customMWCalled bool
customMW := func(c *gin.Context) {
customMWCalled = true
c.Next()
}
p := risk_control.New(risk_control.WithMiddleware(customMW))
assert.Equal(t, "risk_control", p.Name())
assert.Equal(t, "1.0.0", p.Manifest().Version)
require.NoError(t, p.Apply(ctx))
// Verify middlewares registered
mws := ctx.Router().Middlewares()
assert.NotEmpty(t, mws)
// Verify settings
setting, ok := ctx.Settings().Get("risk_control.enable_access_log")
require.True(t, ok)
assert.Equal(t, true, setting.Default)
require.NoError(t, ctx.Dispose())
assert.False(t, customMWCalled) // not dispatched via gin engine here
}
@@ -0,0 +1,44 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS w_users (
id BIGINT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password VARCHAR(255),
nickname VARCHAR(255),
email VARCHAR(255),
avatar_url VARCHAR(255),
is_active BOOLEAN DEFAULT TRUE,
is_admin BOOLEAN DEFAULT FALSE,
bio VARCHAR(500),
phone VARCHAR(32),
gender VARCHAR(16),
website VARCHAR(255),
location VARCHAR(255),
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_users_email ON w_users (email);
CREATE INDEX IF NOT EXISTS idx_w_users_is_active ON w_users (is_active);
CREATE INDEX IF NOT EXISTS idx_w_users_last_login_at ON w_users (last_login_at);
CREATE INDEX IF NOT EXISTS idx_w_users_created_at ON w_users (created_at);
CREATE TABLE IF NOT EXISTS w_access_tokens (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
token_hash VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(128) NOT NULL,
description VARCHAR(255),
is_admin BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_access_tokens_user_id ON w_access_tokens (user_id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS w_access_tokens;
DROP TABLE IF EXISTS w_users;
-- +goose StatementEnd
+130
View File
@@ -0,0 +1,130 @@
// Package user provides the user profile, credential management, role management, and access token domain plugin for Cordis.
package user
import (
"context"
"embed"
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/core/contracts"
"github.com/Rain-kl/Wavelet/core/extpoints"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/apps/user"
"github.com/hibiken/asynq"
)
//go:embed migrations/*.sql
var userMigrations embed.FS
// Option configures the user plugin.
type Option func(*Plugin)
// WithUserService sets a custom UserService implementation.
func WithUserService(svc contracts.UserService) Option {
return func(p *Plugin) {
p.userSvc = svc
}
}
// Plugin implements core.Plugin to provide user account and credential domain services.
type Plugin struct {
userSvc contracts.UserService
}
// New creates a new user domain plugin.
func New(opts ...Option) *Plugin {
p := &Plugin{}
for _, opt := range opts {
if opt != nil {
opt(p)
}
}
return p
}
// Name returns the unique identifier for the user domain plugin.
func (p *Plugin) Name() string {
return "user"
}
// Manifest returns the plugin metadata.
func (p *Plugin) Manifest() core.Manifest {
return core.Manifest{
Name: "user",
Version: "1.0.0",
Description: "User profiles, credentials, role management, and access token domain plugin",
Author: "Wavelet Team",
}
}
// Apply registers user migrations, services, routes, tasks, schedules, and settings into the Context.
func (p *Plugin) Apply(ctx *core.Context) error {
// 1. Register migrations
ctx.Migrations().Register("user", userMigrations)
// 2. Initialize and provide UserService
if p.userSvc == nil {
p.userSvc = newUserService()
}
core.Provide[contracts.UserService](ctx, p.userSvc)
// 3. Register HTTP Routes
userGroup := ctx.Router().Group("/api/v1/user")
{
userGroup.POST("/login", user.Login)
userGroup.POST("/register", user.Register)
userGroup.GET("/logout", user.Logout)
userGroup.POST("/send-email-code", user.SendEmailCode)
userGroup.POST("/change-password", oauth.LoginRequired(), user.ChangePassword)
userGroup.PUT("/profile", oauth.LoginRequired(), user.UpdateProfile)
// Access Tokens
tokensGroup := userGroup.Group("/access-tokens", oauth.LoginRequired(), oauth.DisallowTokenAuth())
{
tokensGroup.GET("", user.ListAccessTokens)
tokensGroup.POST("", user.CreateAccessToken)
tokensGroup.DELETE("/:id", user.DeleteAccessToken)
tokensGroup.POST("/:id/rotate", user.RotateAccessToken)
}
}
// 4. Register Asynq background tasks
ctx.Task().Register("user:send_email_code", func(c context.Context, t *asynq.Task) error {
// Asynq background task handler
return nil
}, extpoints.WithTaskRetry(3))
ctx.Task().Register("user:cleanup_inactive", func(c context.Context, t *asynq.Task) error {
return nil
})
// 5. Register Cron Schedules
ctx.Schedule().RegisterCron("0 3 * * *", "user:daily_audit", map[string]string{"type": "audit"})
// 6. Register Settings Schemas
ctx.Settings().Register(extpoints.SettingSchema{
Key: "user.registration_enabled",
Default: true,
Description: "Whether new user registration is enabled",
Type: "boolean",
Category: "general",
Public: true,
})
ctx.Settings().Register(extpoints.SettingSchema{
Key: "user.password_login_enabled",
Default: true,
Description: "Whether password login is enabled",
Type: "boolean",
Category: "general",
Public: true,
})
ctx.Settings().Register(extpoints.SettingSchema{
Key: "user.min_password_length",
Default: 8,
Description: "Minimum password length required for user accounts",
Type: "integer",
Category: "security",
})
return nil
}
+107
View File
@@ -0,0 +1,107 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package user_test
import (
"context"
"path/filepath"
"testing"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/core/contracts"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/plugins/domain/user"
)
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "user_test.db")
testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, testDB.AutoMigrate(
&model.User{},
&model.AccessToken{},
))
db.SetDB(testDB)
return testDB
}
func TestUserPluginUnit(t *testing.T) {
ctx := core.NewContext(context.Background())
_ = setupTestDB(t)
p := user.New()
assert.Equal(t, "user", p.Name())
assert.Equal(t, "1.0.0", p.Manifest().Version)
require.NoError(t, p.Apply(ctx))
userSvc, err := core.Inject[contracts.UserService](ctx)
require.NoError(t, err)
require.NotNil(t, userSvc)
testCtx := context.Background()
// 1. Create User
u, err := userSvc.CreateUser(testCtx, contracts.CreateUserRequest{
Username: "charlie",
Password: "Password789!",
Email: "charlie@example.com",
})
require.NoError(t, err)
assert.Equal(t, "charlie", u.Username)
// 2. Empty username error
_, err = userSvc.CreateUser(testCtx, contracts.CreateUserRequest{})
assert.Error(t, err)
// 3. Verify Password
assert.True(t, userSvc.VerifyPassword(testCtx, u.ID, "Password789!"))
assert.False(t, userSvc.VerifyPassword(testCtx, u.ID, "Wrong"))
// 4. Update Password with wrong old password
err = userSvc.UpdatePassword(testCtx, u.ID, "WrongOld", "NewPass999!")
assert.Error(t, err)
// Update Password success
err = userSvc.UpdatePassword(testCtx, u.ID, "Password789!", "NewPass999!")
require.NoError(t, err)
assert.True(t, userSvc.VerifyPassword(testCtx, u.ID, "NewPass999!"))
// 5. Update Profile
nickname := "Charlie Brown"
email := "charlie.new@example.com"
gender := "male"
website := "https://charlie.me"
loc := "SF"
updated, err := userSvc.UpdateProfile(testCtx, u.ID, contracts.UpdateUserProfileRequest{
Nickname: &nickname,
Email: &email,
Gender: &gender,
Website: &website,
Location: &loc,
})
require.NoError(t, err)
assert.Equal(t, "Charlie Brown", updated.Nickname)
assert.Equal(t, "charlie.new@example.com", updated.Email)
assert.Equal(t, "male", updated.Gender)
assert.Equal(t, "https://charlie.me", updated.Website)
assert.Equal(t, "SF", updated.Location)
// 6. List and Status
require.NoError(t, userSvc.SetUserAdmin(testCtx, u.ID, true))
require.NoError(t, userSvc.SetUserActive(testCtx, u.ID, true))
list, total, err := userSvc.ListUsers(testCtx, 1, 10, "")
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(1))
assert.NotEmpty(t, list)
}
+210
View File
@@ -0,0 +1,210 @@
// Package user provides user profiles, credentials, role management, and access token domain services.
package user
import (
"context"
"errors"
"time"
"github.com/Rain-kl/Wavelet/core/contracts"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
pkgu "github.com/Rain-kl/Wavelet/pkg/util"
)
func toUserDTO(u *model.User) *contracts.UserDTO {
if u == nil {
return nil
}
return &contracts.UserDTO{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Email: u.Email,
AvatarURL: u.AvatarURL,
IsActive: u.IsActive,
IsAdmin: u.IsAdmin,
Bio: u.Bio,
Phone: u.Phone,
Gender: u.Gender,
Website: u.Website,
Location: u.Location,
LastLoginAt: u.LastLoginAt,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
type userServiceImpl struct{}
func newUserService() contracts.UserService {
return &userServiceImpl{}
}
func (s *userServiceImpl) GetUserByID(ctx context.Context, id uint64) (*contracts.UserDTO, error) {
u, err := repository.GetUserByID(ctx, id)
if err != nil {
return nil, err
}
return toUserDTO(&u), nil
}
func (s *userServiceImpl) GetUserByUsername(ctx context.Context, username string) (*contracts.UserDTO, error) {
u, err := repository.GetUserByUsername(ctx, username)
if err != nil {
return nil, err
}
return toUserDTO(&u), nil
}
func (s *userServiceImpl) GetUserByEmail(ctx context.Context, email string) (*contracts.UserDTO, error) {
var u model.User
if err := db.DB(ctx).Where("email = ?", email).First(&u).Error; err != nil {
return nil, err
}
return toUserDTO(&u), nil
}
func (s *userServiceImpl) CreateUser(ctx context.Context, req contracts.CreateUserRequest) (*contracts.UserDTO, error) {
if req.Username == "" {
return nil, errors.New("user: username cannot be empty")
}
user := model.User{
ID: idgen.NextUint64ID(),
Username: req.Username,
Nickname: req.Nickname,
Email: req.Email,
IsActive: true,
IsAdmin: req.IsAdmin,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
LastLoginAt: time.Now(),
}
if user.Nickname == "" {
user.Nickname = req.Username
}
if req.Password != "" {
if err := user.SetEncryptedPassword(req.Password); err != nil {
return nil, err
}
}
if err := repository.CreateUser(ctx, &user); err != nil {
return nil, err
}
return toUserDTO(&user), nil
}
func (s *userServiceImpl) UpdateProfile(ctx context.Context, id uint64, req contracts.UpdateUserProfileRequest) (*contracts.UserDTO, error) {
updates := make(map[string]any)
if req.Nickname != nil {
updates["nickname"] = *req.Nickname
}
if req.Email != nil {
updates["email"] = *req.Email
}
if req.AvatarURL != nil {
updates["avatar_url"] = *req.AvatarURL
}
if req.Bio != nil {
updates["bio"] = *req.Bio
}
if req.Phone != nil {
updates["phone"] = *req.Phone
}
if req.Gender != nil {
updates["gender"] = *req.Gender
}
if req.Website != nil {
updates["website"] = *req.Website
}
if req.Location != nil {
updates["location"] = *req.Location
}
updates["updated_at"] = time.Now()
if err := db.DB(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return nil, err
}
return s.GetUserByID(ctx, id)
}
func (s *userServiceImpl) UpdatePassword(ctx context.Context, id uint64, oldPassword, newPassword string) error {
var user model.User
if err := db.DB(ctx).Where("id = ?", id).First(&user).Error; err != nil {
return err
}
if !user.CheckPassword(oldPassword) {
return errors.New("user: incorrect old password")
}
if err := user.SetEncryptedPassword(newPassword); err != nil {
return err
}
return db.DB(ctx).Model(&model.User{}).Where("id = ?", id).
Updates(map[string]any{
"password": user.Password,
"updated_at": time.Now(),
}).Error
}
func (s *userServiceImpl) VerifyPassword(ctx context.Context, id uint64, password string) bool {
var user model.User
if err := db.DB(ctx).Where("id = ?", id).First(&user).Error; err != nil {
pkgu.DummyCheckPassword(password)
return false
}
return user.CheckPassword(password)
}
func (s *userServiceImpl) UpdateLastLogin(ctx context.Context, id uint64, ip string) error {
return db.DB(ctx).Model(&model.User{}).Where("id = ?", id).
Updates(map[string]any{
"last_login_at": time.Now(),
"updated_at": time.Now(),
}).Error
}
func (s *userServiceImpl) ListUsers(ctx context.Context, page, pageSize int, keyword string) ([]*contracts.UserDTO, int64, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
filter := repository.AdminUserListFilter{
Username: keyword,
Page: page,
PageSize: pageSize,
}
total, users, err := repository.ListAdminUsers(ctx, filter)
if err != nil {
return nil, 0, err
}
dtos := make([]*contracts.UserDTO, 0, len(users))
for i := range users {
dtos = append(dtos, toUserDTO(&users[i]))
}
return dtos, total, nil
}
func (s *userServiceImpl) SetUserActive(ctx context.Context, id uint64, active bool) error {
return repository.UpdateUserActive(ctx, id, active)
}
func (s *userServiceImpl) SetUserAdmin(ctx context.Context, id uint64, admin bool) error {
return db.DB(ctx).Model(&model.User{}).Where("id = ?", id).Update("is_admin", admin).Error
}