diff --git a/.env.example b/.env.example index cab8579..35c94c1 100644 --- a/.env.example +++ b/.env.example @@ -72,3 +72,23 @@ S3_SECRET_ACCESS_KEY= S3_ENDPOINT= S3_FORCE_PATH_STYLE=false S3_PRESIGN_TTL=15m + +# Discord Account Linking (OAuth2 on the backend; invite-bot runs as a separate server) +DISCORD_ENABLED=false +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +# redirect_uri must be registered verbatim in the Discord Developer Portal (OAuth2 > Redirects). +DISCORD_REDIRECT_URI=http://localhost:8080/api/discord/callback +DISCORD_OAUTH_SCOPES=identify guilds.join +DISCORD_STATE_TTL=5m +DISCORD_OAUTH_TIMEOUT=10s +# Frontend page to return to after the OAuth callback. +DISCORD_SUCCESS_REDIRECT=https://smctf.example.com/profile +# Guild invite shown to users not yet in the guild (fallback button). +DISCORD_INVITE_URL= +# Auto-join the guild via guilds.join scope after linking. +DISCORD_AUTO_JOIN=true +# Connection to the separate invite-bot server (DISCORD_BOT_SECRET must equal the bot's DISCORD_INTERNAL_SECRET). +DISCORD_BOT_BASE_URL=http://localhost:8083 +DISCORD_BOT_SECRET=change-me +DISCORD_BOT_TIMEOUT=5s diff --git a/cmd/server/main.go b/cmd/server/main.go index d32d713..9f4696f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,6 +13,7 @@ import ( "smctf/internal/cache" "smctf/internal/config" "smctf/internal/db" + "smctf/internal/discord" httpserver "smctf/internal/http" "smctf/internal/logging" "smctf/internal/realtime" @@ -85,6 +86,7 @@ func main() { scoreRepo := repo.NewScoreboardRepo(database) appConfigRepo := repo.NewAppConfigRepo(database) vmRepo := repo.NewVMRepo(database) + discordRepo := repo.NewDiscordRepo(database) var fileStore storage.ChallengeFileStore if cfg.S3.Enabled { @@ -107,6 +109,18 @@ func main() { vmClient := vm.NewClient(cfg.VM.OrchestratorBaseURL, cfg.VM.OrchestratorSecret, cfg.VM.OrchestratorTimeout) vmSvc := service.NewVMService(cfg.VM, vmRepo, challengeRepo, submissionRepo, vmClient, redisClient) + var discordSvc *service.DiscordService + if cfg.Discord.Enabled { + discordBotClient := discord.NewBotClient(cfg.Discord.BotBaseURL, cfg.Discord.BotSecret, cfg.Discord.BotTimeout) + discordOAuthClient := discord.NewOAuthClient(discord.OAuthConfig{ + ClientID: cfg.Discord.ClientID, + ClientSecret: cfg.Discord.ClientSecret, + RedirectURI: cfg.Discord.RedirectURI, + Scopes: cfg.Discord.Scopes, + }, cfg.Discord.OAuthTimeout) + discordSvc = service.NewDiscordService(cfg.Discord, discordRepo, discordBotClient, discordOAuthClient, redisClient) + } + bootstrap.BootstrapAdmin(ctx, cfg, database, userRepo, teamRepo, divisionRepo, logger) if cfg, _, _, err := appConfigSvc.Get(ctx); err != nil { @@ -122,7 +136,7 @@ func main() { leaderboardBus := realtime.NewScoreboardBus(redisClient, cfg, scoreSvc, divisionSvc, logger, sseHub) leaderboardBus.Start(ctx) - router := httpserver.NewRouter(cfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, vmSvc, redisClient, logger, sseHub) + router := httpserver.NewRouter(cfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, vmSvc, discordSvc, redisClient, logger, sseHub) srv := &nethttp.Server{ Addr: cfg.HTTPAddr, Handler: router, diff --git a/codecov.yaml b/codecov.yaml index 479bf06..44e17a2 100644 --- a/codecov.yaml +++ b/codecov.yaml @@ -14,6 +14,7 @@ comment: require_changes: false ignore: + - "invite-bot/**" # standalone invite bot server (TypeScript), not part of Go coverage - "**/*_test.go" - "**/mock/**" - "cmd/**" diff --git a/docs/docs/discord.md b/docs/docs/discord.md new file mode 100644 index 0000000..38b9613 --- /dev/null +++ b/docs/docs/discord.md @@ -0,0 +1,224 @@ +--- +title: Discord +nav_order: 12 +--- + +Notes: + +- Discord account linking uses the OAuth2 authorization-code flow on the backend (`identify`, optionally `guilds.join`). +- Guild membership and role changes are performed by a separate **invite-bot** server over an internal HTTP API; the backend never holds the Discord bot token. The bot token, guild id, and verified-role id live on the bot server only. +- Access/refresh tokens are **not** stored. Role granting uses the bot token on the bot server, not the user's OAuth token. +- The acting user is identified by the JWT `access_token` cookie. `connect`, `callback`, and `status` require login; `sync-role` and `unlink` additionally require an unblocked (active) user. +- For authenticated `POST`, `PUT`, `PATCH`, and `DELETE` requests, send both `csrf_token` cookie and matching `X-CSRF-Token` header. +- All endpoints return `503 discord feature disabled` when `DISCORD_ENABLED=false`. + +## Discord Status Schema + +`discordStatusResponse` fields (omitted when empty): + +- `connected`: whether the current user has a linked Discord account. +- `discord_user_id`: linked Discord user id (snowflake). +- `discord_username`: Discord unique handle. +- `discord_global_name`: Discord display name. +- `discord_avatar`: Discord avatar hash. +- `role_status`: one of `CONNECTED`, `VERIFIED`, `NOT_IN_GUILD`, `ROLE_FAILED`, `REVOKED`, `LEFT_GUILD`. +- `connected_at`, `verified_at`: timestamps (UTC). +- `invite_url`: guild invite link to show users who are not yet in the guild (from `DISCORD_INVITE_URL`). + +## Start Linking + +`GET /api/discord/connect` + +Headers + +``` +Cookie: access_token= +``` + +Response 302 — redirect to the Discord authorize URL. The backend generates a single-use `state` (random, TTL `DISCORD_STATE_TTL`) bound to the current user and stores it in Redis. + +``` +Location: https://discord.com/oauth2/authorize?response_type=code&client_id=...&scope=identify+guilds.join&state=...&redirect_uri=...&prompt=consent +``` + +Errors: + +- 401 `invalid token` or `missing access_token cookie` +- 503 `discord feature disabled` +- 500 internal error + +--- + +## OAuth Callback + +`GET /api/discord/callback?code=&state=` + +Headers + +``` +Cookie: access_token= +``` + +Response 302 — redirect to `DISCORD_SUCCESS_REDIRECT` with a `?discord=` query. If `DISCORD_SUCCESS_REDIRECT` is empty, returns `200 {"discord":""}` instead. + +`` values: + +- `verified`: account linked, joined the guild, verified role granted. +- `connected_not_joined`: linked, but not a guild member (`NOT_IN_GUILD` / `LEFT_GUILD`). +- `role_failed`: linked, but the role could not be granted (bot permission / role hierarchy). +- `already_linked`: that Discord account is already linked to another user. +- `state_invalid`: `state` was missing, expired, forged, or did not match the user. +- `error`: token exchange / profile fetch failed, or another unexpected error. + +``` +Location: https://smctf.example.com/profile?discord=verified +``` + +Callback behavior: + +- `state` is validated with `GETDEL` and must match the user that started `connect` (CSRF protection); invalid `state` redirects with `discord=state_invalid`. +- The code is exchanged for an access token, then `/users/@me` is read to capture `discord_user_id`, `username`, `global_name`, `avatar`. +- The connection is upserted. `discord_user_id` is unique — reusing one already linked elsewhere redirects with `discord=already_linked`. +- When `DISCORD_AUTO_JOIN=true`, the backend asks the invite-bot to add the user to the guild (`guilds.join`) using the access token, which is forwarded once and never stored. +- The verified role is then requested through the invite-bot; `role_status` is persisted as `VERIFIED`, `NOT_IN_GUILD`, or `ROLE_FAILED`. + +Errors: + +- 401 `invalid token` or `missing access_token cookie` +- 503 `discord feature disabled` + +(Other failures are surfaced as the `?discord=` redirect query above, not as HTTP error codes.) + +--- + +## Get Link Status + +`GET /api/discord/status` + +Headers + +``` +Cookie: access_token= +``` + +Response 200 — linked + +```json +{ + "connected": true, + "discord_user_id": "900000000000000001", + "discord_username": "neo", + "discord_global_name": "Neo", + "discord_avatar": "a1b2c3d4e5f6", + "role_status": "VERIFIED", + "connected_at": "2026-06-23T11:00:00Z", + "verified_at": "2026-06-23T11:00:02Z", + "invite_url": "https://discord.gg/example" +} +``` + +Response 200 — not linked + +```json +{ + "connected": false, + "invite_url": "https://discord.gg/example" +} +``` + +Errors: + +- 401 `invalid token` or `missing access_token cookie` +- 503 `discord feature disabled` +- 500 internal error + +--- + +## Re-Check Role + +`POST /api/discord/sync-role` + +Headers + +``` +Cookie: access_token= +X-CSRF-Token: +``` + +Re-attempts guild join / verified-role grant for an already-linked user (used after the user joins the guild). Returns the updated `discordStatusResponse`. + +Response 200 — same schema as `GET /api/discord/status` (linked). + +Errors: + +- 401 `invalid token` or `missing access_token cookie` +- 403 `user blocked` +- 404 `discord account not connected` +- 429 `discord rate limited` +- 503 `discord feature disabled` or `discord bot server unavailable` +- 500 internal error + +--- + +## Unlink + +`DELETE /api/discord/unlink` + +Headers + +``` +Cookie: access_token= +X-CSRF-Token: +``` + +Response 200 + +```json +{ + "status": "ok" +} +``` + +Unlink behavior: + +- The invite-bot is asked to kick the user from the guild. This is best-effort: any error (bot down, missing permission, already gone) is logged and ignored. +- The connection row is then deleted. + +Errors: + +- 401 `invalid token` or `missing access_token cookie` +- 403 `user blocked` +- 404 `discord account not connected` +- 503 `discord feature disabled` +- 500 internal error + +--- + +## Configuration (ENV) + +Discord service options (backend): + +- `DISCORD_ENABLED` (default: `false`) +- `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` +- `DISCORD_REDIRECT_URI` — must match a redirect registered in the Discord Developer Portal exactly. +- `DISCORD_OAUTH_SCOPES` (default: `identify guilds.join`) +- `DISCORD_STATE_TTL` (default: `5m`) +- `DISCORD_OAUTH_TIMEOUT` (default: `10s`) +- `DISCORD_SUCCESS_REDIRECT` — frontend page to return to after the callback. +- `DISCORD_INVITE_URL` — guild invite shown to users not yet in the guild. +- `DISCORD_AUTO_JOIN` (default: `true`) +- `DISCORD_BOT_BASE_URL` (default: `http://localhost:8083`) +- `DISCORD_BOT_SECRET` — shared bearer secret for the invite-bot internal API (must equal the bot's `DISCORD_INTERNAL_SECRET`). +- `DISCORD_BOT_TIMEOUT` (default: `5s`) + +Validation rules when `DISCORD_ENABLED=true`: + +- `DISCORD_CLIENT_ID` and `DISCORD_CLIENT_SECRET` must be set +- `DISCORD_REDIRECT_URI` must not be empty +- `DISCORD_OAUTH_SCOPES` must not be empty +- `DISCORD_BOT_BASE_URL` must not be empty +- `DISCORD_STATE_TTL > 0` +- `DISCORD_BOT_TIMEOUT > 0` +- `DISCORD_OAUTH_TIMEOUT > 0` + +The Discord bot token, guild id, and verified-role id are configured on the invite-bot server, not here. diff --git a/internal/config/config.go b/internal/config/config.go index 2651f5f..42f94f8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,7 @@ type Config struct { Logging LoggingConfig S3 S3Config VM VMConfig + Discord DiscordConfig Bootstrap BootstrapConfig } @@ -101,6 +102,23 @@ type VMConfig struct { CreateMax int } +type DiscordConfig struct { + Enabled bool + ClientID string + ClientSecret string + RedirectURI string + Scopes string + StateTTL time.Duration + SuccessRedirect string + InviteURL string + AutoJoin bool + + BotBaseURL string + BotSecret string + BotTimeout time.Duration + OAuthTimeout time.Duration +} + type BootstrapConfig struct { AdminTeamEnabled bool AdminUserEnabled bool @@ -261,6 +279,31 @@ func Load() (Config, error) { errs = append(errs, err) } + discordEnabled, err := getEnvBool("DISCORD_ENABLED", false) + if err != nil { + errs = append(errs, err) + } + + discordAutoJoin, err := getEnvBool("DISCORD_AUTO_JOIN", true) + if err != nil { + errs = append(errs, err) + } + + discordStateTTL, err := getDuration("DISCORD_STATE_TTL", 5*time.Minute) + if err != nil { + errs = append(errs, err) + } + + discordBotTimeout, err := getDuration("DISCORD_BOT_TIMEOUT", 5*time.Second) + if err != nil { + errs = append(errs, err) + } + + discordOAuthTimeout, err := getDuration("DISCORD_OAUTH_TIMEOUT", 10*time.Second) + if err != nil { + errs = append(errs, err) + } + cfg := Config{ AppEnv: appEnv, HTTPAddr: httpAddr, @@ -328,6 +371,21 @@ func Load() (Config, error) { CreateWindow: vmCreateWindow, CreateMax: vmCreateMax, }, + Discord: DiscordConfig{ + Enabled: discordEnabled, + ClientID: getEnv("DISCORD_CLIENT_ID", ""), + ClientSecret: getEnv("DISCORD_CLIENT_SECRET", ""), + RedirectURI: getEnv("DISCORD_REDIRECT_URI", ""), + Scopes: getEnv("DISCORD_OAUTH_SCOPES", "identify guilds.join"), + StateTTL: discordStateTTL, + SuccessRedirect: getEnv("DISCORD_SUCCESS_REDIRECT", ""), + InviteURL: getEnv("DISCORD_INVITE_URL", ""), + AutoJoin: discordAutoJoin, + BotBaseURL: getEnv("DISCORD_BOT_BASE_URL", "http://localhost:8083"), + BotSecret: getEnv("DISCORD_BOT_SECRET", ""), + BotTimeout: discordBotTimeout, + OAuthTimeout: discordOAuthTimeout, + }, Bootstrap: BootstrapConfig{ AdminTeamEnabled: bootstrapAdminTeamEnabled, AdminUserEnabled: bootstrapAdminUserEnabled, @@ -502,6 +560,33 @@ func validateConfig(cfg Config) error { } } + if cfg.Discord.Enabled { + if cfg.Discord.ClientID == "" || cfg.Discord.ClientSecret == "" { + errs = append(errs, errors.New("DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET must be set when DISCORD_ENABLED=true")) + } + if cfg.Discord.RedirectURI == "" { + errs = append(errs, errors.New("DISCORD_REDIRECT_URI must not be empty when DISCORD_ENABLED=true")) + } + if cfg.Discord.Scopes == "" { + errs = append(errs, errors.New("DISCORD_OAUTH_SCOPES must not be empty when DISCORD_ENABLED=true")) + } + if cfg.Discord.BotBaseURL == "" { + errs = append(errs, errors.New("DISCORD_BOT_BASE_URL must not be empty when DISCORD_ENABLED=true")) + } + if cfg.Discord.BotSecret == "" { + errs = append(errs, errors.New("DISCORD_BOT_SECRET must not be empty when DISCORD_ENABLED=true")) + } + if cfg.Discord.StateTTL <= 0 { + errs = append(errs, errors.New("DISCORD_STATE_TTL must be positive")) + } + if cfg.Discord.BotTimeout <= 0 { + errs = append(errs, errors.New("DISCORD_BOT_TIMEOUT must be positive")) + } + if cfg.Discord.OAuthTimeout <= 0 { + errs = append(errs, errors.New("DISCORD_OAUTH_TIMEOUT must be positive")) + } + } + if len(errs) == 0 { return nil } @@ -518,6 +603,8 @@ func Redact(cfg Config) Config { cfg.Bootstrap.AdminEmail = redact(cfg.Bootstrap.AdminEmail) cfg.Bootstrap.AdminPassword = redact(cfg.Bootstrap.AdminPassword) cfg.VM.OrchestratorSecret = redact(cfg.VM.OrchestratorSecret) + cfg.Discord.ClientSecret = redact(cfg.Discord.ClientSecret) + cfg.Discord.BotSecret = redact(cfg.Discord.BotSecret) return cfg } @@ -622,6 +709,21 @@ func FormatForLog(cfg Config) map[string]any { "create_window": seconds(cfg.VM.CreateWindow), "create_max": cfg.VM.CreateMax, }, + "discord": map[string]any{ + "enabled": cfg.Discord.Enabled, + "client_id": cfg.Discord.ClientID, + "client_secret": cfg.Discord.ClientSecret, + "redirect_uri": cfg.Discord.RedirectURI, + "scopes": cfg.Discord.Scopes, + "state_ttl": seconds(cfg.Discord.StateTTL), + "success_redirect": cfg.Discord.SuccessRedirect, + "invite_url": cfg.Discord.InviteURL, + "auto_join": cfg.Discord.AutoJoin, + "bot_base_url": cfg.Discord.BotBaseURL, + "bot_secret": cfg.Discord.BotSecret, + "bot_timeout": seconds(cfg.Discord.BotTimeout), + "oauth_timeout": seconds(cfg.Discord.OAuthTimeout), + }, "bootstrap": map[string]any{ "admin_team_enabled": cfg.Bootstrap.AdminTeamEnabled, "admin_user_enabled": cfg.Bootstrap.AdminUserEnabled, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c5587ae..af17428 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -860,3 +860,126 @@ func TestFormatForLog(t *testing.T) { t.Fatalf("expected cache fields") } } + +func discordEnabledEnv() { + os.Setenv("DISCORD_ENABLED", "true") + os.Setenv("DISCORD_CLIENT_ID", "cid") + os.Setenv("DISCORD_CLIENT_SECRET", "csecret") + os.Setenv("DISCORD_REDIRECT_URI", "http://localhost:8080/api/discord/callback") + os.Setenv("DISCORD_OAUTH_SCOPES", "identify") + os.Setenv("DISCORD_BOT_BASE_URL", "http://localhost:8083") + os.Setenv("DISCORD_BOT_SECRET", "bot-secret") +} + +func TestLoadConfigDiscordEnabled(t *testing.T) { + os.Clearenv() + discordEnabledEnv() + defer os.Clearenv() + + cfg, err := Load() + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if !cfg.Discord.Enabled { + t.Fatal("expected Discord enabled") + } + if cfg.Discord.ClientID != "cid" || cfg.Discord.BotSecret != "bot-secret" { + t.Errorf("unexpected discord config: %+v", cfg.Discord) + } + if !cfg.Discord.AutoJoin { + t.Error("expected AutoJoin true by default") + } + if cfg.Discord.StateTTL != 5*time.Minute { + t.Errorf("expected StateTTL 5m, got %v", cfg.Discord.StateTTL) + } + + red := Redact(cfg) + if red.Discord.ClientSecret == "csecret" || red.Discord.BotSecret == "bot-secret" { + t.Error("expected discord secrets to be redacted") + } + + if _, ok := FormatForLog(cfg)["discord"]; !ok { + t.Error("expected discord section in log map") + } +} + +func TestLoadConfigDiscordValidationErrors(t *testing.T) { + tests := []struct { + name string + key string + val string + }{ + {"missing client id", "DISCORD_CLIENT_ID", ""}, + {"missing client secret", "DISCORD_CLIENT_SECRET", ""}, + {"missing redirect uri", "DISCORD_REDIRECT_URI", ""}, + {"missing bot secret", "DISCORD_BOT_SECRET", ""}, + {"invalid state ttl", "DISCORD_STATE_TTL", "0s"}, + {"invalid bot timeout", "DISCORD_BOT_TIMEOUT", "0s"}, + {"invalid oauth timeout", "DISCORD_OAUTH_TIMEOUT", "0s"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Clearenv() + discordEnabledEnv() + os.Setenv(tt.key, tt.val) + defer os.Clearenv() + + if _, err := Load(); err == nil { + t.Fatalf("expected error for %s", tt.name) + } + }) + } +} + +func TestValidateConfigInvalidDiscord(t *testing.T) { + cfg := Config{ + HTTPAddr: ":8080", + BcryptCost: bcrypt.DefaultCost, + DB: DBConfig{ + Host: "localhost", + Port: 5432, + User: "user", + Name: "db", + MaxOpenConns: 10, + MaxIdleConns: 5, + ConnMaxLifetime: time.Minute, + }, + Redis: RedisConfig{ + Addr: "localhost:6379", + PoolSize: 10, + }, + JWT: JWTConfig{ + Secret: "secret", + Issuer: "issuer", + AccessTTL: time.Hour, + RefreshTTL: 24 * time.Hour, + }, + Security: SecurityConfig{ + SubmissionWindow: time.Minute, + SubmissionMax: 10, + }, + Logging: LoggingConfig{ + Dir: "logs", + FilePrefix: "app", + MaxBodyBytes: 1024, + }, + Discord: DiscordConfig{ + Enabled: true, + ClientID: "cid", + ClientSecret: "csecret", + RedirectURI: "http://localhost/cb", + Scopes: "", + BotBaseURL: "", + BotSecret: "bot-secret", + StateTTL: time.Minute, + BotTimeout: time.Second, + OAuthTimeout: time.Second, + }, + } + + if err := validateConfig(cfg); err == nil { + t.Fatal("expected discord validation errors") + } +} diff --git a/internal/db/db.go b/internal/db/db.go index c170ea4..65c019c 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -46,6 +46,7 @@ func AutoMigrate(ctx context.Context, db *bun.DB) error { (*models.Submission)(nil), (*models.RegistrationKey)(nil), (*models.RegistrationKeyUse)(nil), + (*models.DiscordConnection)(nil), } if err := createTables(ctx, db, modelsToCreate); err != nil { @@ -70,6 +71,14 @@ func createIndexes(ctx context.Context, db *bun.DB) error { name string query string }{ + { + name: "idx_discord_connections_user", + query: "CREATE UNIQUE INDEX IF NOT EXISTS idx_discord_connections_user ON discord_connections (user_id)", + }, + { + name: "idx_discord_connections_discord_user", + query: "CREATE UNIQUE INDEX IF NOT EXISTS idx_discord_connections_discord_user ON discord_connections (discord_user_id)", + }, { name: "idx_submissions_user", query: "CREATE INDEX IF NOT EXISTS idx_submissions_user ON submissions (user_id)", diff --git a/internal/discord/client.go b/internal/discord/client.go new file mode 100644 index 0000000..56b7932 --- /dev/null +++ b/internal/discord/client.go @@ -0,0 +1,199 @@ +package discord + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +var ( + ErrNotInGuild = fmt.Errorf("discord user not in guild") + ErrBotPermission = fmt.Errorf("discord bot permission denied") + ErrRateLimited = fmt.Errorf("discord rate limited") + ErrInvalid = fmt.Errorf("discord request invalid") + ErrUnavailable = fmt.Errorf("discord bot server unavailable") + ErrUnexpected = fmt.Errorf("discord bot server error") +) + +type StatusError struct { + StatusCode int + Code string + Message string +} + +func (e *StatusError) Error() string { + if strings.TrimSpace(e.Message) != "" { + return e.Message + } + + return fmt.Sprintf("discord bot server returned status %d", e.StatusCode) +} + +func (e *StatusError) Unwrap() error { + switch e.Code { + case "NOT_IN_GUILD": + return ErrNotInGuild + case "BOT_PERMISSION": + return ErrBotPermission + case "RATE_LIMITED": + return ErrRateLimited + } + + switch e.StatusCode { + case http.StatusNotFound: + return ErrNotInGuild + case http.StatusForbidden: + return ErrBotPermission + case http.StatusTooManyRequests: + return ErrRateLimited + case http.StatusBadRequest: + return ErrInvalid + case http.StatusServiceUnavailable, http.StatusBadGateway, http.StatusGatewayTimeout: + return ErrUnavailable + default: + return ErrUnexpected + } +} + +type Member struct { + InGuild bool `json:"in_guild"` + HasRole bool `json:"has_role"` +} + +type BotAPI interface { + JoinGuild(ctx context.Context, discordUserID, accessToken string) error + GrantRole(ctx context.Context, discordUserID string) error + KickMember(ctx context.Context, discordUserID string) error + GetMember(ctx context.Context, discordUserID string) (*Member, error) +} + +type BotClient struct { + baseURL string + secret string + httpClient *http.Client +} + +func NewBotClient(baseURL, secret string, timeout time.Duration) *BotClient { + return &BotClient{ + baseURL: strings.TrimRight(baseURL, "/"), + secret: secret, + httpClient: &http.Client{ + Timeout: timeout, + }, + } +} + +type joinRequest struct { + AccessToken string `json:"access_token"` +} + +func (c *BotClient) JoinGuild(ctx context.Context, discordUserID, accessToken string) error { + return c.doJSON(ctx, http.MethodPut, "/internal/guild/members/"+url.PathEscape(discordUserID), joinRequest{AccessToken: accessToken}, nil) +} + +func (c *BotClient) GrantRole(ctx context.Context, discordUserID string) error { + return c.doJSON(ctx, http.MethodPut, "/internal/guild/members/"+url.PathEscape(discordUserID)+"/role", nil, nil) +} + +func (c *BotClient) KickMember(ctx context.Context, discordUserID string) error { + return c.doJSON(ctx, http.MethodDelete, "/internal/guild/members/"+url.PathEscape(discordUserID), nil, nil) +} + +func (c *BotClient) GetMember(ctx context.Context, discordUserID string) (*Member, error) { + var member Member + if err := c.doJSON(ctx, http.MethodGet, "/internal/guild/members/"+url.PathEscape(discordUserID), nil, &member); err != nil { + return nil, err + } + + return &member, nil +} + +func (c *BotClient) doJSON(ctx context.Context, method, path string, body any, out any) error { + reader, err := encodeBody(body) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) + if err != nil { + return fmt.Errorf("discord bot client request: %w", err) + } + + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + if c.secret != "" { + req.Header.Set("Authorization", "Bearer "+c.secret) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer resp.Body.Close() + + if err := handleStatus(resp); err != nil { + return err + } + + if out == nil { + return nil + } + + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("discord bot client decode: %w", err) + } + + return nil +} + +func encodeBody(body any) (io.Reader, error) { + if body == nil { + return nil, nil + } + + payload, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("discord bot client marshal: %w", err) + } + + return bytes.NewReader(payload), nil +} + +type errorBody struct { + Error string `json:"error"` + Code string `json:"code"` +} + +func handleStatus(resp *http.Response) error { + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + statusErr := &StatusError{StatusCode: resp.StatusCode, Message: strings.TrimSpace(resp.Status)} + body, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if err == nil && len(body) > 0 { + var parsed errorBody + if jsonErr := json.Unmarshal(body, &parsed); jsonErr == nil { + if strings.TrimSpace(parsed.Code) != "" { + statusErr.Code = parsed.Code + } + + if strings.TrimSpace(parsed.Error) != "" { + statusErr.Message = strings.TrimSpace(parsed.Error) + } + } else if text := strings.TrimSpace(string(body)); text != "" { + statusErr.Message = text + } + } + + return statusErr +} diff --git a/internal/discord/client_test.go b/internal/discord/client_test.go new file mode 100644 index 0000000..3364dec --- /dev/null +++ b/internal/discord/client_test.go @@ -0,0 +1,139 @@ +package discord + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestBotClientGrantRoleSuccess(t *testing.T) { + var gotPath, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + client := NewBotClient(srv.URL, "secret", time.Second) + if err := client.GrantRole(context.Background(), "123"); err != nil { + t.Fatalf("GrantRole: %v", err) + } + + if gotPath != "/internal/guild/members/123/role" { + t.Errorf("path = %q", gotPath) + } + + if gotAuth != "Bearer secret" { + t.Errorf("auth = %q", gotAuth) + } +} + +func TestBotClientKickMember(t *testing.T) { + var gotMethod, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + client := NewBotClient(srv.URL, "secret", time.Second) + if err := client.KickMember(context.Background(), "123"); err != nil { + t.Fatalf("KickMember: %v", err) + } + + if gotMethod != http.MethodDelete { + t.Errorf("method = %q", gotMethod) + } + + if gotPath != "/internal/guild/members/123" { + t.Errorf("path = %q", gotPath) + } +} + +func TestBotClientGrantRoleMapsCodes(t *testing.T) { + tests := []struct { + name string + status int + code string + wantErr error + }{ + {"not in guild via code", http.StatusNotFound, "NOT_IN_GUILD", ErrNotInGuild}, + {"permission via code", http.StatusForbidden, "BOT_PERMISSION", ErrBotPermission}, + {"rate limited via code", http.StatusTooManyRequests, "RATE_LIMITED", ErrRateLimited}, + {"not in guild via status", http.StatusNotFound, "", ErrNotInGuild}, + {"unavailable", http.StatusServiceUnavailable, "", ErrUnavailable}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + if tc.code != "" { + _, _ = w.Write([]byte(`{"code":"` + tc.code + `","error":"boom"}`)) + } + })) + defer srv.Close() + + client := NewBotClient(srv.URL, "", time.Second) + err := client.GrantRole(context.Background(), "123") + if !errors.Is(err, tc.wantErr) { + t.Fatalf("got %v, want %v", err, tc.wantErr) + } + }) + } +} + +func TestBotClientJoinGuildSendsAccessToken(t *testing.T) { + var body joinRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("method = %s", r.Method) + } + + _ = json.NewDecoder(r.Body).Decode(&body) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + client := NewBotClient(srv.URL, "", time.Second) + if err := client.JoinGuild(context.Background(), "u1", "tok-abc"); err != nil { + t.Fatalf("JoinGuild: %v", err) + } + + if body.AccessToken != "tok-abc" { + t.Errorf("access token = %q", body.AccessToken) + } +} + +func TestBotClientGetMember(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"in_guild":true,"has_role":false}`)) + })) + defer srv.Close() + + client := NewBotClient(srv.URL, "", time.Second) + member, err := client.GetMember(context.Background(), "u1") + if err != nil { + t.Fatalf("GetMember: %v", err) + } + + if !member.InGuild || member.HasRole { + t.Errorf("member = %+v", member) + } +} + +func TestBotClientUnavailableOnDialError(t *testing.T) { + client := NewBotClient("http://127.0.0.1:0", "", 200*time.Millisecond) + err := client.GrantRole(context.Background(), "1") + if !errors.Is(err, ErrUnavailable) { + t.Fatalf("got %v, want ErrUnavailable", err) + } +} diff --git a/internal/discord/oauth.go b/internal/discord/oauth.go new file mode 100644 index 0000000..b8666c9 --- /dev/null +++ b/internal/discord/oauth.go @@ -0,0 +1,145 @@ +package discord + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + discordAPIBase = "https://discord.com/api/v10" + authorizeBaseURL = "https://discord.com/oauth2/authorize" + tokenURL = discordAPIBase + "/oauth2/token" + currentUserURL = discordAPIBase + "/users/@me" +) + +type OAuthConfig struct { + ClientID string + ClientSecret string + RedirectURI string + Scopes string +} + +type TokenResult struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` +} + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + GlobalName *string `json:"global_name"` + Avatar *string `json:"avatar"` +} + +type OAuthClient struct { + cfg OAuthConfig + httpClient *http.Client +} + +func NewOAuthClient(cfg OAuthConfig, timeout time.Duration) *OAuthClient { + return &OAuthClient{ + cfg: cfg, + httpClient: &http.Client{Timeout: timeout}, + } +} + +func (c *OAuthClient) AuthorizeURL(state string) string { + params := url.Values{ + "response_type": {"code"}, + "client_id": {c.cfg.ClientID}, + "scope": {c.cfg.Scopes}, + "state": {state}, + "redirect_uri": {c.cfg.RedirectURI}, + "prompt": {"consent"}, + } + + return authorizeBaseURL + "?" + params.Encode() +} + +func (c *OAuthClient) ExchangeCode(ctx context.Context, code string) (*TokenResult, error) { + form := url.Values{ + "client_id": {c.cfg.ClientID}, + "client_secret": {c.cfg.ClientSecret}, + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {c.cfg.RedirectURI}, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("discord oauth token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, oauthStatusError(resp) + } + + var token TokenResult + if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { + return nil, fmt.Errorf("discord oauth token decode: %w", err) + } + + if token.AccessToken == "" { + return nil, ErrUnexpected + } + + return &token, nil +} + +func (c *OAuthClient) FetchUser(ctx context.Context, accessToken string) (*User, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, currentUserURL, nil) + if err != nil { + return nil, fmt.Errorf("discord oauth user request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, oauthStatusError(resp) + } + + var user User + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return nil, fmt.Errorf("discord oauth user decode: %w", err) + } + + if user.ID == "" { + return nil, ErrUnexpected + } + + return &user, nil +} + +func oauthStatusError(resp *http.Response) error { + message := strings.TrimSpace(resp.Status) + if body, err := io.ReadAll(io.LimitReader(resp.Body, 4096)); err == nil { + if text := strings.TrimSpace(string(body)); text != "" { + message = text + } + } + + return &StatusError{StatusCode: resp.StatusCode, Message: message} +} diff --git a/internal/discord/oauth_test.go b/internal/discord/oauth_test.go new file mode 100644 index 0000000..4ff8116 --- /dev/null +++ b/internal/discord/oauth_test.go @@ -0,0 +1,132 @@ +package discord + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func newTestOAuthClient(serverURL string) *OAuthClient { + c := NewOAuthClient(OAuthConfig{ + ClientID: "cid", + ClientSecret: "csecret", + RedirectURI: "https://example.com/api/discord/callback", + Scopes: "identify guilds.join", + }, time.Second) + c.httpClient = &http.Client{Transport: rewriteTransport{base: serverURL}} + return c +} + +type rewriteTransport struct { + base string +} + +func (t rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + target, _ := url.Parse(t.base) + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + return http.DefaultTransport.RoundTrip(req) +} + +func TestAuthorizeURLContainsParams(t *testing.T) { + c := NewOAuthClient(OAuthConfig{ + ClientID: "cid", + RedirectURI: "https://example.com/cb", + Scopes: "identify guilds.join", + }, time.Second) + + got := c.AuthorizeURL("xyz-state") + parsed, err := url.Parse(got) + if err != nil { + t.Fatalf("parse: %v", err) + } + + q := parsed.Query() + if q.Get("client_id") != "cid" { + t.Errorf("client_id = %q", q.Get("client_id")) + } + + if q.Get("state") != "xyz-state" { + t.Errorf("state = %q", q.Get("state")) + } + + if q.Get("scope") != "identify guilds.join" { + t.Errorf("scope = %q", q.Get("scope")) + } + + if q.Get("response_type") != "code" { + t.Errorf("response_type = %q", q.Get("response_type")) + } +} + +func TestExchangeCodeSendsFormEncoded(t *testing.T) { + var contentType, code, grantType string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + contentType = r.Header.Get("Content-Type") + _ = r.ParseForm() + code = r.Form.Get("code") + grantType = r.Form.Get("grant_type") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"at-123","token_type":"Bearer","expires_in":604800,"scope":"identify"}`)) + })) + defer srv.Close() + + c := newTestOAuthClient(srv.URL) + token, err := c.ExchangeCode(context.Background(), "the-code") + if err != nil { + t.Fatalf("ExchangeCode: %v", err) + } + + if token.AccessToken != "at-123" { + t.Errorf("access_token = %q", token.AccessToken) + } + + if !strings.HasPrefix(contentType, "application/x-www-form-urlencoded") { + t.Errorf("content-type = %q", contentType) + } + + if code != "the-code" || grantType != "authorization_code" { + t.Errorf("form code=%q grant=%q", code, grantType) + } +} + +func TestFetchUserUsesBearer(t *testing.T) { + var auth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"42","username":"neo","global_name":"Neo"}`)) + })) + defer srv.Close() + + c := newTestOAuthClient(srv.URL) + user, err := c.FetchUser(context.Background(), "at-123") + if err != nil { + t.Fatalf("FetchUser: %v", err) + } + + if user.ID != "42" || user.Username != "neo" { + t.Errorf("user = %+v", user) + } + + if auth != "Bearer at-123" { + t.Errorf("auth = %q", auth) + } +} + +func TestExchangeCodeErrorStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`invalid_grant`)) + })) + defer srv.Close() + + c := newTestOAuthClient(srv.URL) + if _, err := c.ExchangeCode(context.Background(), "bad"); err == nil { + t.Fatal("expected error") + } +} diff --git a/internal/http/handlers/discord.go b/internal/http/handlers/discord.go new file mode 100644 index 0000000..c5c4b7f --- /dev/null +++ b/internal/http/handlers/discord.go @@ -0,0 +1,164 @@ +package handlers + +import ( + "errors" + "net/http" + "net/url" + "strings" + "time" + + "smctf/internal/http/middleware" + "smctf/internal/models" + "smctf/internal/service" + + "github.com/gin-gonic/gin" +) + +type discordStatusResponse struct { + Connected bool `json:"connected"` + DiscordID *string `json:"discord_user_id,omitempty"` + Username *string `json:"discord_username,omitempty"` + GlobalName *string `json:"discord_global_name,omitempty"` + Avatar *string `json:"discord_avatar,omitempty"` + RoleStatus string `json:"role_status,omitempty"` + ConnectedAt *time.Time `json:"connected_at,omitempty"` + VerifiedAt *time.Time `json:"verified_at,omitempty"` + InviteURL string `json:"invite_url,omitempty"` +} + +func (h *Handler) newDiscordStatusResponse(conn *models.DiscordConnection) discordStatusResponse { + if conn == nil { + return discordStatusResponse{Connected: false, InviteURL: h.cfg.Discord.InviteURL} + } + + connectedAt := conn.ConnectedAt.UTC() + resp := discordStatusResponse{ + Connected: true, + DiscordID: &conn.DiscordUserID, + Username: conn.DiscordUsername, + GlobalName: conn.DiscordGlobalName, + Avatar: conn.DiscordAvatar, + RoleStatus: conn.RoleStatus, + ConnectedAt: &connectedAt, + VerifiedAt: conn.VerifiedAt, + InviteURL: h.cfg.Discord.InviteURL, + } + + return resp +} + +func (h *Handler) DiscordConnect(ctx *gin.Context) { + if h.discord == nil { + writeError(ctx, service.ErrDiscordDisabled) + return + } + + authorizeURL, err := h.discord.BeginConnect(ctx.Request.Context(), middleware.UserID(ctx)) + if err != nil { + writeError(ctx, err) + return + } + + ctx.Redirect(http.StatusFound, authorizeURL) +} + +func (h *Handler) DiscordCallback(ctx *gin.Context) { + if h.discord == nil { + writeError(ctx, service.ErrDiscordDisabled) + return + } + + code := strings.TrimSpace(ctx.Query("code")) + state := strings.TrimSpace(ctx.Query("state")) + + conn, err := h.discord.HandleCallback(ctx.Request.Context(), middleware.UserID(ctx), code, state) + if err != nil { + h.redirectDiscordResult(ctx, discordResultForError(err)) + return + } + + h.redirectDiscordResult(ctx, discordResultForStatus(conn.RoleStatus)) +} + +func (h *Handler) DiscordStatus(ctx *gin.Context) { + if h.discord == nil { + writeError(ctx, service.ErrDiscordDisabled) + return + } + + conn, err := h.discord.GetConnection(ctx.Request.Context(), middleware.UserID(ctx)) + if err != nil { + writeError(ctx, err) + return + } + + ctx.JSON(http.StatusOK, h.newDiscordStatusResponse(conn)) +} + +func (h *Handler) DiscordSyncRole(ctx *gin.Context) { + if h.discord == nil { + writeError(ctx, service.ErrDiscordDisabled) + return + } + + conn, err := h.discord.SyncRole(ctx.Request.Context(), middleware.UserID(ctx)) + if err != nil { + writeError(ctx, err) + return + } + + ctx.JSON(http.StatusOK, h.newDiscordStatusResponse(conn)) +} + +func (h *Handler) DiscordUnlink(ctx *gin.Context) { + if h.discord == nil { + writeError(ctx, service.ErrDiscordDisabled) + return + } + + if err := h.discord.Unlink(ctx.Request.Context(), middleware.UserID(ctx)); err != nil { + writeError(ctx, err) + return + } + + ctx.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +func discordResultForStatus(roleStatus string) string { + switch roleStatus { + case models.DiscordStatusVerified: + return "verified" + case models.DiscordStatusNotInGuild, models.DiscordStatusLeftGuild: + return "connected_not_joined" + default: + return "role_failed" + } +} + +func discordResultForError(err error) string { + switch { + case err == nil: + return "verified" + case errors.Is(err, service.ErrDiscordStateInvalid): + return "state_invalid" + case errors.Is(err, service.ErrDiscordAlreadyLinked): + return "already_linked" + default: + return "error" + } +} + +func (h *Handler) redirectDiscordResult(ctx *gin.Context, result string) { + target := strings.TrimSpace(h.cfg.Discord.SuccessRedirect) + if target == "" { + ctx.JSON(http.StatusOK, gin.H{"discord": result}) + return + } + + sep := "?" + if strings.Contains(target, "?") { + sep = "&" + } + + ctx.Redirect(http.StatusFound, target+sep+"discord="+url.QueryEscape(result)) +} diff --git a/internal/http/handlers/discord_test.go b/internal/http/handlers/discord_test.go new file mode 100644 index 0000000..43584f7 --- /dev/null +++ b/internal/http/handlers/discord_test.go @@ -0,0 +1,295 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "smctf/internal/config" + "smctf/internal/discord" + "smctf/internal/models" + "smctf/internal/repo" + "smctf/internal/service" + + "github.com/gin-gonic/gin" +) + +type fakeBot struct { + grantErr error + kicked bool +} + +func (f *fakeBot) JoinGuild(_ context.Context, _, _ string) error { return nil } +func (f *fakeBot) GrantRole(_ context.Context, _ string) error { return f.grantErr } +func (f *fakeBot) KickMember(_ context.Context, _ string) error { + f.kicked = true + return nil +} + +func (f *fakeBot) GetMember(_ context.Context, _ string) (*discord.Member, error) { + return &discord.Member{}, nil +} + +type fakeOAuth struct { + user *discord.User +} + +func (f fakeOAuth) AuthorizeURL(state string) string { + return "https://discord.com/oauth2/authorize?state=" + state +} + +func (f fakeOAuth) ExchangeCode(_ context.Context, _ string) (*discord.TokenResult, error) { + return &discord.TokenResult{AccessToken: "at"}, nil +} + +func (f fakeOAuth) FetchUser(_ context.Context, _ string) (*discord.User, error) { + return f.user, nil +} + +func discordEnabledHandler(env handlerEnv, bot discord.BotAPI, user *discord.User) *Handler { + cfg := env.cfg + cfg.Discord = config.DiscordConfig{ + Enabled: true, + StateTTL: 5 * time.Minute, + AutoJoin: true, + SuccessRedirect: "http://localhost:3000/profile", + InviteURL: "https://discord.gg/invite", + } + discordSvc := service.NewDiscordService(cfg.Discord, repo.NewDiscordRepo(env.db), bot, fakeOAuth{user: user}, env.redis) + return New(cfg, env.authSvc, env.ctfSvc, env.appConfigSvc, env.userSvc, env.scoreSvc, env.divisionSvc, env.teamSvc, env.vmSvc, env.redis, discordSvc) +} + +func TestDiscordResultMapping(t *testing.T) { + if got := discordResultForStatus(models.DiscordStatusVerified); got != "verified" { + t.Errorf("verified -> %q", got) + } + + if got := discordResultForStatus(models.DiscordStatusNotInGuild); got != "connected_not_joined" { + t.Errorf("not in guild -> %q", got) + } + + if got := discordResultForStatus(models.DiscordStatusRoleFailed); got != "role_failed" { + t.Errorf("role failed -> %q", got) + } + + if got := discordResultForError(nil); got != "verified" { + t.Errorf("nil -> %q", got) + } + + if got := discordResultForError(service.ErrDiscordStateInvalid); got != "state_invalid" { + t.Errorf("state invalid -> %q", got) + } + + if got := discordResultForError(service.ErrDiscordAlreadyLinked); got != "already_linked" { + t.Errorf("already linked -> %q", got) + } + + if got := discordResultForError(errors.New("boom")); got != "error" { + t.Errorf("generic -> %q", got) + } +} + +func disabledDiscordHandler(redirect string) *Handler { + cfg := config.Config{} + cfg.Discord.SuccessRedirect = redirect + return New(cfg, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) +} + +func TestHandlerDiscordDisabled(t *testing.T) { + h := disabledDiscordHandler("") + + cases := []struct { + name string + method string + invoke func(*Handler, *gin.Context) + }{ + {"connect", http.MethodGet, (*Handler).DiscordConnect}, + {"callback", http.MethodGet, (*Handler).DiscordCallback}, + {"status", http.MethodGet, (*Handler).DiscordStatus}, + {"sync", http.MethodPost, (*Handler).DiscordSyncRole}, + {"unlink", http.MethodDelete, (*Handler).DiscordUnlink}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, rec := newJSONContext(t, tc.method, "/api/discord/"+tc.name, nil) + ctx.Set("userID", int64(1)) + tc.invoke(h, ctx) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d body=%s", rec.Code, rec.Body.String()) + } + }) + } +} + +func TestRedirectDiscordResult(t *testing.T) { + jsonCtx, jsonRec := newJSONContext(t, http.MethodGet, "/api/discord/callback", nil) + disabledDiscordHandler("").redirectDiscordResult(jsonCtx, "verified") + if jsonRec.Code != http.StatusOK || !strings.Contains(jsonRec.Body.String(), `"discord":"verified"`) { + t.Fatalf("json fallback: code=%d body=%s", jsonRec.Code, jsonRec.Body.String()) + } + + redirCtx, redirRec := newJSONContext(t, http.MethodGet, "/api/discord/callback", nil) + disabledDiscordHandler("https://app.example.com/profile?tab=1").redirectDiscordResult(redirCtx, "role_failed") + if redirRec.Code != http.StatusFound { + t.Fatalf("expected 302, got %d", redirRec.Code) + } + + if loc := redirRec.Header().Get("Location"); !strings.Contains(loc, "?tab=1&discord=role_failed") { + t.Fatalf("location = %q", loc) + } +} + +func TestHandlerDiscordStatusNotConnected(t *testing.T) { + env := setupHandlerTest(t) + user := createHandlerUser(t, env, "dh1@example.com", "dhuser1", "pass", models.UserRole) + h := discordEnabledHandler(env, &fakeBot{}, &discord.User{ID: "1"}) + + ctx, rec := newJSONContext(t, http.MethodGet, "/api/discord/status", nil) + ctx.Set("userID", user.ID) + h.DiscordStatus(ctx) + + if rec.Code != http.StatusOK { + t.Fatalf("status code = %d body=%s", rec.Code, rec.Body.String()) + } + + if !strings.Contains(rec.Body.String(), `"connected":false`) { + t.Errorf("body = %s", rec.Body.String()) + } + + if !strings.Contains(rec.Body.String(), "discord.gg/invite") { + t.Errorf("invite url missing: %s", rec.Body.String()) + } +} + +func TestHandlerDiscordConnectAndCallback(t *testing.T) { + env := setupHandlerTest(t) + user := createHandlerUser(t, env, "dh2@example.com", "dhuser2", "pass", models.UserRole) + bot := &fakeBot{} + h := discordEnabledHandler(env, bot, &discord.User{ID: "9001", Username: "neo"}) + + connectCtx, connectRec := newJSONContext(t, http.MethodGet, "/api/discord/connect", nil) + connectCtx.Set("userID", user.ID) + h.DiscordConnect(connectCtx) + if connectRec.Code != http.StatusFound { + t.Fatalf("connect code = %d", connectRec.Code) + } + + location := connectRec.Header().Get("Location") + parsed, err := url.Parse(location) + if err != nil { + t.Fatalf("parse location: %v", err) + } + + state := parsed.Query().Get("state") + if state == "" { + t.Fatal("state missing from authorize url") + } + + cbCtx, cbRec := newJSONContext(t, http.MethodGet, "/api/discord/callback?code=abc&state="+state, nil) + cbCtx.Set("userID", user.ID) + h.DiscordCallback(cbCtx) + if cbRec.Code != http.StatusFound { + t.Fatalf("callback code = %d body=%s", cbRec.Code, cbRec.Body.String()) + } + + if loc := cbRec.Header().Get("Location"); !strings.Contains(loc, "discord=verified") { + t.Errorf("callback location = %q", loc) + } + + stored, err := repo.NewDiscordRepo(env.db).GetByUserID(context.Background(), user.ID) + if err != nil { + t.Fatalf("connection not persisted: %v", err) + } + + if stored.DiscordUserID != "9001" || stored.RoleStatus != models.DiscordStatusVerified { + t.Errorf("stored = %+v", stored) + } +} + +func TestHandlerDiscordSyncRole(t *testing.T) { + env := setupHandlerTest(t) + user := createHandlerUser(t, env, "dh5@example.com", "dhuser5", "pass", models.UserRole) + bot := &fakeBot{grantErr: discord.ErrNotInGuild} + h := discordEnabledHandler(env, bot, &discord.User{ID: "5000", Username: "link"}) + + connectCtx, connectRec := newJSONContext(t, http.MethodGet, "/api/discord/connect", nil) + connectCtx.Set("userID", user.ID) + h.DiscordConnect(connectCtx) + state, _ := url.Parse(connectRec.Header().Get("Location")) + + cbCtx, cbRec := newJSONContext(t, http.MethodGet, "/api/discord/callback?code=abc&state="+state.Query().Get("state"), nil) + cbCtx.Set("userID", user.ID) + h.DiscordCallback(cbCtx) + if loc := cbRec.Header().Get("Location"); !strings.Contains(loc, "discord=connected_not_joined") { + t.Fatalf("callback location = %q", loc) + } + + bot.grantErr = nil + syncCtx, syncRec := newJSONContext(t, http.MethodPost, "/api/discord/sync-role", nil) + syncCtx.Set("userID", user.ID) + h.DiscordSyncRole(syncCtx) + + if syncRec.Code != http.StatusOK { + t.Fatalf("sync code = %d body=%s", syncRec.Code, syncRec.Body.String()) + } + + body := syncRec.Body.String() + if !strings.Contains(body, `"connected":true`) || !strings.Contains(body, `"role_status":"VERIFIED"`) { + t.Errorf("sync body = %s", body) + } +} + +func TestHandlerDiscordCallbackInvalidState(t *testing.T) { + env := setupHandlerTest(t) + user := createHandlerUser(t, env, "dh3@example.com", "dhuser3", "pass", models.UserRole) + h := discordEnabledHandler(env, &fakeBot{}, &discord.User{ID: "3"}) + + ctx, rec := newJSONContext(t, http.MethodGet, "/api/discord/callback?code=abc&state=bogus", nil) + ctx.Set("userID", user.ID) + h.DiscordCallback(ctx) + + if rec.Code != http.StatusFound { + t.Fatalf("code = %d", rec.Code) + } + + if loc := rec.Header().Get("Location"); !strings.Contains(loc, "discord=state_invalid") { + t.Errorf("location = %q", loc) + } +} + +func TestHandlerDiscordUnlink(t *testing.T) { + env := setupHandlerTest(t) + user := createHandlerUser(t, env, "dh4@example.com", "dhuser4", "pass", models.UserRole) + bot := &fakeBot{} + h := discordEnabledHandler(env, bot, &discord.User{ID: "4000", Username: "trinity"}) + + connectCtx, connectRec := newJSONContext(t, http.MethodGet, "/api/discord/connect", nil) + connectCtx.Set("userID", user.ID) + h.DiscordConnect(connectCtx) + state, _ := url.Parse(connectRec.Header().Get("Location")) + + cbCtx, _ := newJSONContext(t, http.MethodGet, "/api/discord/callback?code=abc&state="+state.Query().Get("state"), nil) + cbCtx.Set("userID", user.ID) + h.DiscordCallback(cbCtx) + + unlinkCtx, unlinkRec := newJSONContext(t, http.MethodDelete, "/api/discord/unlink", nil) + unlinkCtx.Set("userID", user.ID) + h.DiscordUnlink(unlinkCtx) + + if unlinkRec.Code != http.StatusOK { + t.Fatalf("unlink code = %d", unlinkRec.Code) + } + + if !bot.kicked { + t.Error("expected kick call") + } + + if _, err := repo.NewDiscordRepo(env.db).GetByUserID(context.Background(), user.ID); err == nil { + t.Error("connection should be deleted") + } +} diff --git a/internal/http/handlers/errors.go b/internal/http/handlers/errors.go index fd3877d..ec6e2cc 100644 --- a/internal/http/handlers/errors.go +++ b/internal/http/handlers/errors.go @@ -134,6 +134,27 @@ func mapError(err error) (int, errorResponse, map[string]string) { case errors.Is(err, service.ErrVMInvalidSpec): status = http.StatusBadRequest resp.Error = service.ErrVMInvalidSpec.Error() + case errors.Is(err, service.ErrDiscordDisabled): + status = http.StatusServiceUnavailable + resp.Error = service.ErrDiscordDisabled.Error() + case errors.Is(err, service.ErrDiscordNotConnected): + status = http.StatusNotFound + resp.Error = service.ErrDiscordNotConnected.Error() + case errors.Is(err, service.ErrDiscordStateInvalid): + status = http.StatusBadRequest + resp.Error = service.ErrDiscordStateInvalid.Error() + case errors.Is(err, service.ErrDiscordExchangeFailed): + status = http.StatusBadGateway + resp.Error = service.ErrDiscordExchangeFailed.Error() + case errors.Is(err, service.ErrDiscordAlreadyLinked): + status = http.StatusConflict + resp.Error = service.ErrDiscordAlreadyLinked.Error() + case errors.Is(err, service.ErrDiscordBotUnavailable): + status = http.StatusServiceUnavailable + resp.Error = service.ErrDiscordBotUnavailable.Error() + case errors.Is(err, service.ErrDiscordRateLimited): + status = http.StatusTooManyRequests + resp.Error = service.ErrDiscordRateLimited.Error() case errors.Is(err, service.ErrNotFound): status = http.StatusNotFound resp.Error = "not found" diff --git a/internal/http/handlers/handler.go b/internal/http/handlers/handler.go index 2182b78..f495ad7 100644 --- a/internal/http/handlers/handler.go +++ b/internal/http/handlers/handler.go @@ -22,20 +22,21 @@ import ( ) type Handler struct { - cfg config.Config - auth *service.AuthService - ctf *service.CTFService - app *service.AppConfigService - users *service.UserService - score *service.ScoreboardService - divs *service.DivisionService - teams *service.TeamService - vms *service.VMService - redis *redis.Client -} - -func New(cfg config.Config, auth *service.AuthService, ctf *service.CTFService, app *service.AppConfigService, users *service.UserService, score *service.ScoreboardService, divisions *service.DivisionService, teams *service.TeamService, vms *service.VMService, redis *redis.Client) *Handler { - return &Handler{cfg: cfg, auth: auth, ctf: ctf, app: app, users: users, score: score, divs: divisions, teams: teams, vms: vms, redis: redis} + cfg config.Config + auth *service.AuthService + ctf *service.CTFService + app *service.AppConfigService + users *service.UserService + score *service.ScoreboardService + divs *service.DivisionService + teams *service.TeamService + vms *service.VMService + discord *service.DiscordService + redis *redis.Client +} + +func New(cfg config.Config, auth *service.AuthService, ctf *service.CTFService, app *service.AppConfigService, users *service.UserService, score *service.ScoreboardService, divisions *service.DivisionService, teams *service.TeamService, vms *service.VMService, redis *redis.Client, discord *service.DiscordService) *Handler { + return &Handler{cfg: cfg, auth: auth, ctf: ctf, app: app, users: users, score: score, divs: divisions, teams: teams, vms: vms, redis: redis, discord: discord} } func (h *Handler) respondFromCache(ctx *gin.Context, cacheKey string) bool { diff --git a/internal/http/handlers/handler_test.go b/internal/http/handlers/handler_test.go index 42204b4..bd9dafd 100644 --- a/internal/http/handlers/handler_test.go +++ b/internal/http/handlers/handler_test.go @@ -462,7 +462,7 @@ func TestHandlerRegisterLoginRefreshLogout(t *testing.T) { } func TestHandlerUserVMSummaryWithoutVMService(t *testing.T) { - handler := New(handlerCfg, nil, nil, nil, nil, nil, nil, nil, nil, handlerRedis) + handler := New(handlerCfg, nil, nil, nil, nil, nil, nil, nil, nil, handlerRedis, nil) count, limit := handler.userVMSummary(context.Background(), 123) if count != 0 || limit != 0 { @@ -1091,7 +1091,7 @@ func TestHandlerRequestChallengeFileUploadStorageUnavailable(t *testing.T) { ctfSvc := service.NewCTFService(env.cfg, env.challengeRepo, env.submissionRepo, env.redis, nil) scoreRepo := repo.NewScoreboardRepo(env.db) scoreSvc := service.NewScoreboardService(scoreRepo) - handler := New(env.cfg, env.authSvc, ctfSvc, env.appConfigSvc, env.userSvc, scoreSvc, env.divisionSvc, env.teamSvc, nil, env.redis) + handler := New(env.cfg, env.authSvc, ctfSvc, env.appConfigSvc, env.userSvc, scoreSvc, env.divisionSvc, env.teamSvc, nil, env.redis, nil) ctx, rec := newJSONContext(t, http.MethodPost, "/api/admin/challenges/1/file/upload", map[string]string{"filename": "bundle.zip"}) ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", challenge.ID)}} @@ -2202,7 +2202,7 @@ func TestHandlerLeaderboardError(t *testing.T) { closedDB := newClosedHandlerDB(t) scoreRepo := repo.NewScoreboardRepo(closedDB) scoreSvc := service.NewScoreboardService(scoreRepo) - handler := New(handlerCfg, nil, nil, nil, nil, scoreSvc, nil, nil, nil, handlerRedis) + handler := New(handlerCfg, nil, nil, nil, nil, scoreSvc, nil, nil, nil, handlerRedis, nil) divisionID := int64(1) ctx, rec := newJSONContext(t, http.MethodGet, fmt.Sprintf("/api/leaderboard?division_id=%d", divisionID), nil) @@ -2223,7 +2223,7 @@ func TestHandlerListChallengesError(t *testing.T) { scoreSvc := service.NewScoreboardService(scoreRepo) appConfigRepo := repo.NewAppConfigRepo(closedDB) appConfigSvc := service.NewAppConfigService(appConfigRepo, handlerRedis, handlerCfg.Cache.AppConfigTTL) - handler := New(handlerCfg, nil, ctfSvc, appConfigSvc, nil, scoreSvc, nil, nil, nil, handlerRedis) + handler := New(handlerCfg, nil, ctfSvc, appConfigSvc, nil, scoreSvc, nil, nil, nil, handlerRedis, nil) divisionID := int64(1) ctx, rec := newJSONContext(t, http.MethodGet, fmt.Sprintf("/api/challenges?division_id=%d", divisionID), nil) @@ -2614,7 +2614,7 @@ func TestOptionalUserID(t *testing.T) { RefreshTTL: time.Hour, }, } - handler := New(cfg, nil, nil, nil, nil, nil, nil, nil, nil, nil) + handler := New(cfg, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) token, err := auth.GenerateAccessToken(cfg.JWT, 99, models.UserRole) if err != nil { @@ -2639,7 +2639,7 @@ func TestOptionalUserIDInvalidHeaders(t *testing.T) { RefreshTTL: time.Hour, }, } - handler := New(cfg, nil, nil, nil, nil, nil, nil, nil, nil, nil) + handler := New(cfg, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) ctx, _ := newJSONContext(t, http.MethodGet, "/api/me", nil) if got := handler.optionalUserID(ctx); got != 0 { diff --git a/internal/http/handlers/testenv_test.go b/internal/http/handlers/testenv_test.go index 6d61efe..943787e 100644 --- a/internal/http/handlers/testenv_test.go +++ b/internal/http/handlers/testenv_test.go @@ -249,7 +249,7 @@ func setupHandlerTest(t *testing.T) handlerEnv { ctfSvc := service.NewCTFService(handlerCfg, challengeRepo, submissionRepo, handlerRedis, fileStore) vmSvc := service.NewVMService(handlerCfg.VM, vmRepo, challengeRepo, submissionRepo, &vm.MockClient{}, handlerRedis) - handler := New(handlerCfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, vmSvc, handlerRedis) + handler := New(handlerCfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, vmSvc, handlerRedis, nil) env := handlerEnv{ cfg: handlerCfg, diff --git a/internal/http/integration/testenv_test.go b/internal/http/integration/testenv_test.go index 76b5111..8638853 100644 --- a/internal/http/integration/testenv_test.go +++ b/internal/http/integration/testenv_test.go @@ -290,7 +290,7 @@ func setupTest(t *testing.T, cfg config.Config) testEnv { appConfigSvc := service.NewAppConfigService(appConfigRepo, testRedis, cfg.Cache.AppConfigTTL) vmSvc := service.NewVMService(cfg.VM, vmRepo, challengeRepo, submissionRepo, &vm.MockClient{}, testRedis) - router := apphttp.NewRouter(cfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, vmSvc, testRedis, testLogger, nil) + router := apphttp.NewRouter(cfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, vmSvc, nil, testRedis, testLogger, nil) env := testEnv{ cfg: cfg, @@ -350,7 +350,7 @@ func setupVMTest(t *testing.T, cfg config.Config, client vm.API) testEnv { appConfigSvc := service.NewAppConfigService(appConfigRepo, testRedis, cfg.Cache.AppConfigTTL) vmSvc := service.NewVMService(cfg.VM, vmRepo, challengeRepo, submissionRepo, client, testRedis) - router := apphttp.NewRouter(cfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, vmSvc, testRedis, testLogger, nil) + router := apphttp.NewRouter(cfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, vmSvc, nil, testRedis, testLogger, nil) env := testEnv{ cfg: cfg, diff --git a/internal/http/router.go b/internal/http/router.go index d901a58..0142353 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -16,7 +16,7 @@ import ( "github.com/redis/go-redis/v9" ) -func NewRouter(cfg config.Config, authSvc *service.AuthService, ctfSvc *service.CTFService, appConfigSvc *service.AppConfigService, userSvc *service.UserService, scoreSvc *service.ScoreboardService, divisionSvc *service.DivisionService, teamSvc *service.TeamService, vmSvc *service.VMService, redis *redis.Client, logger *logging.Logger, sse *realtime.SSEHub) *gin.Engine { +func NewRouter(cfg config.Config, authSvc *service.AuthService, ctfSvc *service.CTFService, appConfigSvc *service.AppConfigService, userSvc *service.UserService, scoreSvc *service.ScoreboardService, divisionSvc *service.DivisionService, teamSvc *service.TeamService, vmSvc *service.VMService, discordSvc *service.DiscordService, redis *redis.Client, logger *logging.Logger, sse *realtime.SSEHub) *gin.Engine { if cfg.AppEnv == "production" { gin.SetMode(gin.ReleaseMode) } @@ -27,7 +27,7 @@ func NewRouter(cfg config.Config, authSvc *service.AuthService, ctfSvc *service. r.Use(middleware.CORS(cfg.AppEnv == "local", cfg.CORS.AllowedOrigins)) r.Use(middleware.CSRF()) - h := handlers.New(cfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, vmSvc, redis) + h := handlers.New(cfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, vmSvc, redis, discordSvc) sseHandler := handlers.NewSSEHandler(sse) r.GET("/healthz", func(ctx *gin.Context) { @@ -58,12 +58,15 @@ func NewRouter(cfg config.Config, authSvc *service.AuthService, ctfSvc *service. api.GET("/users", h.ListUsers) api.GET("/users/:id", h.GetUser) api.GET("/users/:id/solved", h.GetUserSolved) + api.GET("/discord/callback", h.DiscordCallback) auth := api.Group("") auth.Use(middleware.Auth(cfg.JWT)) auth.GET("/me", h.Me) auth.GET("/vms", h.ListVMs) auth.GET("/challenges/:id/vm", h.GetVM) + auth.GET("/discord/connect", h.DiscordConnect) + auth.GET("/discord/status", h.DiscordStatus) unblocked := auth.Group("") unblocked.Use(middleware.RequireActiveUser(userSvc)) @@ -72,6 +75,8 @@ func NewRouter(cfg config.Config, authSvc *service.AuthService, ctfSvc *service. unblocked.POST("/challenges/:id/file/download", h.RequestChallengeFileDownload) unblocked.POST("/challenges/:id/vm", h.CreateVM) unblocked.DELETE("/challenges/:id/vm", h.DeleteVM) + unblocked.POST("/discord/sync-role", h.DiscordSyncRole) + unblocked.DELETE("/discord/unlink", h.DiscordUnlink) admin := api.Group("/admin") admin.Use(middleware.Auth(cfg.JWT), middleware.RequireActiveUser(userSvc), middleware.RequireRole(models.AdminRole)) diff --git a/internal/models/discord_connection.go b/internal/models/discord_connection.go new file mode 100644 index 0000000..65598d7 --- /dev/null +++ b/internal/models/discord_connection.go @@ -0,0 +1,34 @@ +package models + +import ( + "time" + + "github.com/uptrace/bun" +) + +const ( + DiscordStatusConnected = "CONNECTED" + DiscordStatusVerified = "VERIFIED" + DiscordStatusNotInGuild = "NOT_IN_GUILD" + DiscordStatusRoleFailed = "ROLE_FAILED" + DiscordStatusRevoked = "REVOKED" + DiscordStatusLeftGuild = "LEFT_GUILD" +) + +type DiscordConnection struct { + bun.BaseModel `bun:"table:discord_connections"` + ID int64 `bun:"id,pk,autoincrement"` + UserID int64 `bun:"user_id,unique,notnull"` + DiscordUserID string `bun:"discord_user_id,unique,notnull"` + DiscordUsername *string `bun:"discord_username,nullzero"` + DiscordGlobalName *string `bun:"discord_global_name,nullzero"` + DiscordAvatar *string `bun:"discord_avatar,nullzero"` + RoleStatus string `bun:"role_status,notnull,default:'CONNECTED'"` + ConnectedAt time.Time `bun:"connected_at,nullzero,notnull,default:current_timestamp"` + VerifiedAt *time.Time `bun:"verified_at,nullzero"` + RevokedAt *time.Time `bun:"revoked_at,nullzero"` + LastSyncedAt *time.Time `bun:"last_synced_at,nullzero"` + LastError *string `bun:"last_error,nullzero"` + CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"` + UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp"` +} diff --git a/internal/repo/discord_repo.go b/internal/repo/discord_repo.go new file mode 100644 index 0000000..3741713 --- /dev/null +++ b/internal/repo/discord_repo.go @@ -0,0 +1,59 @@ +package repo + +import ( + "context" + + "smctf/internal/models" + + "github.com/uptrace/bun" +) + +type DiscordRepo struct { + db *bun.DB +} + +func NewDiscordRepo(db *bun.DB) *DiscordRepo { + return &DiscordRepo{db: db} +} + +func (r *DiscordRepo) GetByUserID(ctx context.Context, userID int64) (*models.DiscordConnection, error) { + conn := new(models.DiscordConnection) + if err := r.db.NewSelect().Model(conn).Where("user_id = ?", userID).Scan(ctx); err != nil { + return nil, wrapNotFound("discordRepo.GetByUserID", err) + } + + return conn, nil +} + +func (r *DiscordRepo) GetByDiscordUserID(ctx context.Context, discordUserID string) (*models.DiscordConnection, error) { + conn := new(models.DiscordConnection) + if err := r.db.NewSelect().Model(conn).Where("discord_user_id = ?", discordUserID).Scan(ctx); err != nil { + return nil, wrapNotFound("discordRepo.GetByDiscordUserID", err) + } + + return conn, nil +} + +func (r *DiscordRepo) Create(ctx context.Context, conn *models.DiscordConnection) error { + if _, err := r.db.NewInsert().Model(conn).Exec(ctx); err != nil { + return wrapError("discordRepo.Create", err) + } + + return nil +} + +func (r *DiscordRepo) Update(ctx context.Context, conn *models.DiscordConnection) error { + if _, err := r.db.NewUpdate().Model(conn).WherePK().Exec(ctx); err != nil { + return wrapError("discordRepo.Update", err) + } + + return nil +} + +func (r *DiscordRepo) Delete(ctx context.Context, conn *models.DiscordConnection) error { + if _, err := r.db.NewDelete().Model(conn).WherePK().Exec(ctx); err != nil { + return wrapError("discordRepo.Delete", err) + } + + return nil +} diff --git a/internal/repo/discord_repo_test.go b/internal/repo/discord_repo_test.go new file mode 100644 index 0000000..a2db403 --- /dev/null +++ b/internal/repo/discord_repo_test.go @@ -0,0 +1,113 @@ +package repo + +import ( + "context" + "errors" + "testing" + "time" + + "smctf/internal/models" +) + +func createDiscordConnection(t *testing.T, repo *DiscordRepo, userID int64, discordUserID string) *models.DiscordConnection { + t.Helper() + username := "neo" + globalName := "Neo" + avatar := "avatarhash" + conn := &models.DiscordConnection{ + UserID: userID, + DiscordUserID: discordUserID, + DiscordUsername: &username, + DiscordGlobalName: &globalName, + DiscordAvatar: &avatar, + RoleStatus: models.DiscordStatusConnected, + ConnectedAt: time.Now().UTC(), + } + if err := repo.Create(context.Background(), conn); err != nil { + t.Fatalf("create discord connection: %v", err) + } + + return conn +} + +func TestDiscordRepoCRUD(t *testing.T) { + env := setupRepoTest(t) + repo := NewDiscordRepo(env.db) + user := createUserWithNewTeam(t, env, "discord-crud@example.com", "discord-crud", "pass", models.UserRole) + + created := createDiscordConnection(t, repo, user.ID, "900000000000000001") + + byUser, err := repo.GetByUserID(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetByUserID: %v", err) + } + + if byUser.DiscordUserID != created.DiscordUserID || byUser.RoleStatus != models.DiscordStatusConnected { + t.Fatalf("unexpected row by user: %+v", byUser) + } + + byDiscord, err := repo.GetByDiscordUserID(context.Background(), created.DiscordUserID) + if err != nil { + t.Fatalf("GetByDiscordUserID: %v", err) + } + + if byDiscord.UserID != user.ID { + t.Fatalf("unexpected row by discord id: %+v", byDiscord) + } + + verifiedAt := time.Now().UTC() + byUser.RoleStatus = models.DiscordStatusVerified + byUser.VerifiedAt = &verifiedAt + if err := repo.Update(context.Background(), byUser); err != nil { + t.Fatalf("Update: %v", err) + } + + updated, err := repo.GetByUserID(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetByUserID after update: %v", err) + } + + if updated.RoleStatus != models.DiscordStatusVerified || updated.VerifiedAt == nil { + t.Fatalf("update not persisted: %+v", updated) + } + + if err := repo.Delete(context.Background(), updated); err != nil { + t.Fatalf("Delete: %v", err) + } + + if _, err := repo.GetByUserID(context.Background(), user.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound after delete, got %v", err) + } +} + +func TestDiscordRepoNotFound(t *testing.T) { + env := setupRepoTest(t) + repo := NewDiscordRepo(env.db) + + if _, err := repo.GetByUserID(context.Background(), 999); !errors.Is(err, ErrNotFound) { + t.Fatalf("GetByUserID: expected ErrNotFound, got %v", err) + } + + if _, err := repo.GetByDiscordUserID(context.Background(), "missing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("GetByDiscordUserID: expected ErrNotFound, got %v", err) + } +} + +func TestDiscordRepoDuplicateDiscordUserID(t *testing.T) { + env := setupRepoTest(t) + repo := NewDiscordRepo(env.db) + user1 := createUserWithNewTeam(t, env, "discord-dup1@example.com", "discord-dup1", "pass", models.UserRole) + user2 := createUserWithNewTeam(t, env, "discord-dup2@example.com", "discord-dup2", "pass", models.UserRole) + + createDiscordConnection(t, repo, user1.ID, "900000000000000002") + + dup := &models.DiscordConnection{ + UserID: user2.ID, + DiscordUserID: "900000000000000002", + RoleStatus: models.DiscordStatusConnected, + ConnectedAt: time.Now().UTC(), + } + if err := repo.Create(context.Background(), dup); err == nil { + t.Fatal("expected unique violation creating duplicate discord_user_id, got nil") + } +} diff --git a/internal/repo/testenv_test.go b/internal/repo/testenv_test.go index 3a900af..0e8977d 100644 --- a/internal/repo/testenv_test.go +++ b/internal/repo/testenv_test.go @@ -172,7 +172,7 @@ func setupRepoTest(t *testing.T) repoEnv { func resetRepoState(t *testing.T) { t.Helper() - if _, err := repoDB.ExecContext(context.Background(), "TRUNCATE TABLE app_configs, submissions, registration_key_uses, registration_keys, vms, challenges, users, teams, divisions RESTART IDENTITY CASCADE"); err != nil { + if _, err := repoDB.ExecContext(context.Background(), "TRUNCATE TABLE discord_connections, app_configs, submissions, registration_key_uses, registration_keys, vms, challenges, users, teams, divisions RESTART IDENTITY CASCADE"); err != nil { t.Fatalf("truncate tables: %v", err) } } diff --git a/internal/service/discord_service.go b/internal/service/discord_service.go new file mode 100644 index 0000000..49f4312 --- /dev/null +++ b/internal/service/discord_service.go @@ -0,0 +1,280 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "strconv" + "time" + + "smctf/internal/config" + "smctf/internal/discord" + "smctf/internal/models" + "smctf/internal/repo" + + "github.com/redis/go-redis/v9" +) + +type DiscordOAuth interface { + AuthorizeURL(state string) string + ExchangeCode(ctx context.Context, code string) (*discord.TokenResult, error) + FetchUser(ctx context.Context, accessToken string) (*discord.User, error) +} + +type DiscordService struct { + cfg config.DiscordConfig + repo *repo.DiscordRepo + bot discord.BotAPI + oauth DiscordOAuth + redis *redis.Client +} + +func NewDiscordService(cfg config.DiscordConfig, discordRepo *repo.DiscordRepo, bot discord.BotAPI, oauth DiscordOAuth, redisClient *redis.Client) *DiscordService { + return &DiscordService{ + cfg: cfg, + repo: discordRepo, + bot: bot, + oauth: oauth, + redis: redisClient, + } +} + +func (s *DiscordService) ensureEnabled() error { + if s == nil || !s.cfg.Enabled { + return ErrDiscordDisabled + } + + return nil +} + +func stateKey(state string) string { + return "discord:oauth:state:" + state +} + +func (s *DiscordService) BeginConnect(ctx context.Context, userID int64) (string, error) { + if err := s.ensureEnabled(); err != nil { + return "", err + } + + state, err := randomState() + if err != nil { + return "", fmt.Errorf("discord.BeginConnect state: %w", err) + } + + if err := s.redis.Set(ctx, stateKey(state), userID, s.cfg.StateTTL).Err(); err != nil { + return "", fmt.Errorf("discord.BeginConnect store state: %w", err) + } + + return s.oauth.AuthorizeURL(state), nil +} + +func (s *DiscordService) HandleCallback(ctx context.Context, userID int64, code, state string) (*models.DiscordConnection, error) { + if err := s.ensureEnabled(); err != nil { + return nil, err + } + + if code == "" || state == "" { + return nil, ErrDiscordStateInvalid + } + + if err := s.consumeState(ctx, state, userID); err != nil { + return nil, err + } + + token, err := s.oauth.ExchangeCode(ctx, code) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrDiscordExchangeFailed, err) + } + + discordUser, err := s.oauth.FetchUser(ctx, token.AccessToken) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrDiscordExchangeFailed, err) + } + + conn, err := s.upsertConnection(ctx, userID, discordUser) + if err != nil { + return nil, err + } + + s.provision(ctx, conn, token.AccessToken) + + if err := s.repo.Update(ctx, conn); err != nil { + return nil, err + } + + return conn, nil +} + +func (s *DiscordService) SyncRole(ctx context.Context, userID int64) (*models.DiscordConnection, error) { + if err := s.ensureEnabled(); err != nil { + return nil, err + } + + conn, err := s.getConnection(ctx, userID) + if err != nil { + return nil, err + } + + s.provision(ctx, conn, "") + + if err := s.repo.Update(ctx, conn); err != nil { + return nil, err + } + + return conn, nil +} + +func (s *DiscordService) Unlink(ctx context.Context, userID int64) error { + if err := s.ensureEnabled(); err != nil { + return err + } + + conn, err := s.getConnection(ctx, userID) + if err != nil { + return err + } + + if err := s.bot.KickMember(ctx, conn.DiscordUserID); err != nil { + slog.Warn("discord guild kick failed during unlink", + slog.Int64("user_id", userID), + slog.Any("error", err), + ) + } + + return s.repo.Delete(ctx, conn) +} + +func (s *DiscordService) GetConnection(ctx context.Context, userID int64) (*models.DiscordConnection, error) { + if err := s.ensureEnabled(); err != nil { + return nil, err + } + + conn, err := s.repo.GetByUserID(ctx, userID) + if err != nil { + if errors.Is(err, repo.ErrNotFound) { + return nil, nil + } + + return nil, err + } + + return conn, nil +} + +func (s *DiscordService) getConnection(ctx context.Context, userID int64) (*models.DiscordConnection, error) { + conn, err := s.repo.GetByUserID(ctx, userID) + if err != nil { + if errors.Is(err, repo.ErrNotFound) { + return nil, ErrDiscordNotConnected + } + + return nil, err + } + + return conn, nil +} + +func (s *DiscordService) consumeState(ctx context.Context, state string, userID int64) error { + stored, err := s.redis.GetDel(ctx, stateKey(state)).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return ErrDiscordStateInvalid + } + + return fmt.Errorf("discord.consumeState: %w", err) + } + + if stored != strconv.FormatInt(userID, 10) { + return ErrDiscordStateInvalid + } + + return nil +} + +func (s *DiscordService) upsertConnection(ctx context.Context, userID int64, discordUser *discord.User) (*models.DiscordConnection, error) { + existing, err := s.repo.GetByDiscordUserID(ctx, discordUser.ID) + if err != nil && !errors.Is(err, repo.ErrNotFound) { + return nil, err + } + + if existing != nil && existing.UserID != userID { + return nil, ErrDiscordAlreadyLinked + } + + conn, err := s.repo.GetByUserID(ctx, userID) + if err != nil { + if !errors.Is(err, repo.ErrNotFound) { + return nil, err + } + + conn = &models.DiscordConnection{ + UserID: userID, + ConnectedAt: time.Now().UTC(), + RoleStatus: models.DiscordStatusConnected, + } + } + + conn.DiscordUserID = discordUser.ID + conn.DiscordUsername = &discordUser.Username + conn.DiscordGlobalName = discordUser.GlobalName + conn.DiscordAvatar = discordUser.Avatar + conn.RevokedAt = nil + conn.UpdatedAt = time.Now().UTC() + + if conn.ID == 0 { + if err := s.repo.Create(ctx, conn); err != nil { + return nil, err + } + } + + return conn, nil +} + +func (s *DiscordService) provision(ctx context.Context, conn *models.DiscordConnection, accessToken string) { + now := time.Now().UTC() + conn.LastSyncedAt = &now + conn.UpdatedAt = now + conn.LastError = nil + + if s.cfg.AutoJoin && accessToken != "" { + if err := s.bot.JoinGuild(ctx, conn.DiscordUserID, accessToken); err != nil { + slog.Warn("discord guild join failed", + slog.Int64("user_id", conn.UserID), + slog.Any("error", err), + ) + } + } + + if err := s.bot.GrantRole(ctx, conn.DiscordUserID); err != nil { + s.applyGrantError(conn, err) + return + } + + conn.RoleStatus = models.DiscordStatusVerified + conn.VerifiedAt = &now +} + +func (s *DiscordService) applyGrantError(conn *models.DiscordConnection, err error) { + msg := err.Error() + conn.LastError = &msg + conn.VerifiedAt = nil + + switch { + case errors.Is(err, discord.ErrNotInGuild): + conn.RoleStatus = models.DiscordStatusNotInGuild + default: + conn.RoleStatus = models.DiscordStatusRoleFailed + } +} + +func randomState() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + + return hex.EncodeToString(b), nil +} diff --git a/internal/service/discord_service_test.go b/internal/service/discord_service_test.go new file mode 100644 index 0000000..a8e2aa5 --- /dev/null +++ b/internal/service/discord_service_test.go @@ -0,0 +1,242 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "smctf/internal/config" + "smctf/internal/discord" + "smctf/internal/models" + "smctf/internal/repo" +) + +type fakeBot struct { + joinErr error + grantErr error + kickErr error + joined bool + granted bool + kicked bool +} + +func (f *fakeBot) JoinGuild(_ context.Context, _, _ string) error { + f.joined = true + return f.joinErr +} + +func (f *fakeBot) GrantRole(_ context.Context, _ string) error { + f.granted = true + return f.grantErr +} + +func (f *fakeBot) KickMember(_ context.Context, _ string) error { + f.kicked = true + return f.kickErr +} + +func (f *fakeBot) GetMember(_ context.Context, _ string) (*discord.Member, error) { + return &discord.Member{}, nil +} + +type fakeOAuth struct { + user *discord.User +} + +func (f fakeOAuth) AuthorizeURL(state string) string { + return "https://discord.com/oauth2/authorize?state=" + state +} + +func (f fakeOAuth) ExchangeCode(_ context.Context, _ string) (*discord.TokenResult, error) { + return &discord.TokenResult{AccessToken: "at-test"}, nil +} + +func (f fakeOAuth) FetchUser(_ context.Context, _ string) (*discord.User, error) { + return f.user, nil +} + +func discordTestConfig() config.DiscordConfig { + return config.DiscordConfig{ + Enabled: true, + StateTTL: 5 * time.Minute, + AutoJoin: true, + RedirectURI: "https://example.com/cb", + Scopes: "identify guilds.join", + } +} + +func newDiscordServiceForTest(env serviceEnv, bot discord.BotAPI, user *discord.User) (*DiscordService, *repo.DiscordRepo) { + discordRepo := repo.NewDiscordRepo(env.db) + svc := NewDiscordService(discordTestConfig(), discordRepo, bot, fakeOAuth{user: user}, env.redis) + return svc, discordRepo +} + +func TestDiscordBeginConnectStoresState(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "d1@example.com", "duser1", "pass", models.UserRole) + + svc, _ := newDiscordServiceForTest(env, &fakeBot{}, &discord.User{ID: "100", Username: "neo"}) + + url, err := svc.BeginConnect(context.Background(), user.ID) + if err != nil { + t.Fatalf("BeginConnect: %v", err) + } + + if url == "" { + t.Fatal("empty authorize url") + } + + keys := env.redis.Keys(context.Background(), "discord:oauth:state:*").Val() + if len(keys) != 1 { + t.Fatalf("expected 1 state key, got %d", len(keys)) + } +} + +func TestDiscordHandleCallbackVerified(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "d2@example.com", "duser2", "pass", models.UserRole) + + bot := &fakeBot{} + svc, discordRepo := newDiscordServiceForTest(env, bot, &discord.User{ID: "200", Username: "trinity"}) + + state := seedState(t, env, user.ID) + conn, err := svc.HandleCallback(context.Background(), user.ID, "code", state) + if err != nil { + t.Fatalf("HandleCallback: %v", err) + } + + if conn.RoleStatus != models.DiscordStatusVerified { + t.Errorf("status = %q", conn.RoleStatus) + } + + if !bot.joined || !bot.granted { + t.Errorf("expected join+grant, got joined=%v granted=%v", bot.joined, bot.granted) + } + + stored, err := discordRepo.GetByUserID(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetByUserID: %v", err) + } + + if stored.DiscordUserID != "200" { + t.Errorf("discord id = %q", stored.DiscordUserID) + } +} + +func TestDiscordHandleCallbackNotInGuild(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "d3@example.com", "duser3", "pass", models.UserRole) + + bot := &fakeBot{grantErr: discord.ErrNotInGuild} + svc, _ := newDiscordServiceForTest(env, bot, &discord.User{ID: "300", Username: "morpheus"}) + + state := seedState(t, env, user.ID) + conn, err := svc.HandleCallback(context.Background(), user.ID, "code", state) + if err != nil { + t.Fatalf("HandleCallback: %v", err) + } + + if conn.RoleStatus != models.DiscordStatusNotInGuild { + t.Errorf("status = %q", conn.RoleStatus) + } +} + +func TestDiscordHandleCallbackStateMismatch(t *testing.T) { + env := setupServiceTest(t) + owner := createUserWithNewTeam(t, env, "d4@example.com", "duser4", "pass", models.UserRole) + + svc, _ := newDiscordServiceForTest(env, &fakeBot{}, &discord.User{ID: "400"}) + + if _, err := svc.HandleCallback(context.Background(), owner.ID, "code", "bogus-state"); !errors.Is(err, ErrDiscordStateInvalid) { + t.Fatalf("bogus state: got %v, want ErrDiscordStateInvalid", err) + } + + state := seedState(t, env, owner.ID) + if _, err := svc.HandleCallback(context.Background(), owner.ID+999, "code", state); !errors.Is(err, ErrDiscordStateInvalid) { + t.Fatalf("mismatched user: got %v, want ErrDiscordStateInvalid", err) + } +} + +func TestDiscordHandleCallbackAlreadyLinked(t *testing.T) { + env := setupServiceTest(t) + owner := createUserWithNewTeam(t, env, "d5@example.com", "duser5", "pass", models.UserRole) + other := createUserWithNewTeam(t, env, "d6@example.com", "duser6", "pass", models.UserRole) + + svc, discordRepo := newDiscordServiceForTest(env, &fakeBot{}, &discord.User{ID: "500", Username: "cypher"}) + + state := seedState(t, env, owner.ID) + if _, err := svc.HandleCallback(context.Background(), owner.ID, "code", state); err != nil { + t.Fatalf("owner link: %v", err) + } + + state2 := seedState(t, env, other.ID) + _, err := svc.HandleCallback(context.Background(), other.ID, "code", state2) + if !errors.Is(err, ErrDiscordAlreadyLinked) { + t.Fatalf("got %v, want ErrDiscordAlreadyLinked", err) + } + + if _, err := discordRepo.GetByUserID(context.Background(), other.ID); !errors.Is(err, repo.ErrNotFound) { + t.Fatalf("other user should have no connection, got %v", err) + } +} + +func TestDiscordUnlinkRemovesConnection(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "d7@example.com", "duser7", "pass", models.UserRole) + + bot := &fakeBot{} + svc, discordRepo := newDiscordServiceForTest(env, bot, &discord.User{ID: "700"}) + + state := seedState(t, env, user.ID) + if _, err := svc.HandleCallback(context.Background(), user.ID, "code", state); err != nil { + t.Fatalf("link: %v", err) + } + + if err := svc.Unlink(context.Background(), user.ID); err != nil { + t.Fatalf("Unlink: %v", err) + } + + if !bot.kicked { + t.Error("expected kick call") + } + + if _, err := discordRepo.GetByUserID(context.Background(), user.ID); !errors.Is(err, repo.ErrNotFound) { + t.Fatalf("connection should be gone, got %v", err) + } +} + +func TestDiscordGetConnectionNilWhenAbsent(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "d8@example.com", "duser8", "pass", models.UserRole) + + svc, _ := newDiscordServiceForTest(env, &fakeBot{}, &discord.User{ID: "800"}) + + conn, err := svc.GetConnection(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetConnection: %v", err) + } + + if conn != nil { + t.Fatalf("expected nil connection, got %+v", conn) + } +} + +func TestDiscordDisabledReturnsError(t *testing.T) { + env := setupServiceTest(t) + discordRepo := repo.NewDiscordRepo(env.db) + svc := NewDiscordService(config.DiscordConfig{Enabled: false}, discordRepo, &fakeBot{}, fakeOAuth{user: &discord.User{ID: "1"}}, env.redis) + + if _, err := svc.BeginConnect(context.Background(), 1); !errors.Is(err, ErrDiscordDisabled) { + t.Fatalf("got %v, want ErrDiscordDisabled", err) + } +} + +func seedState(t *testing.T, env serviceEnv, userID int64) string { + t.Helper() + state := "teststate-" + time.Now().Format("150405.000000000") + if err := env.redis.Set(context.Background(), stateKey(state), userID, time.Minute).Err(); err != nil { + t.Fatalf("seed state: %v", err) + } + return state +} diff --git a/internal/service/errors.go b/internal/service/errors.go index 3190f48..14affa2 100644 --- a/internal/service/errors.go +++ b/internal/service/errors.go @@ -19,6 +19,15 @@ var ( ErrVMNotFound = errors.New("vm not found") ErrVMOrchestratorDown = errors.New("vm orchestrator unavailable") ErrVMInvalidSpec = errors.New("vm spec invalid") + ErrDiscordDisabled = errors.New("discord feature disabled") + ErrDiscordNotConnected = errors.New("discord account not connected") + ErrDiscordStateInvalid = errors.New("discord oauth state invalid") + ErrDiscordExchangeFailed = errors.New("discord oauth exchange failed") + ErrDiscordAlreadyLinked = errors.New("discord account already linked to another user") + ErrDiscordNotInGuild = errors.New("discord user not in guild") + ErrDiscordRoleFailed = errors.New("discord role assignment failed") + ErrDiscordBotUnavailable = errors.New("discord bot server unavailable") + ErrDiscordRateLimited = errors.New("discord rate limited") ErrNotFound = errors.New("not found") ) diff --git a/invite-bot/.env.example b/invite-bot/.env.example new file mode 100644 index 0000000..88dfacc --- /dev/null +++ b/invite-bot/.env.example @@ -0,0 +1,16 @@ +# Discord bot server configuration. +# The bot owns all Discord secrets; the wargame backend never sees these. + +# HTTP server (internal API consumed by the wargame backend) +HTTP_ADDR=:8083 + +# Shared secret — MUST equal DISCORD_BOT_SECRET in the wargame backend .env +DISCORD_INTERNAL_SECRET=change-me + +# Discord application / bot +DISCORD_BOT_TOKEN= +DISCORD_GUILD_ID= +DISCORD_VERIFIED_ROLE_ID= + +# Optional +DISCORD_AUDIT_REASON=External site verification diff --git a/invite-bot/.gitignore b/invite-bot/.gitignore new file mode 100644 index 0000000..aa0926a --- /dev/null +++ b/invite-bot/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +*.log diff --git a/invite-bot/.prettierignore b/invite-bot/.prettierignore new file mode 100644 index 0000000..4959d72 --- /dev/null +++ b/invite-bot/.prettierignore @@ -0,0 +1,12 @@ +node_modules +dist +.next +build +coverage +*.lock +package-lock.json +yarn.lock +pnpm-lock.yaml +.env +.env.* +!.env.example \ No newline at end of file diff --git a/invite-bot/.prettierrc b/invite-bot/.prettierrc new file mode 100644 index 0000000..8ddc350 --- /dev/null +++ b/invite-bot/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "tabWidth": 4, + "printWidth": 240, + "jsxSingleQuote": true +} diff --git a/invite-bot/Dockerfile b/invite-bot/Dockerfile new file mode 100644 index 0000000..98a4fbc --- /dev/null +++ b/invite-bot/Dockerfile @@ -0,0 +1,19 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build && npm prune --omit=dev + +FROM node:22-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY package.json ./ +EXPOSE 8083 +USER node +CMD ["node", "dist/index.js"] diff --git a/invite-bot/README.md b/invite-bot/README.md new file mode 100644 index 0000000..5656751 --- /dev/null +++ b/invite-bot/README.md @@ -0,0 +1,20 @@ +All routes require `Authorization: Bearer `. + +| Method | Path | Body | Success | +| -------- | -------------------------------------- | --------------------------- | ---------------------------- | +| `PUT` | `/internal/guild/members/:userId` | `{ "access_token": "..." }` | `204` (join) | +| `PUT` | `/internal/guild/members/:userId/role` | – | `204` (grant role) | +| `DELETE` | `/internal/guild/members/:userId` | – | `204` (kick) | +| `GET` | `/internal/guild/members/:userId` | – | `200 { in_guild, has_role }` | +| `GET` | `/healthz` | – | `200` | + +Errors return `{ "error": "...", "code": "..." }` where `code` is one of `NOT_IN_GUILD`, `BOT_PERMISSION`, `RATE_LIMITED`, `INVALID`, `UPSTREAM`. + +```bash +npm install +cp .env.example .env + +npm run dev +npm run build +npm start +``` diff --git a/invite-bot/package-lock.json b/invite-bot/package-lock.json new file mode 100644 index 0000000..25f508f --- /dev/null +++ b/invite-bot/package-lock.json @@ -0,0 +1,1803 @@ +{ + "name": "wargame-invite-bot", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wargame-invite-bot", + "version": "0.1.0", + "dependencies": { + "discord.js": "^14.16.3", + "express": "^4.21.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", + "prettier": "^3.8.4", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=20.12" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.49", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz", + "integrity": "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.26.4", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz", + "integrity": "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/prettier": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/invite-bot/package.json b/invite-bot/package.json new file mode 100644 index 0000000..6141602 --- /dev/null +++ b/invite-bot/package.json @@ -0,0 +1,29 @@ +{ + "name": "wargame-invite-bot", + "version": "0.1.0", + "private": true, + "description": "Discord bot server for wargame account linking (role grant / guild join).", + "type": "module", + "engines": { + "node": ">=20.12" + }, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "tsc -p tsconfig.json --noEmit", + "format": "prettier --write ." + }, + "dependencies": { + "discord.js": "^14.16.3", + "express": "^4.21.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", + "prettier": "^3.8.4", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/invite-bot/src/config.ts b/invite-bot/src/config.ts new file mode 100644 index 0000000..102dc06 --- /dev/null +++ b/invite-bot/src/config.ts @@ -0,0 +1,61 @@ +export interface BotConfig { + httpAddr: string + internalSecret: string + botToken: string + guildId: string + verifiedRoleId: string + auditReason: string +} + +function required(name: string): string { + const value = process.env[name] + if (value === undefined || value.trim() === '') { + throw new Error(`missing required env: ${name}`) + } + + return value.trim() +} + +function optional(name: string, fallback: string): string { + const value = process.env[name] + if (value === undefined || value.trim() === '') { + return fallback + } + + return value.trim() +} + +export function loadDotenv(path = '.env'): void { + const loader = (process as unknown as { loadEnvFile?: (p: string) => void }).loadEnvFile + if (typeof loader !== 'function') { + return + } + + try { + loader(path) + } catch { + return + } +} + +export function loadConfig(): BotConfig { + return { + httpAddr: optional('HTTP_ADDR', ':8083'), + internalSecret: required('DISCORD_INTERNAL_SECRET'), + botToken: required('DISCORD_BOT_TOKEN'), + guildId: required('DISCORD_GUILD_ID'), + verifiedRoleId: required('DISCORD_VERIFIED_ROLE_ID'), + auditReason: optional('DISCORD_AUDIT_REASON', 'External site verification'), + } +} + +export function parsePort(httpAddr: string): number { + const idx = httpAddr.lastIndexOf(':') + const raw = idx >= 0 ? httpAddr.slice(idx + 1) : httpAddr + const port = Number.parseInt(raw, 10) + if (Number.isNaN(port) || port <= 0) { + throw new Error(`invalid HTTP_ADDR: ${httpAddr}`) + } + + return port +} diff --git a/invite-bot/src/discord/client.ts b/invite-bot/src/discord/client.ts new file mode 100644 index 0000000..c336cd2 --- /dev/null +++ b/invite-bot/src/discord/client.ts @@ -0,0 +1,105 @@ +import { Client, GatewayIntentBits, Guild, GuildMember, DiscordAPIError } from 'discord.js' + +import type { BotConfig } from '../config.js' +import { logger } from '../logger.js' +import { BotError, mapDiscordError } from './errors.js' + +const UNKNOWN_MEMBER = 10007 + +export interface MemberStatus { + in_guild: boolean + has_role: boolean +} + +export class DiscordBot { + private readonly client: Client + private guild: Guild | null = null + + constructor(private readonly cfg: BotConfig) { + this.client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers], + }) + } + + async start(): Promise { + await this.client.login(this.cfg.botToken) + this.guild = await this.client.guilds.fetch(this.cfg.guildId) + + this.client.on('guildMemberRemove', (member) => { + logger.info('guild member left', { discord_user_id: member.id }) + }) + + logger.info('discord bot ready', { guild_id: this.cfg.guildId }) + } + + async stop(): Promise { + await this.client.destroy() + } + + private requireGuild(): Guild { + if (!this.guild) { + throw new BotError(503, 'UPSTREAM', 'guild not ready') + } + return this.guild + } + + async joinGuild(userId: string, accessToken: string): Promise { + const guild = this.requireGuild() + try { + await guild.members.add(userId, { + accessToken, + roles: [this.cfg.verifiedRoleId], + }) + } catch (err) { + throw mapDiscordError(err) + } + } + + async grantRole(userId: string): Promise { + const member = await this.fetchMember(userId) + try { + await member.roles.add(this.cfg.verifiedRoleId, this.cfg.auditReason) + } catch (err) { + throw mapDiscordError(err) + } + } + + async kickMember(userId: string): Promise { + const guild = this.requireGuild() + try { + await guild.members.kick(userId, this.cfg.auditReason) + } catch (err) { + if (err instanceof DiscordAPIError && err.code === UNKNOWN_MEMBER) { + return + } + throw mapDiscordError(err) + } + } + + async memberStatus(userId: string): Promise { + try { + const member = await this.fetchMember(userId) + return { + in_guild: true, + has_role: member.roles.cache.has(this.cfg.verifiedRoleId), + } + } catch (err) { + if (err instanceof BotError && err.code === 'NOT_IN_GUILD') { + return { in_guild: false, has_role: false } + } + throw err + } + } + + private async fetchMember(userId: string): Promise { + const guild = this.requireGuild() + try { + return await guild.members.fetch(userId) + } catch (err) { + if (err instanceof DiscordAPIError && err.code === UNKNOWN_MEMBER) { + throw new BotError(404, 'NOT_IN_GUILD', 'user not in guild') + } + throw mapDiscordError(err) + } + } +} diff --git a/invite-bot/src/discord/errors.ts b/invite-bot/src/discord/errors.ts new file mode 100644 index 0000000..7166bc5 --- /dev/null +++ b/invite-bot/src/discord/errors.ts @@ -0,0 +1,44 @@ +export type BotErrorCode = 'NOT_IN_GUILD' | 'BOT_PERMISSION' | 'RATE_LIMITED' | 'INVALID' | 'UPSTREAM' + +export class BotError extends Error { + readonly status: number + readonly code: BotErrorCode + + constructor(status: number, code: BotErrorCode, message: string) { + super(message) + this.name = 'BotError' + this.status = status + this.code = code + } +} + +const UNKNOWN_MEMBER = 10007 +const UNKNOWN_USER = 10013 + +interface DiscordLikeError { + status?: number + code?: number | string + message?: string +} + +export function mapDiscordError(err: unknown): BotError { + const e = err as DiscordLikeError + const message = e?.message ?? 'discord api error' + + if (e?.code === UNKNOWN_MEMBER || e?.code === UNKNOWN_USER) { + return new BotError(404, 'NOT_IN_GUILD', message) + } + + switch (e?.status) { + case 404: + return new BotError(404, 'NOT_IN_GUILD', message) + case 403: + return new BotError(403, 'BOT_PERMISSION', message) + case 429: + return new BotError(429, 'RATE_LIMITED', message) + case 400: + return new BotError(400, 'INVALID', message) + default: + return new BotError(502, 'UPSTREAM', message) + } +} diff --git a/invite-bot/src/http/server.ts b/invite-bot/src/http/server.ts new file mode 100644 index 0000000..901d430 --- /dev/null +++ b/invite-bot/src/http/server.ts @@ -0,0 +1,113 @@ +import express, { type Express, type NextFunction, type Request, type Response } from 'express' + +import type { BotConfig } from '../config.js' +import type { DiscordBot } from '../discord/client.js' +import { BotError } from '../discord/errors.js' +import { logger } from '../logger.js' + +function secretsMatch(a: string, b: string): boolean { + if (a.length !== b.length) { + return false + } + + let diff = 0 + for (let i = 0; i < a.length; i += 1) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i) + } + + return diff === 0 +} + +function requireSecret(secret: string) { + return (req: Request, res: Response, next: NextFunction): void => { + const header = req.header('authorization') ?? '' + const expected = `Bearer ${secret}` + if (!secretsMatch(header, expected)) { + res.status(401).json({ error: 'unauthorized', code: 'INVALID' }) + return + } + next() + } +} + +function pathUserId(req: Request): string { + const raw = req.params.userId + const value = Array.isArray(raw) ? raw[0] : raw + if (typeof value !== 'string' || !/^\d{1,32}$/.test(value)) { + throw new BotError(400, 'INVALID', 'invalid user id') + } + + return value +} + +function asyncRoute(fn: (req: Request, res: Response) => Promise): (req: Request, res: Response) => void { + return (req, res) => { + fn(req, res).catch((err: unknown) => { + if (err instanceof BotError) { + res.status(err.status).json({ error: err.message, code: err.code }) + return + } + + logger.error('unhandled route error', { + path: req.path, + error: err instanceof Error ? err.message : String(err), + }) + + res.status(500).json({ error: 'internal error', code: 'UPSTREAM' }) + }) + } +} + +export function buildServer(cfg: BotConfig, bot: DiscordBot): Express { + const app = express() + app.use(express.json()) + + app.get('/healthz', (_req, res) => { + res.json({ status: 'ok' }) + }) + + const internal = express.Router() + internal.use(requireSecret(cfg.internalSecret)) + + internal.put( + '/guild/members/:userId', + asyncRoute(async (req, res) => { + const accessToken = (req.body?.access_token as string | undefined) ?? '' + if (accessToken.trim() === '') { + res.status(400).json({ error: 'access_token required', code: 'INVALID' }) + return + } + + await bot.joinGuild(pathUserId(req), accessToken) + res.status(204).end() + }), + ) + + internal.put( + '/guild/members/:userId/role', + asyncRoute(async (req, res) => { + await bot.grantRole(pathUserId(req)) + res.status(204).end() + }), + ) + + internal.delete( + '/guild/members/:userId', + asyncRoute(async (req, res) => { + await bot.kickMember(pathUserId(req)) + res.status(204).end() + }), + ) + + internal.get( + '/guild/members/:userId', + asyncRoute(async (req, res) => { + const status = await bot.memberStatus(pathUserId(req)) + res.json(status) + }), + ) + + app.use('/internal', internal) + + return app +} diff --git a/invite-bot/src/index.ts b/invite-bot/src/index.ts new file mode 100644 index 0000000..2d33eb4 --- /dev/null +++ b/invite-bot/src/index.ts @@ -0,0 +1,38 @@ +import type { Server } from 'node:http' + +import { loadConfig, loadDotenv, parsePort } from './config.js' +import { DiscordBot } from './discord/client.js' +import { buildServer } from './http/server.js' +import { logger } from './logger.js' + +async function main(): Promise { + loadDotenv() + const cfg = loadConfig() + const port = parsePort(cfg.httpAddr) + + const bot = new DiscordBot(cfg) + await bot.start() + + const app = buildServer(cfg, bot) + const server: Server = app.listen(port, () => { + logger.info('discord bot http server listening', { port }) + }) + + const shutdown = (signal: string) => { + logger.info('shutting down', { signal }) + server.close(() => { + void bot.stop().finally(() => process.exit(0)) + }) + } + + process.on('SIGTERM', () => shutdown('SIGTERM')) + process.on('SIGINT', () => shutdown('SIGINT')) +} + +main().catch((err: unknown) => { + logger.error('fatal startup error', { + error: err instanceof Error ? err.message : String(err), + }) + + process.exit(1) +}) diff --git a/invite-bot/src/logger.ts b/invite-bot/src/logger.ts new file mode 100644 index 0000000..379a77e --- /dev/null +++ b/invite-bot/src/logger.ts @@ -0,0 +1,23 @@ +type Fields = Record + +function emit(level: string, msg: string, fields?: Fields): void { + const entry = { + time: new Date().toISOString(), + level, + msg, + ...fields, + } + const line = JSON.stringify(entry) + + if (level === 'error') { + console.error(line) + } else { + console.log(line) + } +} + +export const logger = { + info: (msg: string, fields?: Fields) => emit('info', msg, fields), + warn: (msg: string, fields?: Fields) => emit('warn', msg, fields), + error: (msg: string, fields?: Fields) => emit('error', msg, fields), +} diff --git a/invite-bot/tsconfig.json b/invite-bot/tsconfig.json new file mode 100644 index 0000000..26b12f3 --- /dev/null +++ b/invite-bot/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"] +} diff --git a/migrations/2026-06-24/001_add_discord_connections.sql b/migrations/2026-06-24/001_add_discord_connections.sql new file mode 100644 index 0000000..17deec5 --- /dev/null +++ b/migrations/2026-06-24/001_add_discord_connections.sql @@ -0,0 +1,23 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS discord_connections ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + discord_user_id VARCHAR(32) NOT NULL, + discord_username VARCHAR(100) NULL, + discord_global_name VARCHAR(100) NULL, + discord_avatar VARCHAR(255) NULL, + role_status VARCHAR(30) NOT NULL DEFAULT 'CONNECTED', + connected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + verified_at TIMESTAMPTZ NULL, + revoked_at TIMESTAMPTZ NULL, + last_synced_at TIMESTAMPTZ NULL, + last_error TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_discord_connections_user ON discord_connections (user_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_discord_connections_discord_user ON discord_connections (discord_user_id); + +COMMIT; diff --git a/migrations/2026-06-24/999_rollback.sql b/migrations/2026-06-24/999_rollback.sql new file mode 100644 index 0000000..295ef66 --- /dev/null +++ b/migrations/2026-06-24/999_rollback.sql @@ -0,0 +1,5 @@ +BEGIN; + +DROP TABLE IF EXISTS discord_connections; + +COMMIT; diff --git a/scripts/sync-invite-bot.sh b/scripts/sync-invite-bot.sh new file mode 100755 index 0000000..c6d259a --- /dev/null +++ b/scripts/sync-invite-bot.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Sync the invite-bot/ subtree from the wargame repository. +# +# The canonical invite-bot source lives in the wargame repo under invite-bot/. +# This script re-splits that directory into a temporary branch and pulls it into +# this repo's invite-bot/ subtree, so changes made in wargame flow into smctf. +# +# Usage: +# scripts/sync-invite-bot.sh +# WARGAME_REPO=/path/to/wargame scripts/sync-invite-bot.sh +set -euo pipefail + +WARGAME_REPO="${WARGAME_REPO:-/Users/projects/wargame}" +PREFIX="invite-bot" +SPLIT_BRANCH="invitebot-subtree" + +if [ ! -d "${WARGAME_REPO}/.git" ]; then + echo "wargame repo not found at ${WARGAME_REPO} (set WARGAME_REPO=...)" >&2 + exit 1 +fi + +# 1) (Re)create a split whose root is the invite-bot tree. +git -C "${WARGAME_REPO}" branch -D "${SPLIT_BRANCH}" >/dev/null 2>&1 || true +git -C "${WARGAME_REPO}" subtree split --prefix="${PREFIX}" -b "${SPLIT_BRANCH}" + +# 2) Pull it into this repo's invite-bot/ subtree. +git subtree pull --prefix="${PREFIX}" "${WARGAME_REPO}" "${SPLIT_BRANCH}" \ + -m "chore: sync invite-bot from wargame" + +# 3) Clean up the transient split branch. +git -C "${WARGAME_REPO}" branch -D "${SPLIT_BRANCH}" >/dev/null 2>&1 || true + +echo "invite-bot synced from ${WARGAME_REPO}"