ctfy.server.models
Public wire-model package — re-exports for back-compat.
1"""Public wire-model package — re-exports for back-compat.""" 2 3from __future__ import annotations 4 5from ctfy.server.models.achievement import ( 6 AchievementCatalogEntry, 7 AchievementProgress, 8 AchievementSummary, 9 EasterEggClaim, 10 MyAchievementsResponse, 11 RecentUnlock, 12 StarGazerVerifyResponse, 13 TeamAchievement, 14) 15from ctfy.server.models.activity import ( 16 Activity, 17 ActivityTimeseries, 18 ActivityTimeseriesBucket, 19) 20from ctfy.server.models.admin import ( 21 AdminChallengeLastError, 22 AdminChallengeLatencyBucket, 23 AdminChallengeStatsRow, 24 AdminChallengeTimeseries, 25 AdminChallengeTimeseriesBucket, 26 AdminHealthFlags, 27 AdminOverview, 28 AdminOverviewCounts, 29 AdminRecentError, 30 AdminSilentChallenge, 31 AdminSolveCell, 32 AdminSolveMatrix, 33 AdminSolveMatrixChallenge, 34 AdminSolveMatrixTeam, 35 AdminStuckInstance, 36 AdminTaskInfo, 37 AdminTaskListResponse, 38 AdminTaskLogLine, 39 AdminTaskLogsResponse, 40 AdminTaskSubmitRequest, 41 AdminTimeseries, 42 AdminTimeseriesBucket, 43 AdminTrafficInstanceRow, 44 AdminTrafficSummary, 45 AdminTrafficTeamRow, 46 AdminUnhealthyNode, 47 DeploymentConfig, 48 LlmBudgetPage, 49 LlmBudgetResetInfo, 50 LlmBudgetResetRequest, 51 LlmBudgetRow, 52 PlatformSettingInfo, 53 PlatformSettingPatch, 54 QuestionAttemptResetInfo, 55 QuestionAttemptResetRequest, 56 ScheduledJobInfo, 57 ScheduledJobPatch, 58 ShellRecordingInfo, 59) 60from ctfy.server.models.announcement import ( 61 AnnouncementCreate, 62 AnnouncementInfo, 63 AnnouncementUpdate, 64) 65from ctfy.server.models.auth import ( 66 AuthTokenResponse, 67 CreateFineGrainedTokenRequest, 68 CreateFineGrainedTokenResponse, 69 DeviceApproveRequest, 70 DeviceCodeResponse, 71 DeviceInfoResponse, 72 DeviceTokenRequest, 73 DeviceTokenResponse, 74 EmailCodeAuthInfo, 75 LinkedIdentity, 76 LinkStartResponse, 77 LoginRequest, 78 OAuthProviderInfo, 79 PasswordAuthInfo, 80 ProvidersResponse, 81 RegisterRequest, 82 ScopeCatalogEntry, 83 SetPasswordRequest, 84 TokenInfo, 85 TokenScopesResponse, 86) 87from ctfy.server.models.awd import ( 88 MAX_FLAGS_PER_SUBMISSION, 89 AwdBoardEntry, 90 AwdBoardResponse, 91 AwdFlagResult, 92 AwdFlagSubmitRequest, 93 AwdFlagSubmitResponse, 94 AwdMatchConfig, 95 AwdMatchInfo, 96 AwdMatchStatus, 97 AwdProvisionPlanInfo, 98) 99from ctfy.server.models.challenge import ( 100 AdminPatchSubmissionDetail, 101 AdminPatchSubmissionInfo, 102 AdminPatchSubmissionPage, 103 AttachmentList, 104 ChallengeBuildKickoffNodeResult, 105 ChallengeBuildKickoffResponse, 106 ChallengeBuildNodeState, 107 ChallengeBuildStateResponse, 108 ChallengeBuildStateRow, 109 ChallengeFacetCount, 110 ChallengeFacets, 111 ChallengeFlagStats, 112 ChallengeInfo, 113 ChallengePullKickoffNodeResult, 114 ChallengePullKickoffResponse, 115 ChallengePullNodeState, 116 ChallengePullStateResponse, 117 ChallengePullStateRow, 118 ChallengeRescanNodeResult, 119 ChallengeRescanResult, 120 ChallengeSolveAttempt, 121 ChallengeSolveAttemptsResponse, 122 ChallengeStats, 123 ChallengeTeamSolveSummary, 124 CompetitionChallengeBreakdown, 125 CompetitionChallengeRow, 126 CompetitionScoreDistribution, 127 CompetitionScoreHistory, 128 DefenceInfo, 129 DefenceSource, 130 MilestoneProgress, 131 MySolveSummary, 132 PatchSubmissionCreate, 133 PatchSubmissionInfo, 134 QaChallengeView, 135 QaSubmissionCreate, 136 QuestionPublicInfo, 137 ScoreboardEntry, 138 ScoreBucket, 139 ScoreHistoryPoint, 140 ScoreHistorySeries, 141 SubmissionCreate, 142 SubmissionResponse, 143 UserScoreboardEntry, 144) 145from ctfy.server.models.competition import ( 146 BulkInviteRequest, 147 BulkInviteResponse, 148 BulkInviteResultRow, 149 CompetitionAdminInfo, 150 CompetitionCreate, 151 CompetitionDetail, 152 CompetitionInfo, 153 CompetitionInviteInfo, 154 CompetitionMembershipInfo, 155 CompetitionRegistrationInfo, 156 CompetitionSessionInfo, 157 CompetitionUpdate, 158 GrantCompetitionAdminRequest, 159 VirtualGhostBoard, 160 VirtualGhostRow, 161) 162from ctfy.server.models.eval import ( 163 EvalLeaderboard, 164 EvalTrendPoint, 165 HarnessModelCell, 166 HarnessStanding, 167 LeaderboardCell, 168 ModelStanding, 169 VendorStandardSet, 170 VendorStanding, 171) 172from ctfy.server.models.feedback import ( 173 AdminFeedbackRow, 174 FeedbackStats, 175 MyReactionsResponse, 176) 177from ctfy.server.models.instance import ( 178 AnswerTraceHit, 179 AnswerTraceRequest, 180 AnswerTraceResponse, 181 CheckAnswerRequest, 182 CheckAnswerResponse, 183 InstanceInfo, 184 InstanceQuestionInfo, 185 InstanceRecordArtifacts, 186 InstanceRecordDetail, 187 InstanceRecordInfo, 188 InstanceRecordInfoDetail, 189 InstanceStatusResponse, 190 PlayerShellCreate, 191 PlayerShellTicket, 192 RenewResponse, 193 SshCredential, 194 SshExchangeRequest, 195 StartRequest, 196 StartResponse, 197 StopResponse, 198 VerifyAnswerRequest, 199 VerifyAnswerResponse, 200) 201from ctfy.server.models.meta import ( 202 ErrorResponse, 203 HealthResponse, 204 MetaChallenges, 205 MetaPlatform, 206 MetaResponse, 207) 208from ctfy.server.models.node import ( 209 ClusterInfo, 210 CreateInviteRequest, 211 CreateInviteResponse, 212 NodeHeartbeat, 213 NodeInfo, 214 NodeInviteInfo, 215 NodePatch, 216 NodeRegister, 217 NodeRegisterResponse, 218) 219from ctfy.server.models.notification import ( 220 EmailSuppressionCreate, 221 EmailSuppressionInfo, 222 EmailSuppressionListResponse, 223 EmailTemplateInfo, 224 EmailTemplateListResponse, 225 NotificationCategoryInfo, 226 NotificationChannelCell, 227 NotificationPreferencesResponse, 228 NotificationPreferenceUpdate, 229 TestEmailRequest, 230 TestEmailResult, 231) 232from ctfy.server.models.pagination import BigLimitOffsetPage 233from ctfy.server.models.registration import ( 234 MyRegistrationResponse, 235 RegistrationFieldSpec, 236 RegistrationFormSpec, 237 RegistrationRosterRow, 238 RegistrationSubmitRequest, 239 RegistrationSummary, 240 RegistrationTeamRow, 241 ReviewDecisionRequest, 242 TeamLogoUploadResponse, 243) 244from ctfy.server.models.series import ( 245 CompetitionSeriesConfig, 246 CompetitionSeriesInfo, 247 SeriesOccurrencePreview, 248 SeriesPreview, 249) 250from ctfy.server.models.team import ( 251 AdminTeamRow, 252 CompetitionRoster, 253 CompetitionRosterTeam, 254 InboxAnnouncement, 255 InboxCaptainRequest, 256 InboxCompetitionInvite, 257 InboxIncomingInvite, 258 InboxOutgoingRequest, 259 InboxResponse, 260 TeamCaptainRequest, 261 TeamDetail, 262 TeamDisqualifyRequest, 263 TeamInfo, 264 TeamInviteInfo, 265 TeamMemberInfo, 266 TeamStandingInfo, 267) 268from ctfy.server.models.user import ( 269 PROFILE_VISIBILITY_KEYS, 270 AdminUserInfo, 271 CalendarBucket, 272 DeleteMeRequest, 273 DifficultyStat, 274 MeResponse, 275 ProfilePatchRequest, 276 ProfileStats, 277 TagStat, 278 TrendPoint, 279 UpdateUserRoleRequest, 280 UserInfo, 281 UserSolveTrend, 282) 283 284__all__ = [ 285 "MAX_FLAGS_PER_SUBMISSION", 286 "PROFILE_VISIBILITY_KEYS", 287 "AchievementCatalogEntry", 288 "AchievementProgress", 289 "AchievementSummary", 290 "Activity", 291 "ActivityTimeseries", 292 "ActivityTimeseriesBucket", 293 "AdminChallengeLastError", 294 "AdminChallengeLatencyBucket", 295 "AdminChallengeStatsRow", 296 "AdminChallengeTimeseries", 297 "AdminChallengeTimeseriesBucket", 298 "AdminFeedbackRow", 299 "AdminHealthFlags", 300 "AdminOverview", 301 "AdminOverviewCounts", 302 "AdminPatchSubmissionDetail", 303 "AdminPatchSubmissionInfo", 304 "AdminPatchSubmissionPage", 305 "AdminRecentError", 306 "AdminSilentChallenge", 307 "AdminSolveCell", 308 "AdminSolveMatrix", 309 "AdminSolveMatrixChallenge", 310 "AdminSolveMatrixTeam", 311 "AdminStuckInstance", 312 "AdminTaskInfo", 313 "AdminTaskListResponse", 314 "AdminTaskLogLine", 315 "AdminTaskLogsResponse", 316 "AdminTaskSubmitRequest", 317 "AdminTeamRow", 318 "AdminTimeseries", 319 "AdminTimeseriesBucket", 320 "AdminTrafficInstanceRow", 321 "AdminTrafficSummary", 322 "AdminTrafficTeamRow", 323 "AdminUnhealthyNode", 324 "AdminUserInfo", 325 "AnnouncementCreate", 326 "AnnouncementInfo", 327 "AnnouncementUpdate", 328 "AnswerTraceHit", 329 "AnswerTraceRequest", 330 "AnswerTraceResponse", 331 "AttachmentList", 332 "AuthTokenResponse", 333 "AwdBoardEntry", 334 "AwdBoardResponse", 335 "AwdFlagResult", 336 "AwdFlagSubmitRequest", 337 "AwdFlagSubmitResponse", 338 "AwdMatchConfig", 339 "AwdMatchInfo", 340 "AwdMatchStatus", 341 "AwdProvisionPlanInfo", 342 "BigLimitOffsetPage", 343 "BulkInviteRequest", 344 "BulkInviteResponse", 345 "BulkInviteResultRow", 346 "CalendarBucket", 347 "ChallengeBuildKickoffNodeResult", 348 "ChallengeBuildKickoffResponse", 349 "ChallengeBuildNodeState", 350 "ChallengeBuildStateResponse", 351 "ChallengeBuildStateRow", 352 "ChallengeFacetCount", 353 "ChallengeFacets", 354 "ChallengeFlagStats", 355 "ChallengeInfo", 356 "ChallengePullKickoffNodeResult", 357 "ChallengePullKickoffResponse", 358 "ChallengePullNodeState", 359 "ChallengePullStateResponse", 360 "ChallengePullStateRow", 361 "ChallengeRescanNodeResult", 362 "ChallengeRescanResult", 363 "ChallengeSolveAttempt", 364 "ChallengeSolveAttemptsResponse", 365 "ChallengeStats", 366 "ChallengeTeamSolveSummary", 367 "CheckAnswerRequest", 368 "CheckAnswerResponse", 369 "ClusterInfo", 370 "CompetitionAdminInfo", 371 "CompetitionChallengeBreakdown", 372 "CompetitionChallengeRow", 373 "CompetitionCreate", 374 "CompetitionDetail", 375 "CompetitionInfo", 376 "CompetitionInviteInfo", 377 "CompetitionMembershipInfo", 378 "CompetitionRegistrationInfo", 379 "CompetitionRoster", 380 "CompetitionRosterTeam", 381 "CompetitionScoreDistribution", 382 "CompetitionScoreHistory", 383 "CompetitionSeriesConfig", 384 "CompetitionSeriesInfo", 385 "CompetitionSessionInfo", 386 "CompetitionUpdate", 387 "CreateFineGrainedTokenRequest", 388 "CreateFineGrainedTokenResponse", 389 "CreateInviteRequest", 390 "CreateInviteResponse", 391 "DefenceInfo", 392 "DefenceSource", 393 "DeleteMeRequest", 394 "DeploymentConfig", 395 "DeviceApproveRequest", 396 "DeviceCodeResponse", 397 "DeviceInfoResponse", 398 "DeviceTokenRequest", 399 "DeviceTokenResponse", 400 "DifficultyStat", 401 "EasterEggClaim", 402 "EmailCodeAuthInfo", 403 "EmailSuppressionCreate", 404 "EmailSuppressionInfo", 405 "EmailSuppressionListResponse", 406 "EmailTemplateInfo", 407 "EmailTemplateListResponse", 408 "ErrorResponse", 409 "EvalLeaderboard", 410 "EvalTrendPoint", 411 "FeedbackStats", 412 "GrantCompetitionAdminRequest", 413 "HarnessModelCell", 414 "HarnessStanding", 415 "HealthResponse", 416 "InboxAnnouncement", 417 "InboxCaptainRequest", 418 "InboxCompetitionInvite", 419 "InboxIncomingInvite", 420 "InboxOutgoingRequest", 421 "InboxResponse", 422 "InstanceInfo", 423 "InstanceQuestionInfo", 424 "InstanceRecordArtifacts", 425 "InstanceRecordDetail", 426 "InstanceRecordInfo", 427 "InstanceRecordInfoDetail", 428 "InstanceStatusResponse", 429 "LeaderboardCell", 430 "LinkStartResponse", 431 "LinkedIdentity", 432 "LlmBudgetPage", 433 "LlmBudgetResetInfo", 434 "LlmBudgetResetRequest", 435 "LlmBudgetRow", 436 "LoginRequest", 437 "MeResponse", 438 "MetaChallenges", 439 "MetaPlatform", 440 "MetaResponse", 441 "MilestoneProgress", 442 "ModelStanding", 443 "MyAchievementsResponse", 444 "MyReactionsResponse", 445 "MyRegistrationResponse", 446 "MySolveSummary", 447 "NodeHeartbeat", 448 "NodeInfo", 449 "NodeInviteInfo", 450 "NodePatch", 451 "NodeRegister", 452 "NodeRegisterResponse", 453 "NotificationCategoryInfo", 454 "NotificationChannelCell", 455 "NotificationPreferenceUpdate", 456 "NotificationPreferencesResponse", 457 "OAuthProviderInfo", 458 "PasswordAuthInfo", 459 "PatchSubmissionCreate", 460 "PatchSubmissionInfo", 461 "PlatformSettingInfo", 462 "PlatformSettingPatch", 463 "PlayerShellCreate", 464 "PlayerShellTicket", 465 "ProfilePatchRequest", 466 "ProfileStats", 467 "ProvidersResponse", 468 "QaChallengeView", 469 "QaSubmissionCreate", 470 "QuestionAttemptResetInfo", 471 "QuestionAttemptResetRequest", 472 "QuestionPublicInfo", 473 "RecentUnlock", 474 "RegisterRequest", 475 "RegistrationFieldSpec", 476 "RegistrationFormSpec", 477 "RegistrationRosterRow", 478 "RegistrationSubmitRequest", 479 "RegistrationSummary", 480 "RegistrationTeamRow", 481 "RenewResponse", 482 "ReviewDecisionRequest", 483 "ScheduledJobInfo", 484 "ScheduledJobPatch", 485 "ScopeCatalogEntry", 486 "ScoreBucket", 487 "ScoreHistoryPoint", 488 "ScoreHistorySeries", 489 "ScoreboardEntry", 490 "SeriesOccurrencePreview", 491 "SeriesPreview", 492 "SetPasswordRequest", 493 "ShellRecordingInfo", 494 "SshCredential", 495 "SshExchangeRequest", 496 "StarGazerVerifyResponse", 497 "StartRequest", 498 "StartResponse", 499 "StopResponse", 500 "SubmissionCreate", 501 "SubmissionResponse", 502 "TagStat", 503 "TeamAchievement", 504 "TeamCaptainRequest", 505 "TeamDetail", 506 "TeamDisqualifyRequest", 507 "TeamInfo", 508 "TeamInviteInfo", 509 "TeamLogoUploadResponse", 510 "TeamMemberInfo", 511 "TeamStandingInfo", 512 "TestEmailRequest", 513 "TestEmailResult", 514 "TokenInfo", 515 "TokenScopesResponse", 516 "TrendPoint", 517 "UpdateUserRoleRequest", 518 "UserInfo", 519 "UserScoreboardEntry", 520 "UserSolveTrend", 521 "VendorStandardSet", 522 "VendorStanding", 523 "VerifyAnswerRequest", 524 "VerifyAnswerResponse", 525 "VirtualGhostBoard", 526 "VirtualGhostRow", 527]
28class AchievementCatalogEntry(CtfyModel): 29 """One badge as advertised to the frontend.""" 30 31 id: str 32 name: str 33 description: str 34 icon: str 35 tier: str 36 secret: bool = False 37 # Only populated for locked entries on /me/achievements — the 38 # general catalog endpoint and unlocked entries don't carry it. 39 progress: AchievementProgress | None = None 40 # Score weight per tier (bronze=10, silver=25, gold=50, secret=100). 41 points: int = 0 42 # Number of distinct teams that have unlocked this badge. 43 earned_by_count: int = 0 44 # ``common`` | ``uncommon`` | ``rare`` | ``epic`` | ``mythic`` | 45 # ``unearned``. 46 rarity: str = "unearned"
One badge as advertised to the frontend.
14class AchievementProgress(CtfyModel): 15 """Quantifiable progress toward a locked badge. 16 17 ``current`` is what the team has so far, ``target`` is the 18 threshold the rule predicate checks against. Provided only when 19 the achievement has a registered progress provider (decathlete, 20 centurion, completionist, sisyphus, unicorn). Time-of-day / 21 first-blood / easter-egg badges return ``progress = null``. 22 """ 23 24 current: int 25 target: int
Quantifiable progress toward a locked badge.
current is what the team has so far, target is the
threshold the rule predicate checks against. Provided only when
the achievement has a registered progress provider (decathlete,
centurion, completionist, sisyphus, unicorn). Time-of-day /
first-blood / easter-egg badges return progress = null.
65class AchievementSummary(CtfyModel): 66 """Aggregate roll-up for the Achievements page header.""" 67 68 unlocked_count: int = 0 69 total_count: int = 0 70 score: int = 0 71 total_score: int = 0
Aggregate roll-up for the Achievements page header.
18class Activity(CtfyModel): 19 """A single activity event in the platform log.""" 20 21 id: str = "" 22 timestamp: datetime | None = None 23 event: str # See PlatformEvent enum for valid values 24 team_id: str = "" 25 team_name: str = "" 26 challenge_id: str = "" 27 # Who triggered this event — surfaced on the public feed so admins 28 # can filter by actor and auditors can trace back. Values mirror 29 # the ActivityState fields in core.state.models. 30 actor_id: str = "" 31 actor_name: str = "" 32 actor_type: str = "" 33 # Denormalised actor avatar (GitHub/Google), surfaced on the feed so 34 # each event can render the actor's real face. Empty ⇒ the frontend 35 # renders a generated initials chip instead. 36 actor_avatar_url: str = "" 37 detail: ActivityDetail = Field(default_factory=ActivityDetail)
A single activity event in the platform log.
55class ActivityTimeseries(CtfyModel): 56 """Bucketed activity counts for the per-team activity histogram. 57 58 Same ``ts`` semantics as ``AdminTimeseries`` — oldest-first, right 59 edge anchored to "now". ``events`` is the sorted union of event 60 types that appear in any bucket, so the frontend has a stable list 61 of stack segments to render even when individual buckets are empty. 62 """ 63 64 window_s: int = 0 65 bucket_s: int = 0 66 events: list[str] = Field(default_factory=list) 67 buckets: list[ActivityTimeseriesBucket] = Field(default_factory=list)
Bucketed activity counts for the per-team activity histogram.
Same ts semantics as AdminTimeseries — oldest-first, right
edge anchored to "now". events is the sorted union of event
types that appear in any bucket, so the frontend has a stable list
of stack segments to render even when individual buckets are empty.
40class ActivityTimeseriesBucket(CtfyModel): 41 """One bucket of the team's activity histogram. 42 43 ``counts`` is keyed by event type (``flag_correct``, ``instance_started``, 44 …) so the frontend can stack each event as its own bar segment without 45 a follow-up shape-change to add new event types. 46 47 See :class:`AdminTimeseriesBucket` for the ``partial`` semantics. 48 """ 49 50 ts: float = 0.0 # right edge, Unix seconds 51 counts: dict[str, int] = Field(default_factory=dict) 52 partial: bool = False
One bucket of the team's activity histogram.
counts is keyed by event type (flag_correct, instance_started,
…) so the frontend can stack each event as its own bar segment without
a follow-up shape-change to add new event types.
See AdminTimeseriesBucket for the partial semantics.
69class AdminChallengeLastError(CtfyModel): 70 """Most-recent error against a challenge, surfaced on the per-challenge 71 admin row so an admin can spot a broken challenge without opening 72 instance history.""" 73 74 ts: float = 0.0 # stopped_at of the failing instance 75 message: str = "" 76 instance_id: str = ""
Most-recent error against a challenge, surfaced on the per-challenge admin row so an admin can spot a broken challenge without opening instance history.
111class AdminChallengeLatencyBucket(CtfyModel): 112 """One fixed bucket of the cluster-wide launch-duration histogram. 113 114 The Challenges admin table paginates server-side, so the pooled 115 "how slow are launches overall" picture can't be re-derived in the 116 browser from one page of rows — this endpoint pools every 117 challenge's recent successful launches and bins them with the same 118 absolute edges the per-row drawer uses, so cross-page the shape 119 stays comparable. 120 """ 121 122 label: str 123 count: int = 0
One fixed bucket of the cluster-wide launch-duration histogram.
The Challenges admin table paginates server-side, so the pooled "how slow are launches overall" picture can't be re-derived in the browser from one page of rows — this endpoint pools every challenge's recent successful launches and bins them with the same absolute edges the per-row drawer uses, so cross-page the shape stays comparable.
79class AdminChallengeStatsRow(CtfyModel): 80 """One row of the per-challenge admin dashboard. 81 82 Aggregates over ``InstanceRecord`` (lifetime) plus live ``InstanceState`` 83 (running). Launch-duration percentiles are computed only over rows with 84 both ``requested_at`` and ``ready_at`` populated — error rows that never 85 reached READY contribute to ``errors`` / ``error_rate`` but not to p50/p95. 86 """ 87 88 challenge_id: str 89 name: str = "" 90 difficulty: str = "" 91 # Spec metadata folded in so the admin table no longer has to join 92 # a second /challenges bulk fetch in the browser to show / sort / 93 # filter by these (it paginates server-side now). 94 category: str = "" 95 tags: list[str] = Field(default_factory=list) 96 question_count: int = 0 97 launches: int = 0 98 errors: int = 0 99 error_rate: float = 0.0 100 avg_launch_s: float = 0.0 101 p50_launch_s: float = 0.0 102 p95_launch_s: float = 0.0 103 solve_rate: float = 0.0 104 running_count: int = 0 105 last_error: AdminChallengeLastError | None = None 106 # Newest-first launch durations, capped at 20, used for the inline 107 # sparkline. Recorded only for successful launches. 108 recent_launch_durations: list[float] = Field(default_factory=list)
One row of the per-challenge admin dashboard.
Aggregates over InstanceRecord (lifetime) plus live InstanceState
(running). Launch-duration percentiles are computed only over rows with
both requested_at and ready_at populated — error rows that never
reached READY contribute to errors / error_rate but not to p50/p95.
225class AdminChallengeTimeseries(CtfyModel): 226 """Per-challenge bucketed health metrics for the admin drilldown. 227 228 Backs the row-level drawer on /admin/challenges that surfaces "did 229 this challenge's error rate spike at some specific time" without 230 forcing the operator to scrub through raw activity. 231 """ 232 233 challenge_id: str = "" 234 window_s: int = 0 235 bucket_s: int = 0 236 buckets: list[AdminChallengeTimeseriesBucket] = Field(default_factory=list)
Per-challenge bucketed health metrics for the admin drilldown.
Backs the row-level drawer on /admin/challenges that surfaces "did this challenge's error rate spike at some specific time" without forcing the operator to scrub through raw activity.
206class AdminChallengeTimeseriesBucket(CtfyModel): 207 """One bucket of the per-challenge health time-series. 208 209 Carries enough fields for the admin UI to render error-rate **and** 210 launch-duration trends from a single response — no need for the 211 frontend to issue three calls or do its own bucketing. 212 213 See :class:`AdminTimeseriesBucket` for the ``partial`` semantics. 214 """ 215 216 ts: float = 0.0 # right edge, Unix seconds 217 launches: int = 0 218 errors: int = 0 219 error_rate: float = 0.0 # errors / launches; 0 when no launches 220 p50_launch_s: float = 0.0 221 p95_launch_s: float = 0.0 222 partial: bool = False
One bucket of the per-challenge health time-series.
Carries enough fields for the admin UI to render error-rate and launch-duration trends from a single response — no need for the frontend to issue three calls or do its own bucketing.
See AdminTimeseriesBucket for the partial semantics.
43class AdminFeedbackRow(CtfyModel): 44 """One row on the admin feedback audit list. 45 46 Mirrors ``SolveFeedbackState`` plus denormalised user identity for 47 triage convenience. Only ``require_admin``-gated endpoints expose 48 this shape; the public ``stats`` endpoint never includes per-user 49 fields. 50 """ 51 52 user_id: str 53 user_display_name: str = "" 54 user_email: str = "" 55 challenge_id: str 56 team_id: str 57 competition_id: str 58 reaction: Reaction 59 created_at: datetime | None = None 60 updated_at: datetime | None = None
One row on the admin feedback audit list.
Mirrors SolveFeedbackState plus denormalised user identity for
triage convenience. Only require_admin-gated endpoints expose
this shape; the public stats endpoint never includes per-user
fields.
167class AdminHealthFlags(CtfyModel): 168 """Aggregated red-flag panels for the admin Overview "needs attention" 169 section. All four lists are independently populated and may be empty.""" 170 171 stuck_starting: list[AdminStuckInstance] = Field(default_factory=list) 172 recent_errors: list[AdminRecentError] = Field(default_factory=list) 173 unhealthy_nodes: list[AdminUnhealthyNode] = Field(default_factory=list) 174 silent_challenges: list[AdminSilentChallenge] = Field(default_factory=list)
Aggregated red-flag panels for the admin Overview "needs attention" section. All four lists are independently populated and may be empty.
255class AdminOverview(CtfyModel): 256 """Single aggregate payload for the admin landing page.""" 257 258 nodes: AdminOverviewCounts = Field(default_factory=AdminOverviewCounts) 259 config: DeploymentConfig = Field(default_factory=DeploymentConfig) 260 capacity: int = 0 261 running_instances: int = 0 262 teams_total: int = 0 263 solves_total: int = 0 264 solves_today: int = 0 265 # User-engagement counters — the "are people actually on the platform right 266 # now?" signal the infra/challenge/team metrics above don't answer. 267 # ``users_active_24h`` is distinct humans who triggered a write action 268 # (launch / submit / solve / join / answer) in the last 24h, not raw logins 269 # (pure browsing leaves no activity row). ``users_new_24h`` is registrations 270 # in the same window. All default to 0 so the field set stays additive. 271 users_total: int = 0 272 users_active_24h: int = 0 273 users_new_24h: int = 0
Single aggregate payload for the admin landing page.
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
852class AdminPatchSubmissionDetail(AdminPatchSubmissionInfo): 853 """One submission with the code it carried. 854 855 This is what keeping the bytes was *for*: a team disputes a verdict, 856 or a checker bug is found on day two, and someone has to read what 857 was actually submitted. Without a reader the stored blobs bought 858 nothing. 859 """ 860 861 #: ``{path: base64(bytes)}``. Base64 rather than text because a 862 #: patch target may legitimately be binary, and decoding it into a 863 #: string would corrupt it into something that merely looks like a 864 #: bad submission. 865 files_b64: dict[str, str] = Field(default_factory=dict) 866 #: ``{path: sha256}`` — an equality match across teams, which is 867 #: what is left once uniqueness has stopped flag sharing but nothing 868 #: stops *patch* sharing. 869 digests: dict[str, str] = Field(default_factory=dict)
One submission with the code it carried.
This is what keeping the bytes was for: a team disputes a verdict, or a checker bug is found on day two, and someone has to read what was actually submitted. Without a reader the stored blobs bought nothing.
831class AdminPatchSubmissionInfo(PatchSubmissionInfo): 832 """One row of the organiser's defence queue. 833 834 The player's view plus the four things only an organiser asks: 835 *whose* it is, and — when a verdict is disputed — which worker 836 decided it after how many tries. 837 838 Still **without the code**, and here that is a stronger requirement 839 than on the parent: this is a competition-wide listing, so shipping 840 bodies would mean reading every stored patch of the event to render 841 a table of statuses. The bytes live on 842 :class:`AdminPatchSubmissionDetail`, one row at a time. 843 """ 844 845 team_id: str = "" 846 user_id: str = "" 847 #: Which worker judged it, for correlating with that node's logs. 848 node_id: str = "" 849 attempts: int = 0
One row of the organiser's defence queue.
The player's view plus the four things only an organiser asks: whose it is, and — when a verdict is disputed — which worker decided it after how many tries.
Still without the code, and here that is a stronger requirement
than on the parent: this is a competition-wide listing, so shipping
bodies would mean reading every stored patch of the event to render
a table of statuses. The bytes live on
AdminPatchSubmissionDetail, one row at a time.
872class AdminPatchSubmissionPage(CtfyModel): 873 """A page of the defence queue, with the total behind it. 874 875 ``total`` is its own query rather than ``len(items)`` so a pager can 876 say "1-20 of 340" — and, with a ``status`` filter, it is also the 877 answer to "is the queue draining". 878 """ 879 880 items: list[AdminPatchSubmissionInfo] = Field(default_factory=list) 881 total: int = 0
A page of the defence queue, with the total behind it.
total is its own query rather than len(items) so a pager can
say "1-20 of 340" — and, with a status filter, it is also the
answer to "is the queue draining".
138class AdminRecentError(CtfyModel): 139 """An archived instance that ended in ``stop_reason="error"``.""" 140 141 instance_id: str 142 challenge_id: str = "" 143 team_id: str = "" 144 node_id: str = "" 145 error: str = "" 146 stopped_at: float = 0.0
An archived instance that ended in stop_reason="error".
157class AdminSilentChallenge(CtfyModel): 158 """A challenge that has launches and submissions but no full solves — 159 a strong signal that the challenge or its flag is broken.""" 160 161 challenge_id: str 162 name: str = "" 163 launches: int = 0 164 submissions: int = 0
A challenge that has launches and submissions but no full solves — a strong signal that the challenge or its flag is broken.
18class AdminSolveCell(CtfyModel): 19 """One cell in the team × challenge solve matrix. 20 21 Unsolved cells are emitted when the team has at least one submission 22 against the challenge (``attempts > 0``) so the UI can distinguish 23 "tried and failed" from "never attempted". 24 """ 25 26 team_id: str 27 challenge_id: str 28 solved: bool = False 29 solved_at: datetime | None = None 30 solve_time_s: float = 0.0 31 attempts: int = 0 32 # True when this team is the first (fastest) solver of the challenge. 33 first_blood: bool = False
One cell in the team × challenge solve matrix.
Unsolved cells are emitted when the team has at least one submission
against the challenge (attempts > 0) so the UI can distinguish
"tried and failed" from "never attempted".
50class AdminSolveMatrix(CtfyModel): 51 """Team × challenge solve matrix for the admin dashboard. 52 53 Rows (teams) and columns (challenges) pre-sorted — teams by solve count 54 desc, challenges by category → difficulty — so the frontend can render 55 the grid verbatim without re-sorting. Cells are sparse: only teams that 56 have at least attempted a challenge contribute a row there. 57 """ 58 59 teams: list[AdminSolveMatrixTeam] = Field(default_factory=list) 60 challenges: list[AdminSolveMatrixChallenge] = Field(default_factory=list) 61 cells: list[AdminSolveCell] = Field(default_factory=list)
Team × challenge solve matrix for the admin dashboard.
Rows (teams) and columns (challenges) pre-sorted — teams by solve count desc, challenges by category → difficulty — so the frontend can render the grid verbatim without re-sorting. Cells are sparse: only teams that have at least attempted a challenge contribute a row there.
42class AdminSolveMatrixChallenge(CtfyModel): 43 challenge_id: str 44 name: str = "" 45 category: str = "" 46 difficulty: str = "" 47 solves_count: int = 0
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
126class AdminStuckInstance(CtfyModel): 127 """A live instance stuck in STARTING for too long — likely a node-side 128 failure that didn't reconcile to ERROR cleanly.""" 129 130 instance_id: str 131 challenge_id: str = "" 132 team_id: str = "" 133 node_id: str = "" 134 requested_at: float = 0.0 135 stuck_for_s: float = 0.0
A live instance stuck in STARTING for too long — likely a node-side failure that didn't reconcile to ERROR cleanly.
490class AdminTaskInfo(CtfyModel): 491 """One background task on ``GET /admin/tasks`` / detail. A projection 492 of ``TaskState`` onto the wire (these routes are admin-only, so the 493 full ``error_detail`` traceback tail is included).""" 494 495 id: str = "" 496 kind: str = "" 497 status: str = "" 498 params: dict[str, Any] = Field(default_factory=dict) 499 result: dict[str, Any] = Field(default_factory=dict) 500 progress: float = 0.0 501 progress_message: str = "" 502 error: str = "" 503 error_detail: str = "" 504 cancel_requested: bool = False 505 created_by_user_id: str = "" 506 created_by_name: str = "" 507 created_at_ts: float = 0.0 508 started_at_ts: float = 0.0 509 finished_at_ts: float = 0.0
One background task on GET /admin/tasks / detail. A projection
of TaskState onto the wire (these routes are admin-only, so the
full error_detail traceback tail is included).
512class AdminTaskListResponse(CtfyModel): 513 """Paginated task list.""" 514 515 items: list[AdminTaskInfo] = Field(default_factory=list) 516 total: int = 0 517 offset: int = 0 518 limit: int = 50
Paginated task list.
521class AdminTaskLogLine(CtfyModel): 522 """One verbose log line for a task (admin-only).""" 523 524 seq: int = 0 525 ts: float = 0.0 526 level: str = "info" 527 message: str = ""
One verbose log line for a task (admin-only).
530class AdminTaskLogsResponse(CtfyModel): 531 """A cursor page of task logs. ``next_after`` is the ``seq`` to pass as 532 ``?after=`` on the next poll (the last item's seq, or the request's 533 ``after`` when the page is empty — so an empty poll never rewinds the 534 cursor).""" 535 536 items: list[AdminTaskLogLine] = Field(default_factory=list) 537 next_after: int = 0
A cursor page of task logs. next_after is the seq to pass as
?after= on the next poll (the last item's seq, or the request's
after when the page is empty — so an empty poll never rewinds the
cursor).
540class AdminTaskSubmitRequest(CtfyModel): 541 """Body of ``POST /admin/tasks``. ``kind`` is validated against the 542 runner's handler registry; ``params`` is the kind-specific input 543 (e.g. ``{"competition_id": ...}`` or ``{"job": "ttl_sweep"}``).""" 544 545 kind: str = "" 546 params: dict[str, Any] = Field(default_factory=dict)
281class AdminTeamRow(CtfyModel): 282 """One row of the organiser's team table. 283 284 Deliberately **not** ``TeamInfo`` plus a field. ``TeamInfo`` is the 285 public projection, and standing is not public: a disqualification 286 reaches the team and the organisers, and announcing it more widely is 287 the organiser's call — the same reasoning that keeps 288 ``team_disqualified`` off the public SSE feed. Putting 289 ``disqualified`` on the public model would publish the verdict on 290 every team page, from a route nobody had to think about. 291 292 Also **not** ``RegistrationTeamRow``: that one carries every member's 293 sealed personal details and lands an audit row for the bulk read. 294 Managing a roster does not need anybody's phone number, and paying 295 that price to render a table would make the audit trail meaningless 296 by filling it with routine work. 297 """ 298 299 id: str 300 name: str 301 description: str = "" 302 competition_id: str = "" 303 captain_user_id: str = "" 304 captain_display_name: str = "" 305 member_count: int = 0 306 created_at: datetime | None = None 307 review_status: str = "" 308 disqualified: bool = False 309 disqualified_at: datetime | None = None 310 disqualification_reason: str = "" 311 members: list[TeamMemberInfo] = Field(default_factory=list)
One row of the organiser's team table.
Deliberately not TeamInfo plus a field. TeamInfo is the
public projection, and standing is not public: a disqualification
reaches the team and the organisers, and announcing it more widely is
the organiser's call — the same reasoning that keeps
team_disqualified off the public SSE feed. Putting
disqualified on the public model would publish the verdict on
every team page, from a route nobody had to think about.
Also not RegistrationTeamRow: that one carries every member's
sealed personal details and lands an audit row for the bulk read.
Managing a roster does not need anybody's phone number, and paying
that price to render a table would make the audit trail meaningless
by filling it with routine work.
197class AdminTimeseries(CtfyModel): 198 """24h-by-default bucketed counts driving the admin Overview pulse charts.""" 199 200 metric: str = "" 201 window_s: int = 0 202 bucket_s: int = 0 203 buckets: list[AdminTimeseriesBucket] = Field(default_factory=list)
24h-by-default bucketed counts driving the admin Overview pulse charts.
177class AdminTimeseriesBucket(CtfyModel): 178 """One bucket of an admin time-series chart. 179 180 ``ts`` is the right edge of the bucket in Unix seconds — the natural 181 point to evaluate "active right now" metrics like running_instances — 182 and the natural label to render at the right of the bucket on the 183 frontend. 184 185 ``partial=True`` marks the rightmost bucket whose right edge is 186 ``now``; it represents an in-progress window that hasn't finished 187 accumulating yet. The frontend renders these with a "live" indicator 188 (semi-transparent fill, pulsing label) to signal the value will 189 grow until the bucket's right edge passes. 190 """ 191 192 ts: float = 0.0 193 value: int = 0 194 partial: bool = False
One bucket of an admin time-series chart.
ts is the right edge of the bucket in Unix seconds — the natural
point to evaluate "active right now" metrics like running_instances —
and the natural label to render at the right of the bucket on the
frontend.
partial=True marks the rightmost bucket whose right edge is
now; it represents an in-progress window that hasn't finished
accumulating yet. The frontend renders these with a "live" indicator
(semi-transparent fill, pulsing label) to signal the value will
grow until the bucket's right edge passes.
291class AdminTrafficInstanceRow(CtfyModel): 292 """One instance × team × challenge row for the admin traffic dashboard.""" 293 294 instance_id: str 295 team_id: str 296 team_name: str = "" 297 challenge_id: str = "" 298 name: str = "" 299 status: str = "" 300 request_count: int = 0 301 started_at: float = 0.0 302 stopped_at: float = 0.0 303 # ``True`` if the instance is still running (request_count is approximate 304 # — refreshed only when the dashboard re-fetches). 305 live: bool = False
One instance × team × challenge row for the admin traffic dashboard.
308class AdminTrafficSummary(CtfyModel): 309 """Top-level response for ``GET /admin/traffic/summary``.""" 310 311 teams: list[AdminTrafficTeamRow] = Field(default_factory=list) 312 instances: list[AdminTrafficInstanceRow] = Field(default_factory=list) 313 total_requests: int = 0
Top-level response for GET /admin/traffic/summary.
276class AdminTrafficTeamRow(CtfyModel): 277 """Per-team aggregate row for the admin traffic dashboard. 278 279 Sums HTTP request counts across every archived instance the team has 280 ever launched, plus the live count for any instances the team is 281 still running. 282 """ 283 284 team_id: str 285 team_name: str = "" 286 instance_count: int = 0 287 request_count: int = 0 288 last_activity_at: float = 0.0
Per-team aggregate row for the admin traffic dashboard.
Sums HTTP request counts across every archived instance the team has ever launched, plus the live count for any instances the team is still running.
149class AdminUnhealthyNode(CtfyModel): 150 node_id: str 151 display_name: str = "" 152 url: str = "" 153 last_heartbeat_ts: float = 0.0 154 downtime_s: float = 0.0
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
77class AdminUserInfo(UserInfo): 78 """Admin-only view of a user. Carries the privilege tier and the 79 most recent role-change audit fields.""" 80 81 email: str = "" 82 role: Literal["user", "admin", "super_admin"] = "user" 83 promoted_by: str | None = None 84 promoted_at: datetime | None = None 85 # OAuth providers the user has bound (e.g. ``["github", "google"]``), 86 # so the admin user list can surface which SSO accounts are linked. 87 providers: list[str] = Field(default_factory=list)
Admin-only view of a user. Carries the privilege tier and the most recent role-change audit fields.
14class AnnouncementCreate(CtfyModel): 15 """Request body for ``POST /admin/announcements``. 16 17 Time fields accept ISO 8601 strings; empty means "no bound" (live 18 immediately / never expires). Severity defaults to ``info`` so a 19 minimal "title + body" payload still validates. 20 """ 21 22 title: str = Field(min_length=1, max_length=200) 23 body: str = Field(default="", max_length=20000) 24 severity: AnnouncementSeverity = "info" 25 starts_at: datetime | None = None 26 ends_at: datetime | None = None
Request body for POST /admin/announcements.
Time fields accept ISO 8601 strings; empty means "no bound" (live
immediately / never expires). Severity defaults to info so a
minimal "title + body" payload still validates.
39class AnnouncementInfo(CtfyModel): 40 """Response shape — mirrors ``AnnouncementState`` field-for-field.""" 41 42 id: str 43 title: str 44 body: str 45 severity: AnnouncementSeverity 46 starts_at: datetime | None = None 47 ends_at: datetime | None = None 48 created_at: datetime | None = None 49 updated_at: datetime | None = None 50 created_by: str 51 created_by_name: str
Response shape — mirrors AnnouncementState field-for-field.
29class AnnouncementUpdate(CtfyModel): 30 """PATCH body — every field optional, ``None`` means "leave alone".""" 31 32 title: str | None = Field(default=None, min_length=1, max_length=200) 33 body: str | None = Field(default=None, max_length=20000) 34 severity: AnnouncementSeverity | None = None 35 starts_at: datetime | None = None 36 ends_at: datetime | None = None
PATCH body — every field optional, None means "leave alone".
205class AnswerTraceHit(CtfyModel): 206 """One instance that was minted the traced answer. 207 208 ``live`` distinguishes a still-running instance (matched against the 209 in-memory minted map) from an archived one (matched against the 210 persisted digest). Both are authoritative; the flag only says 211 whether the instance can still be inspected or stopped. 212 """ 213 214 instance_id: str = "" 215 question_id: str = "" 216 team_id: str = "" 217 team_name: str = "" 218 challenge_id: str = "" 219 competition_id: str = "" 220 live: bool = False 221 started_at: float = 0.0 222 stopped_at: float = 0.0
One instance that was minted the traced answer.
live distinguishes a still-running instance (matched against the
in-memory minted map) from an archived one (matched against the
persisted digest). Both are authoritative; the flag only says
whether the instance can still be inspected or stopped.
191class AnswerTraceRequest(CtfyModel): 192 """Body for ``POST /admin/answer-trace``. 193 194 The answer travels in the body rather than a query parameter 195 because a live flag in a URL lands in every access log, proxy 196 cache, and browser history between the operator and the app. 197 198 Accepted in either the ``FLAG{...}`` or bare form — the server 199 normalises both to one digest, matching how submissions grade. 200 """ 201 202 answer: str = Field(min_length=1, max_length=512)
Body for POST /admin/answer-trace.
The answer travels in the body rather than a query parameter because a live flag in a URL lands in every access log, proxy cache, and browser history between the operator and the app.
Accepted in either the FLAG{...} or bare form — the server
normalises both to one digest, matching how submissions grade.
225class AnswerTraceResponse(CtfyModel): 226 """Result of ``POST /admin/answer-trace``. 227 228 ``hits`` is normally empty or length 1. More than one means two 229 instances genuinely drew the same value — astronomically unlikely 230 for a 128-bit token, but reported rather than silently collapsed 231 because attributing a leak to the wrong team is worse than 232 reporting ambiguity. 233 234 ``traceable_records`` is the number of archived records that carry 235 a digest at all. Instances archived before answer fingerprinting 236 shipped have none, so an empty ``hits`` on a low count means "this 237 predates tracing", not "this flag was never minted here" — the 238 distinction matters when an organiser is deciding whether to act. 239 """ 240 241 answer_hash: str = "" 242 hits: list[AnswerTraceHit] = Field(default_factory=list) 243 searched_live: int = 0 244 searched_records: int = 0 245 traceable_records: int = 0
Result of POST /admin/answer-trace.
hits is normally empty or length 1. More than one means two
instances genuinely drew the same value — astronomically unlikely
for a 128-bit token, but reported rather than silently collapsed
because attributing a leak to the wrong team is worse than
reporting ambiguity.
traceable_records is the number of archived records that carry
a digest at all. Instances archived before answer fingerprinting
shipped have none, so an empty hits on a low count means "this
predates tracing", not "this flag was never minted here" — the
distinction matters when an organiser is deciding whether to act.
147class AttachmentList(CtfyModel): 148 """Response payload for ``GET /challenges/{id}/attachments``. 149 150 Same data as ``ChallengeInfo.attachments`` but addressable directly 151 so pre-launch UI / agent tooling can fetch it without paging through 152 the catalog. Order matches the on-disk sort. 153 """ 154 155 files: list[AttachmentInfo] = Field(default_factory=list)
Response payload for GET /challenges/{id}/attachments.
Same data as ChallengeInfo.attachments but addressable directly
so pre-launch UI / agent tooling can fetch it without paging through
the catalog. Order matches the on-disk sort.
91class AuthTokenResponse(CtfyModel): 92 """Returned by ``POST /auth/register`` and ``POST /auth/login``. 93 94 The plaintext user token is returned directly (not via fragment) since 95 these endpoints are called by first-party XHR, not redirects. 96 """ 97 98 token: str 99 redirect_to: str = "/"
Returned by POST /auth/register and POST /auth/login.
The plaintext user token is returned directly (not via fragment) since these endpoints are called by first-party XHR, not redirects.
84class AwdBoardEntry(CtfyModel): 85 """One team's standing after one settled round. 86 87 The three components are their own columns rather than a single 88 total (§5.5): a team needs to know whether it is losing on attack or 89 bleeding on defence, and one number cannot say. 90 """ 91 92 rank: int = 0 93 team_id: str = "" 94 #: ⚠️ The one field `AwdBoardRow` cannot supply, and the reason this 95 #: wire model exists at all — a board of uuids is not a board. 96 team_name: str = "" 97 total: float = 0.0 98 attack: float = 0.0 99 defence: float = 0.0 100 sla: float = 0.0 101 #: service id → SLA status, this round. ⚠️ A service the checker 102 #: never reached is **absent**, not `up`: a missing verdict means 103 #: "not probed", and rendering it as healthy would let a round the 104 #: checker never got to look like a clean sheet. 105 services: dict[str, str] = Field(default_factory=dict)
One team's standing after one settled round.
The three components are their own columns rather than a single total (§5.5): a team needs to know whether it is losing on attack or bleeding on defence, and one number cannot say.
108class AwdBoardResponse(CtfyModel): 109 """One round's standings. 110 111 ``tick`` is the round actually rendered, which is the **last settled** 112 one unless the caller named another — never the round in progress, 113 which by construction has no scores yet. 114 """ 115 116 tick: int = -1 117 rows: list[AwdBoardEntry] = Field(default_factory=list)
One round's standings.
tick is the round actually rendered, which is the last settled
one unless the caller named another — never the round in progress,
which by construction has no scores yet.
47class AwdFlagResult(CtfyModel): 48 """What happened to one submitted value. 49 50 ``verdict`` is a :class:`ctfy.core.awd.AttackVerdict` value, plus the 51 route-level ``already_captured`` — which is not a judging outcome 52 (``judge_attack`` is pure and cannot know what is on record) but is 53 the answer a client needs to stop resending a flag it banked. 54 55 ⚠️ **Refusals carry no victim.** ``victim_team_id`` feeds a score 56 debit, so naming a team on a rejected submission would take a point 57 off them because somebody else pasted the wrong string. 58 """ 59 60 verdict: str = "" 61 victim_team_id: str = "" 62 service_id: str = "" 63 #: The round the *flag* belongs to, which for a grace-window capture 64 #: is not the current one. ``-1`` when nothing was resolved. 65 tick: int = -1
What happened to one submitted value.
verdict is a ctfy.core.awd.AttackVerdict value, plus the
route-level already_captured — which is not a judging outcome
(judge_attack is pure and cannot know what is on record) but is
the answer a client needs to stop resending a flag it banked.
⚠️ Refusals carry no victim. victim_team_id feeds a score
debit, so naming a team on a rejected submission would take a point
off them because somebody else pasted the wrong string.
36class AwdFlagSubmitRequest(CtfyModel): 37 """One team's captures, submitted together. 38 39 ⚠️ **In the body, never a query string** — same reasoning as 40 ``POST /admin/answer-trace``: a flag in a URL is a flag in every 41 access log between the player and us. 42 """ 43 44 flags: list[str] = Field(min_length=1, max_length=MAX_FLAGS_PER_SUBMISSION)
One team's captures, submitted together.
⚠️ In the body, never a query string — same reasoning as
POST /admin/answer-trace: a flag in a URL is a flag in every
access log between the player and us.
68class AwdFlagSubmitResponse(CtfyModel): 69 """One result per submitted value, in the order they were sent. 70 71 Positional rather than keyed by flag: a client may legitimately 72 submit a value it cannot match back (a typo, a truncated read), and 73 a short or reordered array silently misattributes every verdict 74 after the first gap. 75 """ 76 77 #: The round the batch was judged in — ``-1`` when the match is not 78 #: in one. It answers "which round am I in" once for the whole 79 #: request, so the per-flag results stay uniform even then. 80 tick: int = -1 81 results: list[AwdFlagResult] = Field(default_factory=list)
One result per submitted value, in the order they were sent.
Positional rather than keyed by flag: a client may legitimately submit a value it cannot match back (a typo, a truncated read), and a short or reordered array silently misattributes every verdict after the first gap.
120class AwdMatchConfig(CtfyModel): 121 """What an organiser may set on a match. 122 123 ⚠️ **`last_tick_done` is deliberately absent, and that absence is a 124 control rather than an omission.** It is the tick loop's bookmark — 125 "has this round been settled" — and a body carrying it lets one save 126 rewind the loop, which then re-probes SLA and re-settles rounds the 127 board has already published *and cached as final* (the board's cache 128 keys on the resolved round precisely because a settled round never 129 changes again). The route preserves the stored value; nothing on the 130 wire can move it. 131 132 ``created_at`` is absent for the smaller version of the same reason: 133 it is a fact about the row, not a setting. 134 135 ⚠️ **`extra="forbid"`, so sending one is a 422 rather than a silent 136 drop.** The route preserves the stored bookmark either way, so this 137 buys nothing mechanically — it buys the client being *told*. A 138 caller that believes it can rewind the loop and gets a 200 goes on 139 believing it, and the next thing it writes may not be against a 140 model that ignores the field. 141 """ 142 143 model_config = ConfigDict(extra="forbid") 144 145 #: The gamebox every team runs. Allowed empty — a match under 146 #: construction is a real state, and `AwdMatchState` says so — but 147 #: `plan_provision` refuses without one, which is where an organiser 148 #: hears about it while they can still act. 149 challenge_id: str = "" 150 #: When round 0 opens. ``None`` means "not scheduled yet", which is 151 #: what makes the whole configuration editable. 152 starts_at: AwareDatetime | None = None 153 #: ⚠️ Both floors are 1, not 0. `is_awd_match` reads a zero on 154 #: either as "not an AWD match", so storing one creates a row that 155 #: every other surface then denies exists — a match an organiser can 156 #: see in the form and nowhere else. 157 tick_seconds: int = Field(ge=1) 158 tick_count: int = Field(ge=1) 159 #: §16.10 — two independent caps, never a mode enum. Both stay 160 #: editable mid-match: `tick_budget_for` is read per round and is 161 #: purely forward-looking, and relaxing the allowance is exactly 162 #: what an organiser does after a bad round eats the field's. 163 per_tick_budget: int | None = Field(default=None, ge=0) 164 total_budget: int | None = Field(default=None, ge=0)
What an organiser may set on a match.
⚠️ last_tick_done is deliberately absent, and that absence is a
control rather than an omission. It is the tick loop's bookmark —
"has this round been settled" — and a body carrying it lets one save
rewind the loop, which then re-probes SLA and re-settles rounds the
board has already published and cached as final (the board's cache
keys on the resolved round precisely because a settled round never
changes again). The route preserves the stored value; nothing on the
wire can move it.
created_at is absent for the smaller version of the same reason:
it is a fact about the row, not a setting.
⚠️ extra="forbid", so sending one is a 422 rather than a silent
drop. The route preserves the stored bookmark either way, so this
buys nothing mechanically — it buys the client being told. A
caller that believes it can rewind the loop and gets a 200 goes on
believing it, and the next thing it writes may not be against a
model that ignores the field.
167class AwdMatchInfo(AwdMatchConfig): 168 """A match as an organiser reads it: the settings plus the clock. 169 170 The three added fields are all **read-only by construction** — 171 ``current_tick`` is derived from the wall clock on every read (see 172 `ctfy.core.awd.current_tick`) and the other two are the loop's own 173 record. Extending the request model rather than declaring a second 174 one keeps the shared half from drifting between what a form submits 175 and what it renders back. 176 """ 177 178 competition_id: str = "" 179 #: The round in progress, or ``None`` before the first and after the 180 #: last. Never stored, never settable. 181 current_tick: int | None = None 182 #: The last round settled. ``-1`` means none has been, which is 183 #: distinct from round 0 having completed. 184 last_tick_done: int = -1 185 #: ⚠️ Not ``current_tick is not None`` — that goes back to ``None`` 186 #: when the match ends, and a finished match is still started for 187 #: every purpose the organiser's form cares about. 188 started: bool = False 189 created_at: AwareDatetime | None = None 190 #: ⚠️ Whether the ``awd_tick`` scheduled job is switched on. It ships 191 #: **disabled** — deliberately, since it starts containers on worker 192 #: nodes and a match still being configured must not begin turning on 193 #: its own — so the default state of a freshly created match is 194 #: "everything looks configured and no round will ever run". 195 #: 196 #: It is on the *match* rather than left to the client reading 197 #: ``GET /admin/scheduled-jobs``, because that route is 198 #: ``require_admin`` while this page is ``require_competition_admin``: 199 #: a per-competition organiser — exactly the person running the 200 #: event — would get a 403 and see no warning at all. 201 tick_job_enabled: bool = False
A match as an organiser reads it: the settings plus the clock.
The three added fields are all read-only by construction —
current_tick is derived from the wall clock on every read (see
ctfy.core.awd.current_tick) and the other two are the loop's own
record. Extending the request model rather than declaring a second
one keeps the shared half from drifting between what a form submits
and what it renders back.
226class AwdMatchStatus(CtfyModel): 227 """The match's clock and rules, as a player reads them. 228 229 Public, like the board: the cadence *is* the format's rules, and a 230 team that cannot see which round it is in cannot play. Deliberately 231 a different model from `AwdMatchInfo` — that one is the organiser's 232 form and carries nothing a player needs, while this one carries 233 ``tick_ends_at``, which the form does not. 234 235 ⚠️ **`tick_ends_at` is on the wire rather than derived client-side.** 236 A countdown computed in the browser from ``starts_at`` + 237 ``tick_seconds`` is a second implementation of the round arithmetic 238 `ctfy.core.awd` exists to keep in one place — and the one that would 239 disagree is the one every player is watching. 240 """ 241 242 competition_id: str = "" 243 challenge_id: str = "" 244 starts_at: AwareDatetime | None = None 245 tick_seconds: int = 0 246 tick_count: int = 0 247 #: The round in progress, or ``None`` before the first and after the 248 #: last. Never stored; recomputed on every read. 249 current_tick: int | None = None 250 #: When the round in progress ends. ``None`` when none is. 251 tick_ends_at: AwareDatetime | None = None 252 #: The last round settled — the one the board is showing. ``-1`` 253 #: means none has been, which is not the same as round 0. 254 last_tick_done: int = -1 255 per_tick_budget: int | None = None 256 total_budget: int | None = None
The match's clock and rules, as a player reads them.
Public, like the board: the cadence is the format's rules, and a
team that cannot see which round it is in cannot play. Deliberately
a different model from AwdMatchInfo — that one is the organiser's
form and carries nothing a player needs, while this one carries
tick_ends_at, which the form does not.
⚠️ tick_ends_at is on the wire rather than derived client-side.
A countdown computed in the browser from starts_at +
tick_seconds is a second implementation of the round arithmetic
ctfy.core.awd exists to keep in one place — and the one that would
disagree is the one every player is watching.
204class AwdProvisionPlanInfo(CtfyModel): 205 """What opening the arena would do, before it does it. 206 207 A read, deliberately separate from the launch: §4.5 fixes the box 208 supply at kickoff and admits no late teams, so an organiser wants to 209 see "12 teams, 10 need a box" *before* committing — and the refusals 210 (no gamebox, no capacity, no healthy node) are exactly the ones that 211 must land where they can still be acted on. 212 """ 213 214 #: Teams still needing a box, in a stable order — a fleet launch is 215 #: chunked and retried, and an unstable order makes "which teams did 216 #: the last run reach" unanswerable. 217 to_start: list[str] = Field(default_factory=list) 218 #: Teams already holding one. Reported rather than dropped so a 219 #: resumed run can say what it skipped. 220 already: list[str] = Field(default_factory=list) 221 #: Every team in the match — the capacity figure §5.1 wants at build 222 #: time, not at kickoff. 223 total: int = 0
What opening the arena would do, before it does it.
A read, deliberately separate from the launch: §4.5 fixes the box supply at kickoff and admits no late teams, so an organiser wants to see "12 teams, 10 need a box" before committing — and the refusals (no gamebox, no capacity, no healthy node) are exactly the ones that must land where they can still be acted on.
468class BulkInviteRequest(CtfyModel): 469 """Body for ``POST /admin/competitions/{id}/invites/bulk``. 470 471 Organisers arrive with a qualifying list — a column pasted out of a 472 spreadsheet, newline- or comma-separated, usually with stray spaces 473 and the odd duplicate. Splitting it server-side keeps every client 474 (web, CLI, SDK) from having to agree on the same rules. 475 """ 476 477 # One blob rather than a parsed list: a textarea is what the organiser 478 # actually has, and asking the client to split it invites three 479 # slightly different splitters. 480 emails: str = Field(default="", max_length=200_000)
Body for POST /admin/competitions/{id}/invites/bulk.
Organisers arrive with a qualifying list — a column pasted out of a spreadsheet, newline- or comma-separated, usually with stray spaces and the odd duplicate. Splitting it server-side keeps every client (web, CLI, SDK) from having to agree on the same rules.
496class BulkInviteResponse(CtfyModel): 497 """Per-address outcomes plus the tallies the toast reports. 498 499 Every address is echoed back rather than only the failures: an 500 organiser pasting 300 lines needs to see that 300 were understood, 501 and a silent drop is indistinguishable from success. 502 """ 503 504 results: list[BulkInviteResultRow] = Field(default_factory=list) 505 invited: int = 0 506 pending: int = 0 507 already: int = 0 508 invalid: int = 0
Per-address outcomes plus the tallies the toast reports.
Every address is echoed back rather than only the failures: an organiser pasting 300 lines needs to see that 300 were understood, and a silent drop is indistinguishable from success.
483class BulkInviteResultRow(CtfyModel): 484 """What happened to one address. ``status`` is one of: 485 486 ``invited`` — a new allowlist row (the user existed); 487 ``pending`` — recorded against the address, waiting for a sign-up; 488 ``already`` — invited before, so nothing changed; 489 ``invalid`` — not an email address, skipped. 490 """ 491 492 email: str 493 status: str
What happened to one address. status is one of:
invited — a new allowlist row (the user existed);
pending — recorded against the address, waiting for a sign-up;
already — invited before, so nothing changed;
invalid — not an email address, skipped.
146class CalendarBucket(CtfyModel): 147 """One UTC-day cell on the contribution calendar. 148 149 ``count`` aggregates correct submissions + first-time solves on that 150 day; effectively "did anything productive happen". The series is 151 dense — every day in the requested window is present, zero-filled.""" 152 153 date: str = "" # YYYY-MM-DD, UTC 154 count: int = 0
One UTC-day cell on the contribution calendar.
count aggregates correct submissions + first-time solves on that
day; effectively "did anything productive happen". The series is
dense — every day in the requested window is present, zero-filled.
285class ChallengeBuildKickoffNodeResult(CtfyModel): 286 """One node's response to a build kickoff (single or all). 287 288 ``queued`` is populated only on the ``build-all`` fan-out; for the 289 single-challenge variant the platform inspects ``status`` to learn 290 what the node accepted. 291 """ 292 293 node_id: str 294 ok: bool 295 status: str = "" 296 queued: list[str] = Field(default_factory=list) 297 skipped_built: list[str] = Field(default_factory=list) 298 skipped_in_progress: list[str] = Field(default_factory=list) 299 error: str = ""
302class ChallengeBuildKickoffResponse(CtfyModel): 303 """Admin ``POST /admin/challenges/{id}/build`` and ``…/build-all`` response.""" 304 305 challenge_id: str = "" # empty for build-all 306 nodes: list[ChallengeBuildKickoffNodeResult] = Field(default_factory=list)
Admin POST /admin/challenges/{id}/build and …/build-all response.
242class ChallengeBuildNodeState(CtfyModel): 243 """One worker node's view of a single challenge's build state. 244 245 ``status`` is one of ``unbuilt`` / ``building`` / ``built`` / 246 ``failed``. ``node_error`` is non-empty only when the platform 247 could not reach the node at all (heartbeat stale / 5xx) — in that 248 case ``status`` is forced to ``unbuilt`` for the aggregate. 249 """ 250 251 node_id: str 252 status: str = "unbuilt" 253 built_at: float = 0.0 254 error: str = "" 255 node_error: str = ""
One worker node's view of a single challenge's build state.
status is one of unbuilt / building / built /
failed. node_error is non-empty only when the platform
could not reach the node at all (heartbeat stale / 5xx) — in that
case status is forced to unbuilt for the aggregate.
279class ChallengeBuildStateResponse(CtfyModel): 280 """Admin ``GET /admin/challenges/build-state`` response.""" 281 282 rows: list[ChallengeBuildStateRow] = Field(default_factory=list)
Admin GET /admin/challenges/build-state response.
258class ChallengeBuildStateRow(CtfyModel): 259 """Per-challenge build state aggregated across every online node. 260 261 ``aggregated`` rolls up ``nodes`` using a worst-case rule so the 262 table's single status column behaves intuitively: 263 264 * any ``building`` → ``building`` (yellow) 265 * any ``failed`` → ``failed`` (red) 266 * any ``unbuilt`` → ``unbuilt`` (grey) 267 * else → ``built`` (green) 268 269 A node that couldn't be reached at all contributes ``unbuilt`` so 270 the aggregate stays conservative — the admin still sees a 271 "missing" pip and can drill into the modal to find out why. 272 """ 273 274 challenge_id: str 275 aggregated: str = "unbuilt" 276 nodes: list[ChallengeBuildNodeState] = Field(default_factory=list)
Per-challenge build state aggregated across every online node.
aggregated rolls up nodes using a worst-case rule so the
table's single status column behaves intuitively:
- any
building→building(yellow) - any
failed→failed(red) - any
unbuilt→unbuilt(grey) - else →
built(green)
A node that couldn't be reached at all contributes unbuilt so
the aggregate stays conservative — the admin still sees a
"missing" pip and can drill into the modal to find out why.
158class ChallengeFacetCount(CtfyModel): 159 """One bucket on the challenge catalog facet summary. 160 161 ``value`` is the difficulty / tag string; ``total`` is how many 162 challenges in scope carry it. ``solved`` is the *calling* user's 163 solved count in that bucket (across every team they have been on); 164 it is 0 for anonymous callers and for tag buckets (the UI only 165 renders a solved/total ratio for difficulty). 166 """ 167 168 value: str 169 total: int = 0 170 solved: int = 0
One bucket on the challenge catalog facet summary.
value is the difficulty / tag string; total is how many
challenges in scope carry it. solved is the calling user's
solved count in that bucket (across every team they have been on);
it is 0 for anonymous callers and for tag buckets (the UI only
renders a solved/total ratio for difficulty).
173class ChallengeFacets(CtfyModel): 174 """Catalog aggregates for the Challenges page side panels + filter 175 pill counts — served by ``GET /challenges/facets`` so the page no 176 longer derives them from a bulk fetch of the whole catalog. 177 178 Scoped by ``competition_id`` (when given) exactly like the list 179 endpoint, so the pills/charts reflect the same subset the paged 180 list pages through. 181 """ 182 183 total: int = 0 184 difficulty: list[ChallengeFacetCount] = Field(default_factory=list) 185 # Per-category counts (only categories with ≥1 challenge in scope 186 # show up). Ordered by the canonical category sequence so the 187 # filter pills always render web → pwn → reverse → crypto → misc. 188 category: list[ChallengeFacetCount] = Field(default_factory=list) 189 tags: list[ChallengeFacetCount] = Field(default_factory=list) 190 # Per-status counts for the calling caller (``solved`` / ``unsolved`` 191 # / ``running``). Drives the count chip on each status filter pill 192 # so the page doesn't N+1 a status-filtered list per pill. Buckets 193 # are always present (in canonical order) for layout stability; on 194 # anonymous callers every count is 0 — never leaking another 195 # team's state. 196 status: list[ChallengeFacetCount] = Field(default_factory=list) 197 # Per-source-bucket counts — the prefix of a challenge id 198 # (``CVE-2021-1234`` → ``CVE``), i.e. the benchmark suite an item 199 # was ingested from. Ordered by count descending then id, so a 200 # caller rendering a top-N breakdown gets a stable, meaningful 201 # slice order without re-sorting. ``solved`` is always 0 here: 202 # this facet answers "what is the catalog made of", not "how am I 203 # doing", and the landing page that consumes it is anonymous. 204 buckets: list[ChallengeFacetCount] = Field(default_factory=list)
Catalog aggregates for the Challenges page side panels + filter
pill counts — served by GET /challenges/facets so the page no
longer derives them from a bulk fetch of the whole catalog.
Scoped by competition_id (when given) exactly like the list
endpoint, so the pills/charts reflect the same subset the paged
list pages through.
374class ChallengeFlagStats(CtfyModel): 375 """Per-flag aggregate for one challenge (for the detail page).""" 376 377 flag_id: str 378 solves_count: int = 0
Per-flag aggregate for one challenge (for the detail page).
101class ChallengeInfo(CtfyModel): 102 id: str 103 name: str 104 category: ChallengeCategory = ChallengeCategory.WEB 105 difficulty: str = "" 106 description: str = "" 107 # Full-challenge solves: teams that captured every declared 108 # question (across all modes — dynamic + static + select). 109 solves_count: int = 0 110 # Times this challenge has been instantiated, ever — one per 111 # archived InstanceRecord (every terminal path writes one). A 112 # "how much has this been run" signal for the challenge card; 113 # platform-wide, not scoped to the viewing team or competition. 114 launch_count: int = 0 115 tags: list[str] = Field(default_factory=list) 116 # Every question declared on this challenge, in author-declared 117 # order. Single-question challenges have a single entry with 118 # id ``"flag"``. ``QuestionPublicInfo`` exposes prompt/mode/choices 119 # but never the answer. 120 questions: list[QuestionPublicInfo] = Field(default_factory=list) 121 # Per-question solve counts (how many teams captured each 122 # question). Keyed by question id. Useful for the challenge 123 # detail page's progress breakdown. Counts span every mode; 124 # the multi_select grader records one solve per fully-correct 125 # submission, not per chosen choice. 126 flag_solves: dict[str, int] = Field(default_factory=dict) 127 # Files shipped under the challenge's ``attachments/`` directory, 128 # downloadable via ``GET /challenges/{id}/attachments/{name}``. 129 # Empty for pure-network challenges that ship nothing. 130 attachments: list[AttachmentInfo] = Field(default_factory=list) 131 # True for pure question-answer challenges (``category: misc``, 132 # no ``docker-compose.yml`` on disk, every declared question 133 # carries a static ``answer:``). The frontend keys off this flag 134 # to switch the competition surface to a quiz UI and skip the 135 # launch-confirm flow. Source of truth lives in 136 # :attr:`ctfy.core.challenge.ChallengeSpec.is_qa_only`, ultimately 137 # derived from :attr:`BenchmarkContext.is_qa_only`. 138 is_qa_only: bool = False 139 # Set for AWD+ challenges; ``None`` for every other kind. Same role 140 # as ``is_qa_only`` one field up — the flag a client keys off to 141 # switch surface — but an object rather than a bool because a 142 # defence surface needs to know *what* it may send, not only that it 143 # may send something. 144 defence: DefenceInfo | None = None
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
355class ChallengePullKickoffNodeResult(CtfyModel): 356 """One node's response to a pull kickoff (single or all).""" 357 358 node_id: str 359 ok: bool 360 status: str = "" 361 queued: list[str] = Field(default_factory=list) 362 skipped_pulled: list[str] = Field(default_factory=list) 363 skipped_in_progress: list[str] = Field(default_factory=list) 364 error: str = ""
One node's response to a pull kickoff (single or all).
367class ChallengePullKickoffResponse(CtfyModel): 368 """Admin ``POST /admin/challenges/{id}/pull`` and ``…/pull-all`` response.""" 369 370 challenge_id: str = "" # empty for pull-all 371 nodes: list[ChallengePullKickoffNodeResult] = Field(default_factory=list)
Admin POST /admin/challenges/{id}/pull and …/pull-all response.
316class ChallengePullNodeState(CtfyModel): 317 """One worker node's view of a single challenge's pull state. 318 319 ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` / 320 ``failed``. ``node_error`` is non-empty only when the platform could 321 not reach the node at all — ``status`` is then forced to ``unpulled`` 322 for the aggregate. 323 """ 324 325 node_id: str 326 status: str = "unpulled" 327 pulled_at: float = 0.0 328 error: str = "" 329 node_error: str = ""
One worker node's view of a single challenge's pull state.
status is one of unpulled / pulling / pulled /
failed. node_error is non-empty only when the platform could
not reach the node at all — status is then forced to unpulled
for the aggregate.
349class ChallengePullStateResponse(CtfyModel): 350 """Admin ``GET /admin/challenges/pull-state`` response.""" 351 352 rows: list[ChallengePullStateRow] = Field(default_factory=list)
Admin GET /admin/challenges/pull-state response.
332class ChallengePullStateRow(CtfyModel): 333 """Per-challenge pull state aggregated across every online node. 334 335 ``aggregated`` rolls up ``nodes`` worst-case, mirroring the build 336 row: 337 338 * any ``pulling`` → ``pulling`` (yellow) 339 * any ``failed`` → ``failed`` (red) 340 * any ``unpulled`` → ``unpulled`` (grey) 341 * else → ``pulled`` (green) 342 """ 343 344 challenge_id: str 345 aggregated: str = "unpulled" 346 nodes: list[ChallengePullNodeState] = Field(default_factory=list)
Per-challenge pull state aggregated across every online node.
aggregated rolls up nodes worst-case, mirroring the build
row:
- any
pulling→pulling(yellow) - any
failed→failed(red) - any
unpulled→unpulled(grey) - else →
pulled(green)
207class ChallengeRescanNodeResult(CtfyModel): 208 """Outcome of fanning the rescan out to one worker node. 209 210 ``ok`` is False for an offline node (skipped, never contacted) or a 211 node that errored; ``total`` is the node's post-rescan challenge 212 count when reached, else ``None``. One unreachable node never fails 213 the whole operation — the platform rescan still stands. 214 """ 215 216 node_id: str 217 ok: bool 218 total: int | None = None 219 error: str | None = None
222class ChallengeRescanResult(CtfyModel): 223 """Admin ``POST /admin/challenges/rescan`` response. 224 225 ``total`` / ``added`` / ``removed`` describe the platform's own 226 catalog after re-scanning ``challenges_dir``; ``nodes`` carries the 227 per-worker fan-out outcome so a newly added challenge is confirmed 228 launchable, not just listable. 229 """ 230 231 total: int 232 added: list[str] = Field(default_factory=list) 233 removed: list[str] = Field(default_factory=list) 234 nodes: list[ChallengeRescanNodeResult] = Field(default_factory=list)
Admin POST /admin/challenges/rescan response.
total / added / removed describe the platform's own
catalog after re-scanning challenges_dir; nodes carries the
per-worker fan-out outcome so a newly added challenge is confirmed
launchable, not just listable.
395class ChallengeSolveAttempt(CtfyModel): 396 """One archived instance for a challenge — used to visualise per-team 397 multi-solve attempts on the challenge detail page.""" 398 399 instance_id: str 400 team_id: str = "" 401 team_name: str = "" 402 display_name: str = "" 403 challenge_id: str = "" 404 started_at: float = 0.0 405 stopped_at: float = 0.0 406 duration_s: float = 0.0 407 attempts: int = 0 408 solved: bool = False 409 solved_flags: list[str] = Field(default_factory=list) 410 stop_reason: str = ""
One archived instance for a challenge — used to visualise per-team multi-solve attempts on the challenge detail page.
427class ChallengeSolveAttemptsResponse(CtfyModel): 428 challenge_id: str 429 attempts: list[ChallengeSolveAttempt] = Field(default_factory=list) 430 per_team: list[ChallengeTeamSolveSummary] = Field(default_factory=list) 431 # Total archived instances (incl. unsolved) — lets the UI show 432 # "showing N solved of M total" when the response is filtered. 433 total_attempts: int = 0
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
381class ChallengeStats(CtfyModel): 382 challenge_id: str 383 name: str = "" 384 category: ChallengeCategory | None = None 385 difficulty: str = "" 386 # Teams that captured every declared flag on this challenge. 387 solves_count: int = 0 388 total_attempts: int = 0 389 success_rate: float = 0.0 390 # Per-flag breakdown. Single-flag challenges have a single entry with 391 # ``flag_id == "flag"`` and ``solves_count == solves_count`` above. 392 flag_stats: list[ChallengeFlagStats] = Field(default_factory=list)
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
413class ChallengeTeamSolveSummary(CtfyModel): 414 """Per-team aggregate for the challenge detail solve-attempts panel.""" 415 416 team_id: str 417 team_name: str = "" 418 display_name: str = "" 419 solve_count: int = 0 420 attempt_count: int = 0 421 best_duration_s: float = 0.0 422 total_duration_s: float = 0.0 423 first_solved_at: float = 0.0 424 last_solved_at: float = 0.0
Per-team aggregate for the challenge detail solve-attempts panel.
319class CheckAnswerRequest(CtfyModel): 320 """Run the checker for one *checker-mode* question on an instance. 321 322 Proof-of-exploitation: the platform execs the question's 323 author-supplied checker inside the trusted judge sidecar; the player 324 submits no answer string — the sidecar emits the minted token only 325 when the exploit's effect is verified. 326 """ 327 328 # Which checker question to verify. Checker questions usually carry a 329 # descriptive id (``"deface"`` / ``"persist"`` / …); defaults to 330 # ``"flag"`` for the single-question shape. 331 question_id: str = "flag"
Run the checker for one checker-mode question on an instance.
Proof-of-exploitation: the platform execs the question's author-supplied checker inside the trusted judge sidecar; the player submits no answer string — the sidecar emits the minted token only when the exploit's effect is verified.
334class CheckAnswerResponse(CtfyModel): 335 # True when the checker confirmed the exploit (its stdout matched the 336 # instance's minted token for this question) and a solve was recorded. 337 passed: bool 338 # Echoes the question id on a pass; ``None`` otherwise. 339 question_id: str | None = None 340 # Checker process exit code — diagnostic only; the token match is the 341 # real signal. ``None`` when the checker couldn't be run at all. 342 exit_code: int | None = None 343 # True when the checker exceeded its wall-clock budget. 344 timed_out: bool = False 345 # True when the team had already solved this question (short-circuit, 346 # no node call made). 347 already_solved: bool = False 348 # First-blood rank on a fresh solve (1/2/3 = 1st/2nd/3rd blood), 0 349 # otherwise. Mirrors ``SubmissionResponse.solve_rank``. 350 solve_rank: int = 0 351 # True when this check completed the challenge (every question solved). 352 challenge_fully_solved: bool = False 353 # Server clock (epoch seconds) when the check ran, for the UI's 354 # "last checked" line. 355 last_checked_at: float = 0.0
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
152class ClusterInfo(CtfyModel): 153 """Returned by ``GET /cluster-info``. 154 155 Provides the bits the ``ctfy-admin server invite`` CLI and admin UI wizard 156 need to build a runnable join command. The *registration token* is 157 minted separately via ``POST /nodes/invites`` — it isn't on this 158 payload because cluster info is queried speculatively by the UI on 159 every admin dashboard load, and we never want to hand an invite 160 token to a page that didn't explicitly ask for one. 161 """ 162 163 platform_url: str 164 node_image: str
Returned by GET /cluster-info.
Provides the bits the ctfy-admin server invite CLI and admin UI wizard
need to build a runnable join command. The registration token is
minted separately via POST /nodes/invites — it isn't on this
payload because cluster info is queried speculatively by the UI on
every admin dashboard load, and we never want to hand an invite
token to a page that didn't explicitly ask for one.
425class CompetitionAdminInfo(CtfyModel): 426 """One per-competition admin grant, for the super-admin management 427 card on the competition detail page.""" 428 429 user_id: str 430 display_name: str = "" 431 email: str = "" 432 role: CompetitionGrantRole = "admin" 433 granted_by: str = "" 434 granted_at: datetime | None = None
One per-competition admin grant, for the super-admin management card on the competition detail page.
709class CompetitionChallengeBreakdown(CtfyModel): 710 challenges: list[CompetitionChallengeRow] = Field(default_factory=list) 711 generated_at: float = 0.0
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
687class CompetitionChallengeRow(CtfyModel): 688 """Per-challenge competition aggregate: solve count, attempt 689 success rate, and first blood. Reconstructed from the solve log 690 with the same window / team filter as the scoreboard so the 691 numbers always agree with the leaderboard.""" 692 693 challenge_id: str 694 name: str = "" 695 category: ChallengeCategory | None = None 696 difficulty: str = "" 697 # Teams that captured every declared flag (matches scoreboard's 698 # ``solved`` tally). 699 solves_count: int = 0 700 # In-window submissions for this challenge across registered teams. 701 attempts: int = 0 702 success_rate: float = 0.0 703 # First team to solve this challenge in-window. Empty when unsolved. 704 first_blood_team_id: str = "" 705 first_blood_team_name: str = "" 706 first_blood_at: datetime | None = None
Per-challenge competition aggregate: solve count, attempt success rate, and first blood. Reconstructed from the solve log with the same window / team filter as the scoreboard so the numbers always agree with the leaderboard.
111class CompetitionCreate(CtfyModel): 112 """Request body for ``POST /admin/competitions``. 113 114 Time fields accept ISO 8601 strings; empty means "no bound" (live 115 immediately / never expires). ``challenge_ids`` is validated against 116 the spec registry inside the route handler — unknown ids return 422. 117 """ 118 119 title: str = Field(min_length=1, max_length=200) 120 description: str = Field(default="", max_length=20000) 121 starts_at: datetime | None = None 122 ends_at: datetime | None = None 123 # No fixed length cap: the real bound is ``_check_challenge_ids`` in 124 # ``admin_competitions`` (every id must be a known spec, so a valid list 125 # can never exceed the catalog). A magic number here is arbitrary and a 126 # recurring foot-gun as the corpus grows — bucket comps already exceed 1000. 127 challenge_ids: list[str] = Field(default_factory=list) 128 # New competitions start hidden ("draft") so admins can prepare them 129 # privately and publish when ready. 130 status: Literal["draft", "published", "archived"] = "draft" 131 # Participation access (orthogonal to ``status``). ``public`` is open 132 # to everyone; ``private_listed`` / ``private_hidden`` require an admin 133 # invite to register (the latter also hides the comp from non-invited 134 # users). Defaults to ``public`` so ad-hoc API callers stay open. 135 access: Literal["public", "private_listed", "private_hidden"] = "public" 136 # Marks this competition as an operator-curated eval **standard set** — 137 # only such a competition may back an ``official`` EvalCampaign 138 # (docs/model-eval-vendor-tenancy.md §4.4). ``corpus_sha`` optionally pins 139 # the benchmark-corpus snapshot it was certified against. Off by default. 140 eval_standard: bool = False 141 corpus_sha: str = "" 142 # Which rule ranks this competition's board, and its tuning. Empty 143 # selects the platform default (``flag_count``), so an API caller 144 # that says nothing gets exactly the historic behaviour. Set it here 145 # rather than later where you can: the rule freezes once the 146 # competition goes live (see ``scoring_locked``). 147 scoring_rule: str = "" 148 scoring_params: dict[str, Any] = Field(default_factory=dict) 149 # Playable slices inside the window. Empty (the default) means the 150 # whole ``starts_at``..``ends_at`` window is playable, which is how 151 # every competition has always behaved. Supplying sessions also 152 # *sets* the window: ``CompetitionState`` derives ``starts_at`` / 153 # ``ends_at`` from the envelope, so an organiser sending sessions 154 # need not compute the outer dates and cannot contradict them. 155 sessions: list[CompetitionSessionInfo] = Field(default_factory=list) 156 # --- registration --------------------------------------------------- 157 # The window sign-ups are open, distinct from the play window above: 158 # a real event closes entries well before the first challenge unlocks 159 # so the organiser can vet entrants and print certificates. 160 registration_starts_at: datetime | None = None 161 registration_ends_at: datetime | None = None 162 # Per-competition roster cap. 0 defers to the platform-wide setting, 163 # so two events on one deployment can carry different rules. 164 max_team_size: int = Field(default=0, ge=0) 165 # When true a newly registered team lands ``pending`` and cannot play 166 # until an organiser approves it. 167 registration_review: bool = False 168 # field name -> "hidden" | "optional" | "required". Keys are validated 169 # against REGISTRATION_FIELDS. Anything absent is treated as hidden, 170 # so an unconfigured competition collects nothing extra. 171 registration_fields: dict[str, RegistrationFieldPolicy] = Field(default_factory=dict) 172 # Days after ``ends_at`` before the retention sweep blanks the three 173 # sealed columns (real name, phone, postal address). ``0`` keeps them 174 # indefinitely, which is what every competition predating this reads 175 # as — the purge is irreversible, so turning it on is a decision 176 # somebody makes about one event, never a default. 177 pii_retention_days: int = Field(default=0, ge=0) 178 179 @field_validator("registration_fields") 180 @classmethod 181 def _known_registration_fields( 182 cls, v: dict[str, RegistrationFieldPolicy] 183 ) -> dict[str, RegistrationFieldPolicy]: 184 return _check_registration_fields(v) 185 186 _norm_times = field_validator("starts_at", "ends_at", mode="before")(_blank_to_none)
Request body for POST /admin/competitions.
Time fields accept ISO 8601 strings; empty means "no bound" (live
immediately / never expires). challenge_ids is validated against
the spec registry inside the route handler — unknown ids return 422.
386class CompetitionDetail(CompetitionInfo): 387 """Full detail payload — includes a *preview* of the resolved 388 challenge summaries and the caller's registration state when 389 authenticated. 390 391 ⚠️ ``challenges`` is bounded by 392 :data:`~ctfy.server.routes.competitions.COMPETITION_CHALLENGE_PREVIEW` 393 and is **not** the competition's challenge set. It used to be, and a 394 bucket competition put hundreds of full ``ChallengeInfo`` rows — 395 each carrying questions, attachments and a defence block — through 396 a payload whose only reader renders six cards. 397 398 Anything that means *all of them* must page 399 ``GET /competitions/{id}/challenges`` (SDK: 400 ``client.competition(id).challenges(offset, limit)``), and anything 401 that means *how many* must read ``challenge_count``. Both existed as 402 ``len(challenges)`` before the bound and would have kept working 403 while reporting the preview size — which is why the two aggregates 404 below are served rather than left to the caller to derive. 405 """ 406 407 challenges: list[ChallengeInfo] = Field(default_factory=list) 408 # The true totals, over the whole resolved set rather than the 409 # preview. Derived here because a client cannot recover them from a 410 # bounded list, and two clients deriving them differently is two 411 # clients disagreeing about how big the competition is. 412 challenge_count: int = 0 413 difficulty_counts: dict[str, int] = Field(default_factory=dict) 414 registered_at: datetime | None = None
Full detail payload — includes a preview of the resolved challenge summaries and the caller's registration state when authenticated.
⚠️ challenges is bounded by
~ctfy.server.routes.competitions.COMPETITION_CHALLENGE_PREVIEW
and is not the competition's challenge set. It used to be, and a
bucket competition put hundreds of full ChallengeInfo rows —
each carrying questions, attachments and a defence block — through
a payload whose only reader renders six cards.
Anything that means all of them must page
GET /competitions/{id}/challenges (SDK:
client.competition(id).challenges(offset, limit)), and anything
that means how many must read challenge_count. Both existed as
len(challenges) before the bound and would have kept working
while reporting the preview size — which is why the two aggregates
below are served rather than left to the caller to derive.
240class CompetitionInfo(CtfyModel): 241 """Summary row used in the list view. 242 243 Per-viewer fields (``is_registered`` / ``my_*``) are populated only 244 when the request carries an authenticated user; for anonymous 245 requests they fall back to sentinels (empty strings, ``-1``) so 246 the response shape stays stable. Front-end treats ``my_rank < 0`` 247 as "no value" rather than "rank zero". 248 """ 249 250 id: str 251 title: str 252 description: str 253 starts_at: datetime | None = None 254 ends_at: datetime | None = None 255 created_at: datetime | None = None 256 updated_at: datetime | None = None 257 created_by: str 258 created_by_name: str 259 challenge_ids: list[str] = Field(default_factory=list) 260 # Visibility gate. Only ``published`` rows reach non-admins; admins 261 # and per-competition admins also receive ``draft`` / ``archived``. 262 status: Literal["draft", "published", "archived"] = "published" 263 # Participation access. ``public`` is open; ``private_listed`` stays 264 # discoverable but invite-only to register; ``private_hidden`` is also 265 # hidden from non-invited users. Default ``public`` for backward 266 # compat (legacy rows / anonymous responses). 267 access: Literal["public", "private_listed", "private_hidden"] = "public" 268 #: The series this contest is an occurrence of, empty for a one-off. 269 #: Carried so a client can reach the **ladder** — before this the id 270 #: was on no wire model, so the whole rating surface was unreachable 271 #: from the browser however public its route was. Not a disclosure: 272 #: it names a series whose ladder the reader may already read, and 273 #: nothing about the series' pool or cadence travels with it. 274 series_id: str = "" 275 # Eval standard-set marker + pinned corpus snapshot (admin-only meaning; 276 # see CompetitionState). Surfaced so the admin form can show/edit it. 277 eval_standard: bool = False 278 corpus_sha: str = "" 279 # Registration window + form config. The window is genuinely public 280 # information — an entrant needs to know when sign-ups close, and it 281 # differs from the play window — and the rest lets the admin edit form 282 # prefill without a second fetch. Nothing here is sensitive: the same 283 # field policy is already served to any signed-in user by 284 # ``GET /competitions/{id}/registration``. 285 registration_starts_at: datetime | None = None 286 registration_ends_at: datetime | None = None 287 max_team_size: int = 0 288 registration_review: bool = False 289 registration_fields: dict[str, RegistrationFieldPolicy] = Field(default_factory=dict) 290 # Public deliberately: how long entrants' personal data is kept is 291 # part of the notice they are owed, not an internal setting. 292 pii_retention_days: int = 0 293 # Which rule ranks this board, and its tuning. Empty name means the 294 # platform default (``flag_count``). Public because the players are 295 # entitled to know how they are being scored. 296 scoring_rule: str = "" 297 scoring_params: dict[str, Any] = Field(default_factory=dict) 298 # Server-derived: whether the rule is now frozen. The admin form 299 # disables the control off this rather than re-deriving the 300 # draft/phase rule client-side and disagreeing with the server. 301 scoring_locked: bool = False 302 # Server-derived projection. ``"upcoming"`` / ``"running"`` / ``"past"`` 303 # — saves the frontend from re-implementing the same string-compare 304 # the backend already does. 305 phase: Literal["upcoming", "running", "past"] = "running" 306 # Declared play sessions, empty for a single-window competition. 307 # Public: an entrant has to know the event runs 08:00–16:00 daily, 308 # and the schedule is the first thing a competition page shows. 309 sessions: list[CompetitionSessionInfo] = Field(default_factory=list) 310 # Server-derived: whether play is open *right now*. Deliberately not 311 # folded into ``phase`` — a competition between its 16:00 and the 312 # next 08:00 is still running (board live, registration open, scoring 313 # window unmoved), and re-classifying it would change what every 314 # existing ``phase`` consumer says about a live event. 315 # 316 # ``True`` for every competition with no sessions, which is the whole 317 # existing corpus: they are gated by nothing, so play is never shut. 318 play_open: bool = True 319 # When play reopens. ``None`` means there is no next session — either 320 # none are declared, or the last one has ended. The closed-for-now 321 # card counts down to this, so it is the one field it needs. 322 next_session_starts_at: datetime | None = None 323 # When the current session closes. ``None`` outside a session, and 324 # also for a competition that declares none — there is no session to 325 # name, and reporting the window's end here would put a "session ends 326 # at" countdown on an event that never had sessions. 327 current_session_ends_at: datetime | None = None 328 # Whether this competition runs a classic AWD tick loop. Server-derived 329 # from the ``awd_matches`` side table through ``core.awd.is_awd_match``, 330 # never re-derived per client: it decides which board a player is sent 331 # to (``/awd`` vs ``/scoreboard``) and whether a gamebox page exists at 332 # all, so two clients answering it differently is two clients 333 # disagreeing about how the event is played. 334 # 335 # A bool rather than a ``format`` enum on purpose. ``docs/awd-plan.md`` 336 # §14.1 refuses a ``GameMode`` taxonomy at N=2 — a format is a point on 337 # six orthogonal axes, not a subclass — and this field claims only the 338 # fact the side table already stores. ``False`` is every competition 339 # that predates AWD, which is the whole existing corpus. 340 is_awd_match: bool = False 341 # The competition this one is a *virtual sitting* of, empty for a 342 # real one. 343 # 344 # On the wire because the marker is a rendering decision: the row's 345 # ``title`` is the source's verbatim, deliberately, so a "(virtual)" 346 # suffix baked into the database would be untranslatable English in 347 # every locale's listing. A client reads this and labels it itself. 348 virtual_of: str = "" 349 # Whether this contest can be replayed on your own clock — the fact 350 # a client renders its "Replay" button from, so it does not have to 351 # POST and read a 400 to find out. 352 # 353 # Derived server-side from ``core.virtual.can_replay``, never per 354 # client, for the same reason ``is_awd_match`` is: a client working 355 # the refusal set out itself is a second implementation of 356 # ``plan_virtual_run``, and it would drift once, silently, into a 357 # button that fails the moment it is pressed. 358 # 359 # ⚠️ Deliberately **not** viewer-scoped, unlike ``can_participate``. 360 # "Am I signed in" the client already knows, and "have I already 361 # started one" is answered by the route being idempotent — asking it 362 # here would put a per-user allowlist read inside a projection that 363 # runs once per row of the listing, which is the N+1 shape three 364 # other fields on this model have already had to have removed. 365 # Defaults ``False``: a button that always 400s is worse than none. 366 can_replay: bool = False 367 # Total registered teams. Surfaced on cards so users can gauge 368 # popularity at a glance. 369 registered_count: int = 0 370 # Viewer-scoped fields — see class docstring. 371 is_registered: bool = False 372 # Whether the viewer may register for this comp. Always ``True`` for 373 # ``public`` comps; for private comps it's ``True`` only when the 374 # viewer is invited or can administer it. Lets the frontend render a 375 # "private — invitation required" locked card without a probe POST. 376 # Defaults ``True`` so public / anonymous-on-public responses are open. 377 can_participate: bool = True 378 my_team_id: str = "" 379 my_team_name: str = "" 380 my_role: Literal["captain", "member", ""] = "" 381 my_rank: int = -1 382 my_score: int = -1 383 my_solves: int = -1
Summary row used in the list view.
Per-viewer fields (is_registered / my_*) are populated only
when the request carries an authenticated user; for anonymous
requests they fall back to sentinels (empty strings, -1) so
the response shape stays stable. Front-end treats my_rank < 0
as "no value" rather than "rank zero".
451class CompetitionInviteInfo(CtfyModel): 452 """One participation-allowlist entry, for the admin "Invitations" 453 card on a private competition. The body for POST is the shared 454 ``GrantCompetitionAdminRequest`` (resolve by id or email).""" 455 456 user_id: str 457 display_name: str = "" 458 email: str = "" 459 invited_by: str = "" 460 invited_at: datetime | None = None 461 # True while the invitation is still waiting for its person: the 462 # address was invited before anyone signed up with it, so there is no 463 # account to name yet. ``user_id`` is empty in that state, which is 464 # why the card keys its rows on ``email``. 465 pending_signup: bool = False
One participation-allowlist entry, for the admin "Invitations"
card on a private competition. The body for POST is the shared
GrantCompetitionAdminRequest (resolve by id or email).
30class CompetitionMembershipInfo(CtfyModel): 31 """One row on ``MeResponse.competition_teams``. 32 33 Lets the frontend hydrate a per-comp team picker / "My 34 competitions" hub from a single ``/me`` call without N+1 35 fetches. Captain status surfaces in the UI for buttons gated 36 on ``role == "captain"`` (mint invite / kick / rename). 37 38 ``competition_title`` / ``competition_phase`` are denormalized 39 from the competition the membership points at so the switcher 40 and the dashboard can label rows by the *competition* (the 41 thing the user is choosing between) rather than the team name — 42 every user is auto-joined to an identically named personal team 43 per comp, so team name alone makes the rows indistinguishable. 44 """ 45 46 competition_id: str 47 competition_title: str 48 competition_phase: Literal["upcoming", "running", "past"] 49 team_id: str 50 team_name: str 51 role: Literal["captain", "member"] 52 # Whether this competition runs a classic AWD tick loop — the same 53 # fact ``CompetitionInfo.is_awd_match`` carries, denormalized here 54 # for the one reader that cannot fetch it. ``Sidebar`` is mounted 55 # above the routes while ``CompetitionContext`` lives inside 56 # ``CompetitionLayout``, so the nav cannot see the competition the 57 # layout already loaded; it *can* see this, through ``AuthContext``, 58 # with no request of its own. Without it the per-competition block 59 # sends every AWD player to the Jeopardy board, which a match never 60 # populates. 61 is_awd_match: bool = False 62 # Rail-enrichment fields (denormalized so the Slack-style rail can 63 # render instance count / rank / a time-progress ring from one /me 64 # call, no per-tile fetch fan-out). 65 # 66 # Live instances for THIS team in THIS comp. Mirrors 67 # InstanceCountContext semantics (all non-terminal InstanceState 68 # rows for the team — terminal ones already left the live store as 69 # InstanceRecord); 0 when none. 70 running_instances: int = 0 71 # Competition window, copied straight off the CompetitionState the 72 # /me loop already fetches. "" start = live immediately, "" end = 73 # never ends (same convention as CompetitionState / phase()). 74 starts_at: datetime | None = None 75 ends_at: datetime | None = None 76 # Caller's current rank in this comp. -1 sentinel = "not computed" 77 # (upcoming phase, or the per-request rank budget was exhausted) — 78 # matches the existing my_rank<0 frontend convention. 79 my_rank: int = -1 80 # Registered-team count, the denominator the UI shows as "#3 / 40". 81 competition_team_count: int = 0
One row on MeResponse.competition_teams.
Lets the frontend hydrate a per-comp team picker / "My
competitions" hub from a single /me call without N+1
fetches. Captain status surfaces in the UI for buttons gated
on role == "captain" (mint invite / kick / rename).
competition_title / competition_phase are denormalized
from the competition the membership points at so the switcher
and the dashboard can label rows by the competition (the
thing the user is choosing between) rather than the team name —
every user is auto-joined to an identically named personal team
per comp, so team name alone makes the rows indistinguishable.
417class CompetitionRegistrationInfo(CtfyModel): 418 """One row in the admin "who's registered" table.""" 419 420 team_id: str 421 team_name: str = "" 422 registered_at: datetime | None = None
One row in the admin "who's registered" table.
68class CompetitionRoster(CtfyModel): 69 """``GET /competitions/{id}/roster`` — every registered team with its 70 members, in one read. 71 72 Exists because the alternative is a fan-out: a caller that wants to 73 know who is playing has to call ``GET /teams/{id}`` once per team, 74 which for a 400-team event is 400 round trips for a page that has to 75 stay live. The backend answers it in three bulk queries whatever the 76 team count. 77 78 ⚠️ **Public identity only.** ``TeamMemberInfo`` carries 79 ``display_name`` / ``avatar_url`` and nothing else, which is exactly 80 what ``GET /teams/{id}`` already publishes per team. The entrant 81 dossier — ``real_name`` / ``phone`` / ``mailing_address``, 个人敏感信息 82 sealed at the backend boundary — reaches the reviewer roster 83 (``RegistrationTeamRow``) and nowhere else, and must never be added 84 here: this response is public and cacheable, so a field added to it 85 is a field published to everyone forever. 86 """ 87 88 teams: list[CompetitionRosterTeam] = Field(default_factory=list) 89 generated_at: float = 0.0
GET /competitions/{id}/roster — every registered team with its
members, in one read.
Exists because the alternative is a fan-out: a caller that wants to
know who is playing has to call GET /teams/{id} once per team,
which for a 400-team event is 400 round trips for a page that has to
stay live. The backend answers it in three bulk queries whatever the
team count.
⚠️ Public identity only. TeamMemberInfo carries
display_name / avatar_url and nothing else, which is exactly
what GET /teams/{id} already publishes per team. The entrant
dossier — real_name / phone / mailing_address, 个人敏感信息
sealed at the backend boundary — reaches the reviewer roster
(RegistrationTeamRow) and nowhere else, and must never be added
here: this response is public and cacheable, so a field added to it
is a field published to everyone forever.
59class CompetitionRosterTeam(CtfyModel): 60 """One team plus its roster, for the competition-wide roster read.""" 61 62 team_id: str 63 name: str = "" 64 captain_user_id: str = "" 65 members: list[TeamMemberInfo] = Field(default_factory=list)
One team plus its roster, for the competition-wide roster read.
721class CompetitionScoreDistribution(CtfyModel): 722 """Aggregate companion to the (now server-paginated) competition 723 scoreboard: the score-distribution histogram + the true team count. 724 725 The standings table pages server-side, so the histogram / "N teams" 726 figure can't be re-derived from one page in the browser — this 727 bins every registered team's ``flags_solved`` with the same 728 adaptive scheme the frontend used to do client-side. 729 """ 730 731 total_teams: int = 0 732 bins: list[ScoreBucket] = Field(default_factory=list)
Aggregate companion to the (now server-paginated) competition scoreboard: the score-distribution histogram + the true team count.
The standings table pages server-side, so the histogram / "N teams"
figure can't be re-derived from one page in the browser — this
bins every registered team's flags_solved with the same
adaptive scheme the frontend used to do client-side.
678class CompetitionScoreHistory(CtfyModel): 679 """Top-N teams' score/rank progression, reconstructed from the 680 competition's solve log (no snapshot dependency — exact for past 681 and live competitions alike).""" 682 683 series: list[ScoreHistorySeries] = Field(default_factory=list) 684 generated_at: float = 0.0
Top-N teams' score/rank progression, reconstructed from the competition's solve log (no snapshot dependency — exact for past and live competitions alike).
34class CompetitionSeriesConfig(CtfyModel): 35 """What an organiser sets on a series.""" 36 37 model_config = ConfigDict(extra="forbid") 38 39 name: str = "" 40 enabled: bool = False 41 42 weekday: int = 5 43 hour: int = 20 44 minute: int = 0 45 timezone: str = "UTC" 46 every_n_weeks: int = 1 47 first_at: AwareDatetime | None = None 48 lead_seconds: int = Field(default=172800, ge=0) 49 50 title_template: str = "" 51 description: str = "" 52 duration_seconds: int = Field(default=10800, ge=1) 53 pool: list[str] = Field(default_factory=list) 54 slate_size: int = Field(default=1, ge=1) 55 scoring_rule: str = "" 56 scoring_params: dict[str, Any] = Field(default_factory=dict) 57 status: Literal["draft", "published", "archived"] = "published" 58 access: Literal["public", "private_listed", "private_hidden"] = "public" 59 60 @model_validator(mode="after") 61 def _cadence_is_expressible(self) -> CompetitionSeriesConfig: 62 """⚠️ Validated through `Cadence`, never re-checked here. 63 64 Two copies of "is this a legal weekday / a real timezone" is how 65 the form and the job come to disagree about which Saturdays 66 exist — and the job's copy is the one nobody sees fail. 67 """ 68 Cadence( 69 weekday=self.weekday, 70 hour=self.hour, 71 minute=self.minute, 72 timezone=self.timezone, 73 every_n_weeks=self.every_n_weeks, 74 ) 75 return self
What an organiser sets on a series.
78class CompetitionSeriesInfo(CompetitionSeriesConfig): 79 """A series as the organiser's page reads it.""" 80 81 id: str = "" 82 created_at: AwareDatetime | None = None 83 created_by: str = "" 84 #: The next scheduled start, or ``None`` for a series whose schedule 85 #: is over or unusable. Derived, never stored. 86 next_occurrence_at: AwareDatetime | None = None 87 #: How many occurrences already exist. A count is the right shape 88 #: *here* — this is a read for a human, not the job's idempotency, 89 #: which asks the database which instants exist. 90 occurrences_spawned: int = 0 91 #: ⚠️ Whether the job that actually spawns is switched on. It ships 92 #: enabled, unlike `awd_tick`, but a super-admin can pause it from 93 #: `/admin/scheduled-jobs` — and a series page that cannot say so 94 #: leaves an organiser reading a perfectly configured schedule that 95 #: will never run. Absent means not seeded, which is the same thing 96 #: as off to a reader; reporting it as on is the false reassurance 97 #: this field exists to remove. 98 spawn_job_enabled: bool = False 99 #: How many of `pool` declare a patchable surface. 100 #: 101 #: ⚠️ Derived here rather than in each client, which is the opposite 102 #: of how the competition wizard answers the same question — and the 103 #: difference is not an inconsistency. That form has just *browsed* 104 #: the catalog, so it holds the rows; a series client holds ids and 105 #: nothing else, and there is no by-id read on `GET /challenges`. Two 106 #: clients deriving it would be two clients disagreeing about whether 107 #: a weekly can score defence at all. 108 #: 109 #: The reason it is worth a field: `scoring_rule` and `pool` are set 110 #: in one form and nothing cross-checks them, so an "attack & 111 #: defence" weekly over a pool with nothing patchable scores zero 112 #: defence **every week**, unattended, with no error anywhere. This 113 #: is a report, never a refusal — a pool an organiser intends to fill 114 #: later is legitimate. 115 defensible_pool_count: int = 0 116 #: Pool entries the catalog does not have. A separate and equally 117 #: silent failure: `plan_slate` refuses an occurrence whose slate it 118 #: cannot fill, so a pool naming a since-deleted challenge produces a 119 #: Saturday with no contest rather than an error. 120 unknown_pool_ids: list[str] = Field(default_factory=list) 121 #: Pool entries **another series** also draws from — the third member 122 #: of the same family, and the one with a security edge rather than a 123 #: scoring one. 124 #: 125 #: `plan_slate` consumes a pool *within one series*, so two series 126 #: overlapping is invisible from either: nothing refuses, nothing 127 #: logs, and both weeklies are individually correct. What it costs is 128 #: that a challenge played on the internal ladder is **pre-leaked** 129 #: when the public one draws it, and the first sign is a public 130 #: Saturday where some squad solves in ninety seconds. 131 #: 132 #: A report, never a refusal, exactly like the two above: an 133 #: organiser staging both series out of one draft pool, or 134 #: deliberately re-running a problem for a different audience, is 135 #: doing something legitimate. 136 shared_pool_ids: list[str] = Field(default_factory=list)
A series as the organiser's page reads it.
104class CompetitionSessionInfo(CtfyModel): 105 """One playable slice of a competition's window, on the wire.""" 106 107 starts_at: datetime 108 ends_at: datetime
One playable slice of a competition's window, on the wire.
189class CompetitionUpdate(CtfyModel): 190 """PATCH body — every field optional, ``None`` means "leave alone".""" 191 192 title: str | None = Field(default=None, min_length=1, max_length=200) 193 description: str | None = Field(default=None, max_length=20000) 194 starts_at: datetime | None = None 195 ends_at: datetime | None = None 196 challenge_ids: list[str] | None = Field(default=None) # no cap — see CompetitionCreate 197 status: Literal["draft", "published", "archived"] | None = None 198 access: Literal["public", "private_listed", "private_hidden"] | None = None 199 eval_standard: bool | None = None 200 corpus_sha: str | None = None 201 # Rejected with 409 once the competition is live — re-submitting the 202 # value it already holds is a no-op, so the edit form can keep 203 # sending every field when the admin saves any of them. 204 scoring_rule: str | None = None 205 scoring_params: dict[str, Any] | None = None 206 #: ``None`` leaves the schedule alone; ``[]`` clears every session and 207 #: returns the competition to a single continuous window. 208 sessions: list[CompetitionSessionInfo] | None = None 209 # --- registration --------------------------------------------------- 210 # The window sign-ups are open, distinct from the play window above: 211 # a real event closes entries well before the first challenge unlocks 212 # so the organiser can vet entrants and print certificates. 213 registration_starts_at: datetime | None = None 214 registration_ends_at: datetime | None = None 215 # Per-competition roster cap. 0 defers to the platform-wide setting, 216 # so two events on one deployment can carry different rules. 217 max_team_size: int | None = Field(default=None, ge=0) 218 # When true a newly registered team lands ``pending`` and cannot play 219 # until an organiser approves it. 220 registration_review: bool | None = None 221 # field name -> "hidden" | "optional" | "required". Keys are validated 222 # against REGISTRATION_FIELDS. Anything absent is treated as hidden, 223 # so an unconfigured competition collects nothing extra. 224 registration_fields: dict[str, RegistrationFieldPolicy] | None = None 225 #: ``None`` leaves it alone, same as every other field here. 226 pii_retention_days: int | None = Field(default=None, ge=0) 227 228 _norm_times = field_validator( 229 "starts_at", "ends_at", "registration_starts_at", "registration_ends_at", mode="before" 230 )(_blank_to_none) 231 232 @field_validator("registration_fields") 233 @classmethod 234 def _known_registration_fields( 235 cls, v: dict[str, RegistrationFieldPolicy] | None 236 ) -> dict[str, RegistrationFieldPolicy] | None: 237 return None if v is None else _check_registration_fields(v)
PATCH body — every field optional, None means "leave alone".
171class CreateFineGrainedTokenRequest(CtfyModel): 172 """Body for ``POST /auth/tokens`` (mints a fine-grained token). 173 174 Back-compat: when ``competition_access`` / ``permissions`` are 175 omitted the server mints the broad legacy profile (every 176 competition, full participate access) so existing 177 ``mint_token(label)`` callers — SDK, CLI, MCP — keep getting a 178 token equivalent to the old agent token. 179 """ 180 181 label: str = Field(default="", max_length=64) 182 # Lifetime in days. ``None`` uses the server default (30); ``0`` means 183 # "never expires". The server caps this at ~5 years to reject absurd 184 # values. 185 expires_in_days: int | None = Field(default=None, ge=0, le=365 * 5) 186 # ``None`` → legacy-broad default. Otherwise: none | all | selected. 187 competition_access: str | None = None 188 competition_ids: list[str] = Field(default_factory=list) 189 # Category → "none" | "read" | "write". Clamped server-side to each 190 # category's max level. ``None`` → legacy-broad default. 191 permissions: dict[str, str] | None = None
Body for POST /auth/tokens (mints a fine-grained token).
Back-compat: when competition_access / permissions are
omitted the server mints the broad legacy profile (every
competition, full participate access) so existing
mint_token(label) callers — SDK, CLI, MCP — keep getting a
token equivalent to the old agent token.
194class CreateFineGrainedTokenResponse(TokenInfo): 195 """Includes the plaintext ``token`` — returned once, never persisted.""" 196 197 token: str
Includes the plaintext token — returned once, never persisted.
114class CreateInviteRequest(CtfyModel): 115 """Body for ``POST /api/v1/nodes/invites``. 116 117 ``ttl_seconds`` controls how long the minted registration token stays 118 valid — it cannot be renewed, only re-created. 119 """ 120 121 ttl_seconds: int = Field(default=3600, ge=60, le=7 * 24 * 3600) 122 # Optional vendor binding: a node registered with this invite is stamped 123 # as that vendor's bring-your-own-node (docs/model-eval-vendor-tenancy.md 124 # §6.3). "" = a platform-shared node (the default). Validated to an 125 # existing vendor at mint time. 126 owner_vendor_id: str = ""
Body for POST /api/v1/nodes/invites.
ttl_seconds controls how long the minted registration token stays
valid — it cannot be renewed, only re-created.
129class CreateInviteResponse(CtfyModel): 130 """Plaintext registration token is returned **exactly once** here. 131 132 Subsequent ``GET /nodes/invites`` lookups only expose metadata; the 133 plaintext is never persisted — only ``token_hash`` is stored. 134 """ 135 136 id: str 137 registration_token: str 138 expires_at: datetime | None = None
Plaintext registration token is returned exactly once here.
Subsequent GET /nodes/invites lookups only expose metadata; the
plaintext is never persisted — only token_hash is stored.
76class DefenceInfo(CtfyModel): 77 """What a defender may do on this challenge — AWD+ only. 78 79 Present iff the challenge declares a ``patch:`` block. ``None`` 80 everywhere else, which is what lets a client decide whether to render 81 a defence surface at all: without this the only way to find out was 82 to POST a submission and read the refusal, so the web UI had no way 83 to know AWD+ existed. 84 85 ``targets`` is deliberately public. It is a whitelist, not a secret — 86 a defender is *given* this source (they can read it in their own 87 box), the refusal message already names a rejected path, and a UI 88 that cannot say which files it will accept sends players guessing. 89 """ 90 91 #: Challenge-relative paths a submission may replace. Anything else 92 #: is refused at submit time rather than at judge time. 93 targets: list[str] = Field(default_factory=list) 94 #: Whether ``patch.live`` declares a defender's shell, i.e. whether 95 #: ``ctfy patch shell`` / the terminal button will work here. False 96 #: means uploads are the only channel — a real and supported shape, 97 #: not a degraded one, so the UI says so rather than hiding. 98 live_shell: bool = False
What a defender may do on this challenge — AWD+ only.
Present iff the challenge declares a patch: block. None
everywhere else, which is what lets a client decide whether to render
a defence surface at all: without this the only way to find out was
to POST a submission and read the refusal, so the web UI had no way
to know AWD+ existed.
targets is deliberately public. It is a whitelist, not a secret —
a defender is given this source (they can read it in their own
box), the refusal message already names a rejected path, and a UI
that cannot say which files it will accept sends players guessing.
46class DefenceSource(CtfyModel): 47 """The patchable source of an AWD+ challenge, as the defender gets it. 48 49 AWD+ hands every team the same vulnerable service **and its source** 50 (§15.1) — you cannot patch what you cannot read. Until this there 51 was no way to get it: the only channel was `patch.live`, i.e. ssh 52 into your own box, which is the *classic AWD* shape and leaves 53 virtual participation and upsolve impossible, since after the event 54 there is no box to enter. That is the feature §15.5 calls decisive 55 for a weekly series. 56 57 ⚠️ **Exactly `patch.targets`, never the tree.** A benchmark 58 directory also holds `solution/exploit.py` and the checker, and for 59 some challenges the groundtruth — serving the directory would hand a 60 player the reference exploit and the thing that grades them. The 61 whitelist is already the format's one real security boundary (see 62 `routes/patches.py`), so the download reuses it rather than 63 inventing a second, weaker one. 64 65 ``files`` is keyed by challenge-relative path with **base64** 66 values, which is deliberately the shape `POST /patches` accepts: a 67 defender — or an agent — round-trips download → edit → upload with 68 no transformation, and a binary target does not need a second code 69 path. 70 """ 71 72 challenge_id: str 73 files: dict[str, str] = Field(default_factory=dict)
The patchable source of an AWD+ challenge, as the defender gets it.
AWD+ hands every team the same vulnerable service and its source
(§15.1) — you cannot patch what you cannot read. Until this there
was no way to get it: the only channel was patch.live, i.e. ssh
into your own box, which is the classic AWD shape and leaves
virtual participation and upsolve impossible, since after the event
there is no box to enter. That is the feature §15.5 calls decisive
for a weekly series.
⚠️ Exactly patch.targets, never the tree. A benchmark
directory also holds solution/exploit.py and the checker, and for
some challenges the groundtruth — serving the directory would hand a
player the reference exploit and the thing that grades them. The
whitelist is already the format's one real security boundary (see
routes/patches.py), so the download reuses it rather than
inventing a second, weaker one.
files is keyed by challenge-relative path with base64
values, which is deliberately the shape POST /patches accepts: a
defender — or an agent — round-trips download → edit → upload with
no transformation, and a binary target does not need a second code
path.
136class DeleteMeRequest(CtfyModel): 137 """Body for ``DELETE /me``. ``confirm_display_name`` must match the 138 caller's user display name exactly (case-sensitive) — the typo 139 gate that stops a fat-fingered click from cascading the account's 140 data away. (Falls back to email when display_name is empty.) 141 """ 142 143 confirm_display_name: str = Field(min_length=1, max_length=120)
Body for DELETE /me. confirm_display_name must match the
caller's user display name exactly (case-sensitive) — the typo
gate that stops a fat-fingered click from cascading the account's
data away. (Falls back to email when display_name is empty.)
239class DeploymentConfig(CtfyModel): 240 """Read-only deployment posture for the admin overview — env-only 241 knobs that are *not* on public ``/meta`` and not runtime-tunable via 242 platform-settings, so an operator can see how this deployment is 243 configured at a glance. ``rate_limiting_enabled`` is deliberately 244 absent: it is runtime-tunable, so the platform-settings page owns its 245 effective value + source tag. Defaults mirror ``CtfyConfig``.""" 246 247 single_process_guard: bool = True 248 auto_migrate: bool = True 249 password_auth_enabled: bool = True 250 # HSTS is emitted only when the deployment is reachable over https 251 # (``oauth_redirect_base`` is https) — see security_headers. 252 hsts_enabled: bool = False
Read-only deployment posture for the admin overview — env-only
knobs that are not on public /meta and not runtime-tunable via
platform-settings, so an operator can see how this deployment is
configured at a glance. rate_limiting_enabled is deliberately
absent: it is runtime-tunable, so the platform-settings page owns its
effective value + source tag. Defaults mirror CtfyConfig.
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
102class DeviceCodeResponse(CtfyModel): 103 """RFC 8628-style response to ``POST /auth/device/code`` — the CLI 104 shows ``user_code`` + ``verification_uri`` and polls with 105 ``device_code``.""" 106 107 device_code: str 108 user_code: str 109 verification_uri: str 110 verification_uri_complete: str 111 expires_in: int 112 interval: int
RFC 8628-style response to POST /auth/device/code — the CLI
shows user_code + verification_uri and polls with
device_code.
132class DeviceInfoResponse(CtfyModel): 133 """What the approval page shows about a pending device request.""" 134 135 status: str 136 requested_at: datetime | None = None 137 requester_ip: str = "" 138 requester_user_agent: str = ""
What the approval page shows about a pending device request.
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
119class DeviceTokenResponse(CtfyModel): 120 """Poll result for ``POST /auth/device/token``. ``status`` is one of 121 ``pending`` / ``approved`` / ``denied`` / ``expired``; ``token`` is 122 set only when ``approved``.""" 123 124 status: str 125 token: str = ""
170class DifficultyStat(CtfyModel): 171 difficulty: str = "" # easy | medium | hard | ... 172 solved: int = 0 173 total: int = 0
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
94class EasterEggClaim(CtfyModel): 95 """Reply for ``POST /easter-eggs/{egg_id}/claim``. 96 97 ``already_unlocked`` lets the page tell "first time, congrats!" 98 from "you've been here before" without an extra GET. 99 """ 100 101 egg_id: str 102 achievement: TeamAchievement 103 already_unlocked: bool = False
Reply for POST /easter-eggs/{egg_id}/claim.
already_unlocked lets the page tell "first time, congrats!"
from "you've been here before" without an extra GET.
35class EmailCodeAuthInfo(CtfyModel): 36 """Whether passwordless email sign-in is offered. 37 38 Tracks outbound-email configuration exactly — there is no separate 39 feature flag, because a sign-in method that cannot deliver its code 40 is not a sign-in method, and offering the button anyway just produces 41 a dead end. 42 """ 43 44 enabled: bool = False
Whether passwordless email sign-in is offered.
Tracks outbound-email configuration exactly — there is no separate feature flag, because a sign-in method that cannot deliver its code is not a sign-in method, and offering the button anyway just produces a dead end.
104class EmailSuppressionCreate(CtfyModel): 105 email: str 106 #: Free text; stored as the ``detail`` an operator reads later when 107 #: deciding whether the entry still applies. 108 detail: str = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
78class EmailSuppressionInfo(CtfyModel): 79 """One address the platform will not send to.""" 80 81 email: str 82 #: ``hard_bounce`` | ``complaint`` | ``manual``. 83 reason: str 84 #: The provider's own words, when it gave any. 85 detail: str = "" 86 #: ``webhook`` | ``admin`` — a provider's verdict or a human's. 87 source: str = "" 88 #: The message whose bounce caused this, for cross-referencing the 89 #: outbox row that sent it. 90 provider_message_id: str = "" 91 created_at: str = "" 92 created_by: str = ""
One address the platform will not send to.
95class EmailSuppressionListResponse(CtfyModel): 96 items: list[EmailSuppressionInfo] 97 total: int 98 #: False when no Svix secret is set, in which case nothing will ever 99 #: be added automatically and the list is manual-only. The UI says so 100 #: rather than letting an operator conclude that nothing bounces. 101 webhook_configured: bool
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
55class EmailTemplateInfo(CtfyModel): 56 """One row of the admin template list. 57 58 ``category`` and ``forced`` come from the same registry the 59 preference matrix reads, so the admin sees *why* a message is or is 60 not suppressible without cross-referencing another page. 61 """ 62 63 name: str 64 category: str 65 forced: bool 66 subject: str
69class EmailTemplateListResponse(CtfyModel): 70 templates: list[EmailTemplateInfo] 71 #: False when Resend is unconfigured. The UI disables Send rather 72 #: than letting the operator discover it through a 400. 73 email_configured: bool 74 #: Where a test send goes when the request omits a recipient. 75 default_recipient: str = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
136class ErrorResponse(CtfyModel): 137 """Unified shape for every HTTP 4xx/5xx body produced by platform routes. 138 139 ``detail`` mirrors the pre-existing field (so SDK / CLI / frontend code 140 that reads ``data.detail`` keeps working); ``code`` adds a stable 141 machine-readable identifier derived from the raising exception class, 142 and ``timestamp`` is server-side UTC in ISO 8601 — useful when 143 correlating client-side and server-side logs. 144 145 ``detail`` is a union because three sources feed the envelope: 146 * a plain string from legacy ``HTTPException`` / ``raise_error`` calls; 147 * a structured dict (``{"message": "...", "competition_id": ...}`` 148 or the legacy ``{"error": "..."}``) when the route wants to attach 149 machine-readable context the frontend can branch on; 150 * a list of FastAPI ``RequestValidationError`` issues, wrapped under 151 ``{"message", "issues"}`` by ``_handle_validation_error``. 152 """ 153 154 code: str 155 detail: str | dict[str, Any] | list[dict[str, Any]] 156 timestamp: datetime | None = None
Unified shape for every HTTP 4xx/5xx body produced by platform routes.
detail mirrors the pre-existing field (so SDK / CLI / frontend code
that reads data.detail keeps working); code adds a stable
machine-readable identifier derived from the raising exception class,
and timestamp is server-side UTC in ISO 8601 — useful when
correlating client-side and server-side logs.
detail is a union because three sources feed the envelope:
- a plain string from legacy
HTTPException/raise_errorcalls; - a structured dict (
{"message": "...", "competition_id": ...}or the legacy{"error": "..."}) when the route wants to attach machine-readable context the frontend can branch on; - a list of FastAPI
RequestValidationErrorissues, wrapped under{"message", "issues"}by_handle_validation_error.
154class EvalLeaderboard(CtfyModel): 155 """The full leaderboard: ranked model standings + the dimensions available.""" 156 157 competition_id: str = "" 158 challenge_count: int = 0 159 #: the single corpus version all runs share (reproducibility header); 160 #: blank when runs span mixed corpus revisions. 161 corpus_sha: str = "" 162 dimensions: list[str] = Field(default_factory=list) 163 models: list[ModelStanding] = Field(default_factory=list) 164 #: vendor scorecards (pooled across each vendor's models); unattributed 165 #: models (blank vendor) are omitted. 166 vendors: list[VendorStanding] = Field(default_factory=list) 167 #: harness scorecards (pooled across each harness's runs) — the harness 168 #: comparison axis. The blank harness id is the built-in ctfy harness. 169 harnesses: list[HarnessStanding] = Field(default_factory=list) 170 #: per-(model, harness) cross-tab cells — the apples-to-apples view that 171 #: isolates the harness effect on a fixed model. Empty unless ≥2 harnesses. 172 cross: list[HarnessModelCell] = Field(default_factory=list) 173 #: run activity + run-level solve rate bucketed by UTC day (chronological). 174 trend: list[EvalTrendPoint] = Field(default_factory=list)
The full leaderboard: ranked model standings + the dimensions available.
121class EvalTrendPoint(CtfyModel): 122 """Eval activity + run-level performance for one UTC day. 123 124 ``solve_rate`` here is *run-level* (fully-solved runs ÷ runs that day), 125 a different lens from the per-challenge best-of-k rate in the standings: 126 it tracks how the fleet is performing over time, not cumulative coverage. 127 """ 128 129 period: str = "" # UTC calendar day, YYYY-MM-DD 130 runs: int = 0 131 fully_solved: int = 0 132 solve_rate: float = 0.0 133 cost_usd: float = 0.0
Eval activity + run-level performance for one UTC day.
solve_rate here is run-level (fully-solved runs ÷ runs that day),
a different lens from the per-challenge best-of-k rate in the standings:
it tracks how the fleet is performing over time, not cumulative coverage.
28class FeedbackStats(CtfyModel): 29 """Public aggregate counts for one challenge. 30 31 ``counts`` is dense over every Reaction key (zeros included) so the 32 frontend can render the 9 chips uniformly without filling in 33 missing keys. ``total`` is the sum across all reactions — under 34 multi-select this is "total taps" not "distinct users", so it can 35 exceed the unique-reactor count when players stack chips. 36 """ 37 38 challenge_id: str 39 counts: dict[Reaction, int] 40 total: int
Public aggregate counts for one challenge.
counts is dense over every Reaction key (zeros included) so the
frontend can render the 9 chips uniformly without filling in
missing keys. total is the sum across all reactions — under
multi-select this is "total taps" not "distinct users", so it can
exceed the unique-reactor count when players stack chips.
437class GrantCompetitionAdminRequest(CtfyModel): 438 """Body for ``PUT /admin/competitions/{id}/admins`` — resolve the 439 target user by id or (case-insensitive) email. 440 441 ``role`` picks the grant tier: ``admin`` (full organiser control of 442 this event) or ``reviewer`` (roster read + eligibility verdicts + 443 export, nothing that removes a team). Defaults to ``admin`` so an 444 older client that omits it keeps the prior behaviour.""" 445 446 user_id: str = "" 447 email: str = "" 448 role: CompetitionGrantRole = "admin"
Body for PUT /admin/competitions/{id}/admins — resolve the
target user by id or (case-insensitive) email.
role picks the grant tier: admin (full organiser control of
this event) or reviewer (roster read + eligibility verdicts +
export, nothing that removes a team). Defaults to admin so an
older client that omits it keeps the prior behaviour.
103class HarnessModelCell(CtfyModel): 104 """One ``(model, harness)`` pair's solve stats — a cross-tab cell. 105 106 The apples-to-apples lens the pooled rollups can't give: holding the 107 model fixed and varying the harness isolates the harness's effect on 108 that model (e.g. ``gpt-5`` solves 80% under one harness, 60% under 109 another). Display labels are resolved frontend-side from the model / 110 harness standings, so the cell carries only ids + stats. 111 """ 112 113 model_id: str = "" 114 #: harness registry id; blank = the built-in ctfy harness. 115 harness_id: str = "" 116 challenges_attempted: int = 0 117 challenges_solved: int = 0 118 solve_rate: float = 0.0
One (model, harness) pair's solve stats — a cross-tab cell.
The apples-to-apples lens the pooled rollups can't give: holding the
model fixed and varying the harness isolates the harness's effect on
that model (e.g. gpt-5 solves 80% under one harness, 60% under
another). Display labels are resolved frontend-side from the model /
harness standings, so the cell carries only ids + stats.
79class HarnessStanding(CtfyModel): 80 """One harness's aggregate scorecard across all the models it drove. 81 82 The harness's runs are pooled across models: a challenge counts as 83 *solved* if **any** run under the harness solved it. Like the vendor 84 rollup this ranks harness *plus its model fleet*, not the harness in 85 isolation — a harness paired with stronger models scores higher. 86 """ 87 88 rank: int = 0 89 #: harness registry id; blank = the built-in ctfy harness. 90 harness_id: str = "" 91 display_name: str = "" 92 #: distinct models evaluated under this harness. 93 models: int = 0 94 challenges_attempted: int = 0 95 challenges_solved: int = 0 96 solve_rate: float = 0.0 97 questions_solved: int = 0 98 total_tokens: int = 0 99 #: estimated USD cost summed across the harness's runs (0 when unpriced). 100 cost_usd: float = 0.0
One harness's aggregate scorecard across all the models it drove.
The harness's runs are pooled across models: a challenge counts as solved if any run under the harness solved it. Like the vendor rollup this ranks harness plus its model fleet, not the harness in isolation — a harness paired with stronger models scores higher.
19class HealthResponse(CtfyModel): 20 status: str = "ok" 21 hostname: str = "" 22 running_instances: int = 0 23 capacity: int = 0 24 # Server's installed ``ctfy`` version. Lets the SDK/CLI flag a 25 # client/server skew off the probe they already make — no extra 26 # round trip. Defaults to "" so older servers (and the model's own 27 # default construction) stay valid. 28 version: str = "" 29 # Deployment tier + label, carried on the same probe so the CLI can 30 # warn an operator they're pointed at a non-production deployment (and 31 # add friction to destructive commands on prod) without a second 32 # round trip. ``prod`` is the fail-safe default. 33 environment: Environment = "prod" 34 environment_label: str = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
160class InboxAnnouncement(CtfyModel): 161 """Announcement projected onto the Inbox surface. 162 163 Carries the same fields as :class:`AnnouncementInfo` plus a 164 per-user ``is_read`` flag derived from the 165 ``announcement_reads`` table. The frontend renders unread rows 166 prominently and lets the user mark them read via 167 ``POST /me/announcements/{id}/read``. 168 """ 169 170 id: str 171 title: str 172 body: str 173 severity: AnnouncementSeverity 174 starts_at: datetime | None = None 175 ends_at: datetime | None = None 176 created_at: datetime | None = None 177 updated_at: datetime | None = None 178 created_by: str 179 created_by_name: str 180 is_read: bool = False
Announcement projected onto the Inbox surface.
Carries the same fields as AnnouncementInfo plus a
per-user is_read flag derived from the
announcement_reads table. The frontend renders unread rows
prominently and lets the user mark them read via
POST /me/announcements/{id}/read.
125class InboxCaptainRequest(CtfyModel): 126 """Pending join request against any team the calling user 127 captains. Frontend renders an Approve / Reject pair pointing at 128 ``/competitions/{competition_id}/invites/{invite_id}/approve|reject``. 129 """ 130 131 id: str 132 competition_id: str 133 competition_title: str 134 team_id: str 135 team_name: str 136 requester_user_id: str 137 requester_display_name: str 138 created_at: datetime | None = None
Pending join request against any team the calling user
captains. Frontend renders an Approve / Reject pair pointing at
/competitions/{competition_id}/invites/{invite_id}/approve|reject.
141class InboxCompetitionInvite(CtfyModel): 142 """A standing invitation to participate in a PRIVATE competition the 143 calling user has been granted but not yet joined. 144 145 Unlike the team invites above this isn't accept/decline — the 146 allowlist row is a standing permission, so the frontend renders a 147 "Register" CTA linking to ``/competitions/{competition_id}/team``. 148 Drops off the inbox once the user registers (or the comp ends / is 149 flipped back to public). 150 """ 151 152 competition_id: str 153 competition_title: str 154 competition_phase: Literal["upcoming", "running", "past"] 155 invited_by_user_id: str = "" 156 invited_by_display_name: str = "" 157 invited_at: datetime | None = None
A standing invitation to participate in a PRIVATE competition the calling user has been granted but not yet joined.
Unlike the team invites above this isn't accept/decline — the
allowlist row is a standing permission, so the frontend renders a
"Register" CTA linking to /competitions/{competition_id}/team.
Drops off the inbox once the user registers (or the comp ends / is
flipped back to public).
92class InboxIncomingInvite(CtfyModel): 93 """Pending direct invite where the calling user is the named target. 94 95 The frontend renders an Accept / Decline pair pointing at 96 ``/competitions/{competition_id}/invites/{invite_id}/accept|decline``. 97 """ 98 99 id: str 100 competition_id: str 101 competition_title: str 102 team_id: str 103 team_name: str 104 captain_user_id: str 105 captain_display_name: str 106 created_at: datetime | None = None
Pending direct invite where the calling user is the named target.
The frontend renders an Accept / Decline pair pointing at
/competitions/{competition_id}/invites/{invite_id}/accept|decline.
109class InboxOutgoingRequest(CtfyModel): 110 """Pending join request the calling user opened — surfaces so the 111 requester can see "did the captain see this yet?" without 112 refreshing the team page. 113 """ 114 115 id: str 116 competition_id: str 117 competition_title: str 118 team_id: str 119 team_name: str 120 captain_user_id: str 121 captain_display_name: str 122 created_at: datetime | None = None
Pending join request the calling user opened — surfaces so the requester can see "did the captain see this yet?" without refreshing the team page.
183class InboxResponse(CtfyModel): 184 """``GET /me/inbox`` payload — pending invites/requests + 185 actionable announcements grouped by the action the calling 186 user can take. 187 """ 188 189 incoming_invites: list[InboxIncomingInvite] = Field(default_factory=list) 190 outgoing_requests: list[InboxOutgoingRequest] = Field(default_factory=list) 191 captain_requests: list[InboxCaptainRequest] = Field(default_factory=list) 192 # Standing participation invites to private competitions the user 193 # hasn't joined yet (rendered with a "Register" CTA, not accept/decline). 194 competition_invites: list[InboxCompetitionInvite] = Field(default_factory=list) 195 # Site-wide announcements relevant right now (currently live + a 196 # short tail of recently-expired) with the user's read state. 197 announcements: list[InboxAnnouncement] = Field(default_factory=list) 198 # Convenience count so the sidebar can render an unread badge 199 # without re-iterating ``announcements``. 200 unread_announcement_count: int = 0
GET /me/inbox payload — pending invites/requests +
actionable announcements grouped by the action the calling
user can take.
65class InstanceInfo(CtfyModel): 66 id: str 67 challenge_id: str = "" 68 team_id: str = "" 69 # Competition the instance is scoped to. Stamped from the resolved 70 # per-comp team at start time; empty for legacy rows or unscoped 71 # admin instances. Frontend keys per-comp instance pages off this 72 # field so a user registered for two comps doesn't see comp A's 73 # instances in comp B's tab. 74 competition_id: str = "" 75 # Worker node currently hosting this instance. Empty only for 76 # records created before nodes were tracked (legacy). 77 node_id: str = "" 78 name: str = "" 79 category: ChallengeCategory | None = None 80 difficulty: str = "" 81 status: str = InstanceStatus.STARTING 82 started_at: float = 0.0 83 ttl: int = 0 84 expires_at: float = 0.0 85 services: list[ServiceEndpoint] = Field(default_factory=list) 86 description: str = "" 87 # Per-instance question view: same shape as ``ChallengeInfo.questions`` 88 # but additionally carries the agent-visible state. ``unlocked`` is 89 # ``False`` when the question's ``requires:`` predecessors haven't 90 # all been answered correctly yet; the UI hides the prompt for locked 91 # questions to avoid leaking route hints. ``answered_correctly`` is 92 # ``True`` once this team has captured this question on this instance. 93 questions: list[InstanceQuestionInfo] = Field(default_factory=list) 94 # Mirrored from ``ChallengeInfo.attachments`` so players who jump 95 # straight to the instance page (e.g. via a launch link) see the 96 # download list without re-fetching the catalog row. 97 attachments: list[AttachmentInfo] = Field(default_factory=list) 98 # How the platform exposes this instance to the player. Always 99 # surfaced (defaults to ``"simple"``) so the agent can branch on 100 # `if info.network_topology == "engagement": ...` without dealing 101 # with a missing field. 102 network_topology: Literal["simple", "engagement"] = "simple" 103 # Set only when ``network_topology == "engagement"``. Carries the 104 # tunnel endpoint host:port + the URL the client downloads the 105 # `.ovpn` body from. ``None`` in simple mode. 106 vpn_endpoint: VpnEndpoint | None = None 107 # Landing-page hints declared in the challenge's ``metadata.yaml`` 108 # (``entry_urls:`` field, validated by META013). Surfaced verbatim 109 # to the frontend so the VPN / services panel can render a 110 # "start here" list without the player having to read the 111 # description for the canonical first URL. 112 entry_urls: list[str] = Field(default_factory=list) 113 # Which supply this box came from. ``on_demand`` is a player's own 114 # launch and behaves the way every instance always has; ``match`` is 115 # an arena gamebox an organiser provisioned, and it is a genuinely 116 # different object to its owner — it never expires, stopping it is 117 # not theirs to do, and it is the one the round loop rotates flags 118 # into. The client cannot tell them apart from `challenge_id` alone, 119 # because a player may also launch the gamebox on demand. 120 lease: Literal["on_demand", "match"] = "on_demand"
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
27class InstanceQuestionInfo(CtfyModel): 28 """One question, projected for a specific running instance. 29 30 Mirrors :class:`QuestionPublicInfo` plus per-instance state. The 31 ``prompt`` is replaced with an empty string when ``unlocked`` is 32 ``False`` so dependent questions never leak their route hints 33 before the predecessor is solved. 34 """ 35 36 id: str 37 prompt: str 38 mode: str | None = None 39 choices: list[str] | None = None 40 requires: list[str] = Field(default_factory=list) 41 # ``True`` once every id in ``requires`` has been captured by the 42 # calling team on this instance. ``False`` keeps the prompt empty 43 # to avoid leaking the question text early. 44 unlocked: bool = True 45 # ``True`` once the calling team has submitted a correct answer 46 # for this question against this instance. Drives the UI checkmark. 47 answered_correctly: bool = False 48 # Wrong-attempt budget remaining for the calling team on this 49 # specific question. ``None`` when the question's mode is uncapped 50 # (dynamic free-form, or any mode the operator opted out of via 51 # ``question_attempt_caps``). The UI renders an ``X/N attempts`` 52 # badge from this on first page load and decrements it locally on 53 # each wrong submit (the submission response also carries it). 54 attempts_remaining: int | None = None 55 # The cap that ``attempts_remaining`` is being measured against — 56 # echoed so the UI can render ``X/N`` without recomputing N from 57 # the question's mode + current platform settings. 58 attempts_cap: int | None = None 59 # ``True`` for a checker question whose ``check.poll`` is on: the 60 # platform auto-verifies it in the background, so the UI shows an 61 # "auto-verify on" affordance. Never exposes the checker command. 62 auto_check: bool = False
One question, projected for a specific running instance.
Mirrors QuestionPublicInfo plus per-instance state. The
prompt is replaced with an empty string when unlocked is
False so dependent questions never leak their route hints
before the predecessor is solved.
165class InstanceRecordArtifacts(CtfyModel): 166 """Per-artifact presence flags + counts for an archived instance. 167 168 Returned alongside the full :class:`InstanceRecord` from 169 ``GET /admin/instance-records/{id}`` so the admin UI can show 170 ``Manifest / Events / Submissions / Container log`` tabs at a glance 171 without a round-trip per tab. 172 """ 173 174 has_manifest: bool = False 175 has_container_log: bool = False 176 events_count: int = 0 177 submissions_count: int = 0 178 traffic_count: int = 0 179 # Bytes on disk for the archived ``traffic.pcap``. 0 when no capture 180 # was persisted (sidecar disabled, archive disabled, or fetch 181 # failed). The admin UI uses this both to gate the download button 182 # and to render an "X MB" hint next to it. 183 pcap_bytes: int = 0
Per-artifact presence flags + counts for an archived instance.
Returned alongside the full InstanceRecord from
GET /admin/instance-records/{id} so the admin UI can show
Manifest / Events / Submissions / Container log tabs at a glance
without a round-trip per tab.
186class InstanceRecordDetail(CtfyModel): 187 record: InstanceRecordInfoDetail 188 artifacts: InstanceRecordArtifacts = Field(default_factory=InstanceRecordArtifacts)
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
123class InstanceRecordInfo(CtfyModel): 124 """Archived-instance summary for the admin history list. 125 126 Mirrors :class:`ctfy.core.state.models.InstanceRecord` minus the heavy 127 fields (``spec``, ``surface``). Used by ``GET /admin/instance-records``. 128 """ 129 130 id: str 131 team_id: str = "" 132 challenge_id: str = "" 133 node_id: str = "" 134 name: str = "" 135 category: ChallengeCategory | None = None 136 difficulty: str = "" 137 status: str = "" 138 stop_reason: str = "" 139 error: str = "" 140 # Full node diagnostic (e.g. ``docker compose up`` stderr tail). 141 # Admin-only: this model backs ``/admin/instance-records*`` only; 142 # the player-facing ``InstanceInfo`` / ``InstanceStatusResponse`` 143 # deliberately omit it. 144 error_detail: str = "" 145 started_at: float = 0.0 146 stopped_at: float = 0.0 147 duration_s: float = 0.0 148 ttl: int = 0 149 solved: bool = False 150 attempts: int = 0 151 # Total HTTP requests captured by the mitmproxy sidecar during the 152 # instance's lifetime. 0 when the archive sink was disabled or no 153 # traffic was captured. 154 request_count: int = 0
Archived-instance summary for the admin history list.
Mirrors ctfy.core.state.models.InstanceRecord minus the heavy
fields (spec, surface). Used by GET /admin/instance-records.
157class InstanceRecordInfoDetail(InstanceRecordInfo): 158 """Full record — includes spec + surface, returned by detail endpoint.""" 159 160 spec: dict[str, Any] = Field(default_factory=dict) 161 surface: AttackSurface | None = None 162 artifact_dir: str = ""
Full record — includes spec + surface, returned by detail endpoint.
248class InstanceStatusResponse(CtfyModel): 249 id: str 250 status: str = InstanceStatus.STARTING 251 attack_surface: AttackSurface | None = None 252 error: str = "" 253 # CA volume for the per-instance mitmproxy. Operators trust this CA 254 # if they want to MITM HTTPS; HTTP traffic is captured transparently 255 # without it. 256 cert_volume: str = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
17class LeaderboardCell(CtfyModel): 18 """One model's solve stats within one dimension bucket (e.g. difficulty=hard).""" 19 20 key: str = "" 21 attempted: int = 0 22 solved: int = 0 23 solve_rate: float = 0.0
One model's solve stats within one dimension bucket (e.g. difficulty=hard).
217class LinkStartResponse(CtfyModel): 218 """Returned by ``POST /auth/identities/link/{provider}``. 219 220 The caller is already authenticated via XHR, so we don't 302 them; 221 instead we hand back the authorize URL for the frontend to navigate 222 the browser to. 223 """ 224 225 authorize_url: str
Returned by POST /auth/identities/link/{provider}.
The caller is already authenticated via XHR, so we don't 302 them; instead we hand back the authorize URL for the frontend to navigate the browser to.
53class LinkedIdentity(CtfyModel): 54 id: str 55 # "github" | "google" (OAuth) or "password" for a local credential. 56 provider: str 57 provider_email: str = "" 58 provider_display_name: str = "" 59 # Stable third-party account id (GitHub numeric id, Google `sub`); 60 # empty for the local password credential. 61 provider_user_id: str = "" 62 # GitHub `@handle`; empty for Google and password. 63 provider_login: str = "" 64 avatar_url: str = "" 65 created_at: datetime | None = None 66 last_used_at: datetime | None = None
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
456class LlmBudgetPage(CtfyModel): 457 """The organiser's LLM-spend roster. 458 459 ``gateway_configured`` is on the envelope for the reason 460 ``webhook_configured`` is on the email-suppression list: an empty 461 table otherwise reads as "nobody has spent anything" when it means 462 "this deployment enforces no budgets at all", and those call for 463 opposite actions. 464 """ 465 466 items: list[LlmBudgetRow] 467 total: int 468 gateway_configured: bool
The organiser's LLM-spend roster.
gateway_configured is on the envelope for the reason
webhook_configured is on the email-suppression list: an empty
table otherwise reads as "nobody has spent anything" when it means
"this deployment enforces no budgets at all", and those call for
opposite actions.
396class LlmBudgetResetInfo(CtfyModel): 397 """Response from the LLM-budget reset endpoint. 398 399 ⚠️ ``outcome`` is the point of the model. A reset touches two stores 400 and can legitimately land as *nothing to reset*; reporting only 401 ``200`` would make "the team has a fresh allowance" and "the gateway 402 is unreachable so nothing changed" the same reply, which is exactly 403 the failure the seam is written to avoid. 404 """ 405 406 team_id: str 407 competition_id: str 408 challenge_id: str 409 outcome: str 410 reason: str
Response from the LLM-budget reset endpoint.
⚠️ outcome is the point of the model. A reset touches two stores
and can legitimately land as nothing to reset; reporting only
200 would make "the team has a fresh allowance" and "the gateway
is unreachable so nothing changed" the same reply, which is exactly
the failure the seam is written to avoid.
386class LlmBudgetResetRequest(CtfyModel): 387 """Request body for ``POST /admin/teams/.../llm-budget/.../reset``. 388 389 ``reason`` is free-form audit metadata, capped like its 390 question-attempt twin so it round-trips through the SSE frame. 391 """ 392 393 reason: str = Field(default="", max_length=500)
Request body for POST /admin/teams/.../llm-budget/.../reset.
reason is free-form audit metadata, capped like its
question-attempt twin so it round-trips through the SSE frame.
413class LlmBudgetRow(CtfyModel): 414 """One team's LLM spend on one challenge. 415 416 ⚠️ ``budget_tokens`` is the cap **as configured right now**, not the 417 cap that applied when the spend happened: it is one deployment-wide 418 setting the operator can retune mid-event. So a row can read 419 ``spent 90k of 200k`` for a team that really was refused at 50k, and 420 the honest place to see what happened *then* is the 421 ``llm_quota_exhausted`` activity row, which carries the cap it 422 crossed. Reporting a per-row historical cap would mean storing one, 423 which is a column that exists only to be believed. 424 425 ``exhausted`` is derived from the pair rather than stored, for the 426 same reason: it is a statement about the two numbers on this row and 427 must not disagree with them. 428 """ 429 430 team_id: str 431 team_name: str = "" 432 competition_id: str 433 challenge_id: str 434 spent_tokens: int 435 budget_tokens: int 436 #: Whether this team still holds a live grant for this challenge. 437 #: A budget outlives the box that spent it — the key is 438 #: ``(team, competition, challenge)`` precisely so a fresh instance 439 #: does not refill — so a row with no live grant is the normal 440 #: shape after a box stops, not an orphan. 441 live: bool = False 442 443 @computed_field 444 @property 445 def exhausted(self) -> bool: 446 """⚠️ A **`computed_field`**, unlike ``TeamState.is_disqualified`` 447 which is deliberately a plain ``@property``. Opposite reasons, 448 and the distinction is the model's kind: that one is *state*, so 449 ``model_dump`` feeds ``to_orm`` and every dumped key has to be a 450 column; this is a *wire* model whose whole purpose is to reach a 451 client, and a plain property simply never leaves the process. 452 """ 453 return self.budget_tokens > 0 and self.spent_tokens >= self.budget_tokens
One team's LLM spend on one challenge.
⚠️ budget_tokens is the cap as configured right now, not the
cap that applied when the spend happened: it is one deployment-wide
setting the operator can retune mid-event. So a row can read
spent 90k of 200k for a team that really was refused at 50k, and
the honest place to see what happened then is the
llm_quota_exhausted activity row, which carries the cap it
crossed. Reporting a per-row historical cap would mean storing one,
which is a column that exists only to be believed.
exhausted is derived from the pair rather than stored, for the
same reason: it is a statement about the two numbers on this row and
must not disagree with them.
443 @computed_field 444 @property 445 def exhausted(self) -> bool: 446 """⚠️ A **`computed_field`**, unlike ``TeamState.is_disqualified`` 447 which is deliberately a plain ``@property``. Opposite reasons, 448 and the distinction is the model's kind: that one is *state*, so 449 ``model_dump`` feeds ``to_orm`` and every dumped key has to be a 450 column; this is a *wire* model whose whole purpose is to reach a 451 client, and a plain property simply never leaves the process. 452 """ 453 return self.budget_tokens > 0 and self.spent_tokens >= self.budget_tokens
⚠️ A computed_field, unlike TeamState.is_disqualified
which is deliberately a plain @property. Opposite reasons,
and the distinction is the model's kind: that one is state, so
model_dump feeds to_orm and every dumped key has to be a
column; this is a wire model whose whole purpose is to reach a
client, and a plain property simply never leaves the process.
86class LoginRequest(CtfyModel): 87 email: EmailStr 88 password: str = Field(min_length=1, max_length=256)
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
33class MeResponse(UserInfo): 34 """``GET /me`` payload — the logged-in user's full profile. 35 36 Extends :class:`UserInfo` with auth-state context so the frontend 37 can render Settings (linked providers, admin badge) with a single 38 request. ``providers`` lists provider names currently bound 39 (e.g. ``["github", "google"]``); ``token_kind`` distinguishes 40 ``"user"`` (browser session) from ``"fine_grained"`` (CLI / agent 41 token). 42 43 ``is_admin`` is the OR of admin + super_admin so existing clients 44 keep working without inspecting ``role`` directly. 45 46 ``competition_teams`` carries every per-comp team the user is 47 currently on. The legacy ``current_team_id`` / 48 ``current_team_name`` fields were dropped — there is no global 49 "current team" any more. 50 """ 51 52 email: str = "" 53 is_admin: bool = False 54 role: Literal["user", "admin", "super_admin"] = "user" 55 providers: list[str] = Field(default_factory=list) 56 token_kind: str = "user" 57 profile_visibility: dict[str, bool] = Field(default_factory=dict) 58 competition_teams: list[CompetitionMembershipInfo] = Field(default_factory=list) 59 # Competitions this user per-comp-administers. Empty for global 60 # admins/super_admins (already a superset — the frontend treats 61 # ``is_admin`` as "admin of every competition"). 62 competition_admin_ids: list[str] = Field(default_factory=list) 63 # Competitions whose registration roster this user may read + rule on. 64 # A superset of ``competition_admin_ids`` (an admin can review), plus 65 # any narrow ``reviewer`` grant. Drives whether the frontend shows the 66 # registrations link; the server re-checks on every request. 67 competition_reviewer_ids: list[str] = Field(default_factory=list)
GET /me payload — the logged-in user's full profile.
Extends UserInfo with auth-state context so the frontend
can render Settings (linked providers, admin badge) with a single
request. providers lists provider names currently bound
(e.g. ["github", "google"]); token_kind distinguishes
"user" (browser session) from "fine_grained" (CLI / agent
token).
is_admin is the OR of admin + super_admin so existing clients
keep working without inspecting role directly.
competition_teams carries every per-comp team the user is
currently on. The legacy current_team_id /
current_team_name fields were dropped — there is no global
"current team" any more.
58class MetaChallenges(CtfyModel): 59 """Source-control identity of the challenges repository, plus the 60 public count of available challenges. 61 62 ``commit_sha`` is None when the challenges directory isn't a git 63 working tree (e.g. ``CTFY_CHALLENGES_REPO=""`` or a bare checkout). 64 ``total`` is visible to everyone — the list itself is browsable. 65 66 ``suites`` / ``environments`` are the other two axes of "how big is 67 the catalog" (see :class:`~ctfy.core.challenge.CatalogCounts`): 68 distinct source buckets, and the subset that ships a runnable 69 Docker topology rather than being a pure QA item. Both are derived 70 from the same public spec list, so they carry no more information 71 than ``GET /challenges`` already does. 72 """ 73 74 commit_sha: str | None = None 75 commit_url: str | None = None 76 repo_url: str = "" 77 total: int = 0 78 suites: int = 0 79 environments: int = 0
Source-control identity of the challenges repository, plus the public count of available challenges.
commit_sha is None when the challenges directory isn't a git
working tree (e.g. CTFY_CHALLENGES_REPO="" or a bare checkout).
total is visible to everyone — the list itself is browsable.
suites / environments are the other two axes of "how big is
the catalog" (see ~ctfy.core.challenge.CatalogCounts):
distinct source buckets, and the subset that ships a runnable
Docker topology rather than being a pure QA item. Both are derived
from the same public spec list, so they carry no more information
than GET /challenges already does.
44class MetaPlatform(CtfyModel): 45 """Source-control identity of the running ctfy build. 46 47 ``commit_sha`` is None when the build couldn't determine its own 48 revision (no ``CTFY_GIT_COMMIT``, no usable git binary). 49 ``commit_url`` is built from ``repo_url`` + sha so the frontend 50 doesn't have to know the URL convention. 51 """ 52 53 commit_sha: str | None = None 54 commit_url: str | None = None 55 repo_url: str = ""
Source-control identity of the running ctfy build.
commit_sha is None when the build couldn't determine its own
revision (no CTFY_GIT_COMMIT, no usable git binary).
commit_url is built from repo_url + sha so the frontend
doesn't have to know the URL convention.
82class MetaResponse(CtfyModel): 83 version: str = "" 84 platform: MetaPlatform = Field(default_factory=MetaPlatform) 85 challenges: MetaChallenges = Field(default_factory=MetaChallenges) 86 started_at_ts: float = 0.0 87 server_time_ts: float = 0.0 88 # Cluster-wide counters — admin-only. ``None`` for non-admin 89 # callers so the frontend can distinguish "not authorised" from 90 # "happens to be zero" and hide the segment entirely instead of 91 # rendering a misleading "0 nodes". 92 teams_total: int | None = None 93 users_total: int | None = None 94 nodes_total: int | None = None 95 nodes_healthy: int | None = None 96 running_instances: int | None = None 97 solves_total: int | None = None 98 # Public surface bit for the super-admin docker-exec feature. The 99 # frontend uses this to decide whether to render the Shell entry 100 # in the admin instance list — false means the routes aren't 101 # mounted, so any UI link would 404. Never carries the 102 # token/recording details — those stay server-side. 103 admin_shell_enabled: bool = False 104 # Where a defender's own `ssh` should knock, and — by being empty — 105 # whether the channel exists at all. ``POST /instances/{id}/ssh`` 106 # refuses with 503 unless the CA key *and* the bastion host are both 107 # configured, so these two report the same pair rather than the raw 108 # config values: advertising a host with no CA would put a command 109 # on the defence card that always 503s, which a player reads as 110 # their box being broken. Public deliberately, unlike the counters 111 # below — it is the address the player types, and knowing where to 112 # knock buys nothing without a certificate the platform mints per 113 # instance. ``ssh_bastion_port`` is 0 (not its 2222 config default) 114 # when the channel is off, so neither field can imply a bastion the 115 # other denies. 116 ssh_bastion_host: str = "" 117 ssh_bastion_port: int = 0 118 # Operator-set deployment label (``CTFY_ENVIRONMENT_LABEL``), shown 119 # as a badge next to the brand so a staging tab is visually distinct 120 # from production. Empty = no badge. Public on purpose: it names the 121 # deployment, not anything about its internals. 122 environment_label: str = "" 123 # Canonical tier derived from the label (``ctfy.core.environment``). 124 # The client keys the frame / favicon tone and the prod-only confirm 125 # friction off this instead of substring-matching the free-text label 126 # itself, so classification lives in one place. ``prod`` is the 127 # fail-safe default (the unmarked deployment is production). 128 environment: Environment = "prod"
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
568class MilestoneProgress(CtfyModel): 569 """Per-challenge milestone progress for the calling user. 570 571 Returned by ``GET /me/milestone-progress``. One row per challenge 572 with at least one captured question, regardless of whether the 573 challenge is fully solved. The Challenges list page renders a 574 progress bar from ``len(solved_question_ids) / total_questions`` 575 so a player can see "2/5 milestones" instead of an all-or-nothing 576 solved badge. 577 578 Like ``MySolveSummary`` this is user-scoped: ``solved_question_ids`` 579 aggregates every question id the user captured for the challenge 580 across every team they have ever been on. The optional 581 ``competition_id`` query param narrows the aggregation to solves 582 stamped against the user's team in that comp. 583 """ 584 585 challenge_id: str 586 solved_question_ids: list[str] = Field(default_factory=list) 587 total_questions: int = 0
Per-challenge milestone progress for the calling user.
Returned by GET /me/milestone-progress. One row per challenge
with at least one captured question, regardless of whether the
challenge is fully solved. The Challenges list page renders a
progress bar from len(solved_question_ids) / total_questions
so a player can see "2/5 milestones" instead of an all-or-nothing
solved badge.
Like MySolveSummary this is user-scoped: solved_question_ids
aggregates every question id the user captured for the challenge
across every team they have ever been on. The optional
competition_id query param narrows the aggregation to solves
stamped against the user's team in that comp.
26class ModelStanding(CtfyModel): 27 """One model's overall standing plus its per-dimension breakdowns.""" 28 29 rank: int = 0 30 model_id: str = "" 31 display_name: str = "" 32 flavor: str = "" 33 #: owning vendor / org label (blank when unattributed). 34 vendor: str = "" 35 runs: int = 0 36 challenges_attempted: int = 0 37 challenges_solved: int = 0 38 solve_rate: float = 0.0 39 questions_solved: int = 0 40 input_tokens: int = 0 41 output_tokens: int = 0 42 #: input + output across all the model's succeeded runs. 43 total_tokens: int = 0 44 #: cost-efficiency: total tokens per solved challenge (0 if none solved). 45 tokens_per_solve: float = 0.0 46 #: estimated USD cost (tokens × the model's price); 0 when unpriced. 47 cost_usd: float = 0.0 48 #: USD per solved challenge (0 when unpriced or nothing solved). 49 cost_per_solve: float = 0.0 50 avg_turns: float = 0.0 51 #: deepest pass@k sampling — max succeeded runs against any one challenge. 52 max_samples: int = 0 53 #: corpus version these runs were graded against (the model's modal sha). 54 corpus_sha: str = "" 55 #: dimension name (``difficulty`` / ``bucket`` / ``tag``) → its cells. 56 breakdowns: dict[str, list[LeaderboardCell]] = Field(default_factory=dict)
One model's overall standing plus its per-dimension breakdowns.
74class MyAchievementsResponse(CtfyModel): 75 """The self-view: unlocked + locked (with secret-hiding).""" 76 77 unlocked: list[TeamAchievement] = Field(default_factory=list) 78 locked: list[AchievementCatalogEntry] = Field(default_factory=list) 79 summary: AchievementSummary = Field(default_factory=AchievementSummary)
The self-view: unlocked + locked (with secret-hiding).
14class MyReactionsResponse(CtfyModel): 15 """The calling user's active reaction chips on a challenge. 16 17 Multi-select: a player can stack any subset of the 9 reactions. 18 ``reactions`` is the unordered set the player has currently 19 toggled on; empty list means none. ``updated_at`` is the most 20 recent change among the active rows, used as a hydration hint 21 by the optimistic UI. 22 """ 23 24 reactions: list[Reaction] = Field(default_factory=list) 25 updated_at: datetime | None = None
The calling user's active reaction chips on a challenge.
Multi-select: a player can stack any subset of the 9 reactions.
reactions is the unordered set the player has currently
toggled on; empty list means none. updated_at is the most
recent change among the active rows, used as a hydration hint
by the optimistic UI.
107class MyRegistrationResponse(CtfyModel): 108 """``GET /competitions/{id}/registration`` — the caller's own dossier. 109 110 Carries the form spec alongside the values so the wizard can render 111 from one round trip, and ``missing_required`` so it can tell the user 112 exactly what still blocks submission without reimplementing the 113 server's rule. 114 """ 115 116 competition_id: str 117 form: RegistrationFormSpec 118 registered: bool = False 119 submitted_at: datetime | None = None 120 updated_at: datetime | None = None 121 missing_required: list[str] = Field(default_factory=list) 122 123 real_name: str = "" 124 phone: str = "" 125 mailing_address: str = "" 126 gender: str = "" 127 organization: str = "" 128 academic_stage: str = "" 129 grade: str = "" 130 undergrad_school: str = "" 131 specialties: list[str] = Field(default_factory=list) 132 133 # Team-scoped answers, echoed so the captain's wizard round-trips. 134 team_id: str = "" 135 team_name: str = "" 136 team_honors: str = "" 137 team_has_logo: bool = False 138 is_captain: bool = False 139 review_status: RegistrationReviewStatus = "approved" 140 review_note: str = ""
GET /competitions/{id}/registration — the caller's own dossier.
Carries the form spec alongside the values so the wizard can render
from one round trip, and missing_required so it can tell the user
exactly what still blocks submission without reimplementing the
server's rule.
540class MySolveSummary(CtfyModel): 541 """Per-challenge solve summary for the calling team. 542 543 Returned by ``GET /me/solves``. One row per challenge with at least 544 one captured flag. ``best_rank`` is the team's best (smallest) rank 545 across the flags they captured for the challenge — drives the 546 1血/2血/3血 badge on the challenge cards. Multi-flag challenges may 547 have different ranks per flag; reporting the best one gives players 548 credit for whichever piece they nailed first. 549 """ 550 551 challenge_id: str 552 best_rank: int 553 solved_at: datetime | None = None 554 # Whether **every** capture the team holds on this challenge is on 555 # the competition's board. 556 # 557 # ⚠️ ``all``, not ``any``: the card renders this as "some of what you 558 # captured here does not count", and a partial split has to say so. 559 # ``any`` would stay quiet about two uncounted flags out of three, 560 # which is the same silence as before the field existed. 561 # 562 # Unscoped (``competition_id=""``) this is always ``True``: the 563 # cross-competition history applies no window, so no capture there 564 # is "not counted" — see ``/me/solves``. 565 ranked: bool = True
Per-challenge solve summary for the calling team.
Returned by GET /me/solves. One row per challenge with at least
one captured flag. best_rank is the team's best (smallest) rank
across the flags they captured for the challenge — drives the
1血/2血/3血 badge on the challenge cards. Multi-flag challenges may
have different ranks per flag; reporting the best one gives players
credit for whichever piece they nailed first.
70class NodeHeartbeat(CtfyModel): 71 """Body for ``POST /nodes/heartbeat``. 72 73 Node reports its live counts + resource utilisation on every beat. 74 The percentages are 0–100; 0 is a safe default for the first beat 75 where psutil hasn't had a prior sample to diff against. 76 """ 77 78 id: str 79 running: int = 0 80 capacity: int = 50 81 cpu_percent: float = 0.0 82 memory_percent: float = 0.0 83 # Physical RAM in MiB (not a percentage). Memory-aware admission 84 # needs the absolute size; 0 = not reported, which older node builds 85 # send and which admission reads as "unknown, fall back to slots". 86 memory_total_mb: float = 0.0 87 disk_percent: float = 0.0 88 # Unix seconds when the node sampled the metrics. The server stores 89 # ``received_at - sampled_at`` as one-way latency on the resulting 90 # health sample. Default 0 means "not measured" — older node builds 91 # that haven't been upgraded keep working. 92 sampled_at: float = 0.0
Body for POST /nodes/heartbeat.
Node reports its live counts + resource utilisation on every beat. The percentages are 0–100; 0 is a safe default for the first beat where psutil hasn't had a prior sample to diff against.
29class NodeInfo(CtfyModel): 30 id: str 31 url: str 32 display_name: str = "" 33 capacity: int = 0 34 running: int = 0 35 is_healthy: bool = True 36 last_heartbeat: datetime | None = None 37 # First successful registration. ``None`` only for rows that 38 # pre-date the field; read-path code in 39 # ``ctfy/server/routes/nodes.py`` falls back to ``last_heartbeat`` 40 # so the admin UI never shows "—" for established nodes. 41 registered_at: datetime | None = None 42 labels: dict[str, str] = Field(default_factory=dict) 43 # Latest resource sample reported on heartbeat (0–100). 44 cpu_percent: float = 0.0 45 memory_percent: float = 0.0 46 disk_percent: float = 0.0 47 # Physical RAM in MiB; 0 = not reported yet. Surfaced so the admin 48 # Nodes page can show real headroom rather than a bare percentage. 49 memory_total_mb: float = 0.0 50 # Owning vendor slug for a bring-your-own-node; "" = platform-shared. 51 owner_vendor_id: str = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
141class NodeInviteInfo(CtfyModel): 142 """Admin-facing summary (``GET /nodes/invites``). No token fields.""" 143 144 id: str 145 created_at: datetime | None = None 146 expires_at: datetime | None = None 147 status: str # "active" | "consumed" | "expired" 148 consumed_by_node_id: str = "" 149 owner_vendor_id: str = "" # vendor binding, if any ("" = shared)
Admin-facing summary (GET /nodes/invites). No token fields.
54class NodePatch(CtfyModel): 55 """Body for ``PATCH /admin/nodes/{node_id}``. 56 57 Fields are optional — absent fields keep their current value. 58 display_name is the common case; labels allow adding/replacing the 59 full dict (partial label edits need a second round-trip). 60 ``owner_vendor_id`` re-assigns (or clears, with ``""``) the node's 61 vendor binding — an admin correction path complementing the 62 invite-time binding. 63 """ 64 65 display_name: str | None = None 66 labels: dict[str, str] | None = None 67 owner_vendor_id: str | None = None
Body for PATCH /admin/nodes/{node_id}.
Fields are optional — absent fields keep their current value.
display_name is the common case; labels allow adding/replacing the
full dict (partial label edits need a second round-trip).
owner_vendor_id re-assigns (or clears, with "") the node's
vendor binding — an admin correction path complementing the
invite-time binding.
22class NodeRegister(CtfyModel): 23 url: str # e.g. "http://node1:8100" 24 display_name: str # required; operators pick a human-readable label 25 capacity: int = DEFAULT_NODE_CAPACITY 26 labels: dict[str, str] = Field(default_factory=dict) # optional metadata
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
95class NodeRegisterResponse(NodeInfo): 96 """Registration response carries the node's bearer token (plaintext). 97 98 Returned exactly once, at registration. The same token is used in 99 both directions: 100 101 * **node→platform** (heartbeat, deregister) — node presents it as 102 the Bearer; platform looks the row up by comparing plaintexts. 103 * **platform→node** (start/stop/status) — platform presents the 104 same plaintext on every call; node verifies against its in-memory 105 copy. 106 107 Re-registration rotates the token. The node never persists it on 108 disk; "restart = re-register = fresh credential". 109 """ 110 111 token: str = ""
Registration response carries the node's bearer token (plaintext).
Returned exactly once, at registration. The same token is used in both directions:
- node→platform (heartbeat, deregister) — node presents it as the Bearer; platform looks the row up by comparing plaintexts.
- platform→node (start/stop/status) — platform presents the same plaintext on every call; node verifies against its in-memory copy.
Re-registration rotates the token. The node never persists it on disk; "restart = re-register = fresh credential".
28class NotificationCategoryInfo(CtfyModel): 29 id: str 30 title: str 31 description: str 32 #: Account & security. Not switchable; the API refuses to store a cell. 33 forced: bool 34 #: ``transactional`` | ``marketing``. A marketing category is off 35 #: until the recipient explicitly turns it on, and a recorded 36 #: opt-out on one cannot be reverted away — so the UI must not 37 #: offer "revert to default" there, which would imply reverting 38 #: could switch it back on. 39 policy: str = "transactional" 40 channels: dict[str, NotificationChannelCell]
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
14class NotificationChannelCell(CtfyModel): 15 """One (category, channel) cell, as the settings UI needs it. 16 17 Three values rather than one because storage is sparse: ``effective`` 18 is what happens now, ``default`` is what reverting would restore, and 19 ``explicit`` says whether the user chose this or is inheriting it — 20 which is also whether a "revert" control should appear at all. 21 """ 22 23 effective: bool 24 explicit: bool 25 default: bool
One (category, channel) cell, as the settings UI needs it.
Three values rather than one because storage is sparse: effective
is what happens now, default is what reverting would restore, and
explicit says whether the user chose this or is inheriting it —
which is also whether a "revert" control should appear at all.
47class NotificationPreferenceUpdate(CtfyModel): 48 """PATCH body for one cell.""" 49 50 category: str 51 channel: str = "email" 52 enabled: bool
PATCH body for one cell.
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
17class OAuthProviderInfo(CtfyModel): 18 name: str # "github" | "google" | "oidc" 19 enabled: bool 20 authorize_path: str # relative, e.g. "/api/v1/auth/login/github" 21 # Operator-supplied button label. Empty for the built-in providers, 22 # whose names the client already translates ("Sign in with GitHub"). 23 # A generic OIDC provider has no name the client could know — the 24 # only useful label is the one the operator chose ("清华大学统一身份 25 # 认证"), so it travels on the wire rather than living in a bundle. 26 display_name: str = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
29class PasswordAuthInfo(CtfyModel): 30 """Gating flag for the email+password sign-in form on the login page.""" 31 32 enabled: bool = False
Gating flag for the email+password sign-in form on the login page.
757class PatchSubmissionCreate(CtfyModel): 758 """An AWD+ defence submission, addressed one of two ways. 759 760 ``instance_id`` is the handle when a box is running, exactly as on 761 :class:`SubmissionCreate`: the instance row carries the challenge, 762 the team and the competition, so the caller repeats none of them. 763 764 ⚠️ **But a defence submission does not need a box, and requiring one 765 made the format's natural order unplayable.** Judging builds from 766 the pristine tree plus the submitted bytes and never touches the 767 instance, ``GET /challenges/{id}/defence/source`` is already 768 challenge-scoped for exactly this reason, and upsolve, virtual 769 participation and a post-event fix all mean patching with nothing 770 running. So ``challenge_id`` (plus ``competition_id``, which scopes 771 which team is credited) addresses it directly. 772 773 Naming neither is refused rather than guessed at: falling back to 774 "the caller's only instance" would silently judge a challenge they 775 did not name. Naming an instance *and* a different challenge is 776 refused for the same reason. 777 778 **This is not a weaker authorisation than the instance form.** There 779 is no team on the wire either way — the credited team is resolved 780 from the caller's own membership, as ``GET /patches`` already does — 781 so the challenge form has no id to spoof at all. 782 """ 783 784 instance_id: str = "" 785 #: The challenge to defend, when no box is running. Ignored (but 786 #: cross-checked) when ``instance_id`` is given. 787 challenge_id: str = "" 788 #: Which competition credits the patch. Empty means the caller's 789 #: unscoped team, the same convention ``GET /patches`` follows. 790 competition_id: str = "" 791 #: Where the patched bytes come from. 792 #: 793 #: ``upload`` carries them in ``files``. ``live`` reads them out of 794 #: the player's own running box — they SSH in, edit, and submit with 795 #: no payload at all, which is how an AWD player actually works. 796 #: 797 #: Explicit rather than inferred from an empty ``files``: an empty 798 #: map is also what a client bug produces, and silently switching 799 #: channel on a bug would grade something the player never meant to 800 #: send. 801 source: Literal["upload", "live"] = "upload" 802 #: ``{challenge-relative path: base64(bytes)}``. Base64 because a 803 #: patch target may legitimately be binary, and JSON has no way to 804 #: carry those bytes intact otherwise. Must be empty when 805 #: ``source="live"``. 806 files: dict[str, str] = Field(default_factory=dict)
An AWD+ defence submission, addressed one of two ways.
instance_id is the handle when a box is running, exactly as on
SubmissionCreate: the instance row carries the challenge,
the team and the competition, so the caller repeats none of them.
⚠️ But a defence submission does not need a box, and requiring one
made the format's natural order unplayable. Judging builds from
the pristine tree plus the submitted bytes and never touches the
instance, GET /challenges/{id}/defence/source is already
challenge-scoped for exactly this reason, and upsolve, virtual
participation and a post-event fix all mean patching with nothing
running. So challenge_id (plus competition_id, which scopes
which team is credited) addresses it directly.
Naming neither is refused rather than guessed at: falling back to "the caller's only instance" would silently judge a challenge they did not name. Naming an instance and a different challenge is refused for the same reason.
This is not a weaker authorisation than the instance form. There
is no team on the wire either way — the credited team is resolved
from the caller's own membership, as GET /patches already does —
so the challenge form has no id to spoof at all.
809class PatchSubmissionInfo(CtfyModel): 810 """A defence submission's status — deliberately without its code. 811 812 The stored row keeps the submitted bytes so a verdict can be 813 re-derived after a checker fix, but a *status* view is polled, and 814 shipping every patch body on every poll is bulk nobody asked for. 815 """ 816 817 id: str 818 challenge_id: str 819 competition_id: str 820 submitted_at: datetime | None = None 821 #: The paths this submission replaced. 822 files: list[str] = Field(default_factory=list) 823 status: str = "pending" 824 #: Empty until judged, and empty forever on a submission the 825 #: platform failed to judge — our outage is not the player's loss. 826 verdict: str = "" 827 detail: str = "" 828 judged_at: datetime | None = None
A defence submission's status — deliberately without its code.
The stored row keeps the submitted bytes so a verdict can be re-derived after a checker fix, but a status view is polled, and shipping every patch body on every poll is bulk nobody asked for.
323class PlatformSettingInfo(CtfyModel): 324 """One row from ``GET /admin/platform-settings``. 325 326 ``value`` is the live resolved value (DB > env > default); the 327 other two fields show what env and the built-in default would 328 resolve to in isolation, so the UI can render a tooltip 329 explaining what a ``DELETE`` would land on. ``source`` is the 330 tier the current value came from. 331 """ 332 333 key: str = "" 334 type: str = "" 335 description: str = "" 336 value: Any = None 337 source: str = "" # "db" | "env" | "default" 338 env_value: Any = None 339 default_value: Any = None
One row from GET /admin/platform-settings.
value is the live resolved value (DB > env > default); the
other two fields show what env and the built-in default would
resolve to in isolation, so the UI can render a tooltip
explaining what a DELETE would land on. source is the
tier the current value came from.
342class PlatformSettingPatch(CtfyModel): 343 """Body of ``PATCH /admin/platform-settings/{key}``. 344 345 ``value`` is JSON-typed; the resolver's per-key validator decides 346 whether it's accepted. 347 """ 348 349 value: Any = None
Body of PATCH /admin/platform-settings/{key}.
value is JSON-typed; the resolver's per-key validator decides
whether it's accepted.
358class PlayerShellCreate(CtfyModel): 359 """Ask for a shell into your own AWD+ box. 360 361 Note what is *absent*: no container. The admin shell takes one 362 because a super-admin legitimately picks among an instance's 363 containers; accepting one here would hand the caller the choice the 364 endpoint exists to deny them. The platform resolves the target from 365 the challenge's own ``patch.live.service``. 366 """ 367 368 #: ``bash`` or ``sh``. A tiny allow-list rather than free text — 369 #: the value becomes the exec'd command on the node. 370 shell: str = "bash"
Ask for a shell into your own AWD+ box.
Note what is absent: no container. The admin shell takes one
because a super-admin legitimately picks among an instance's
containers; accepting one here would hand the caller the choice the
endpoint exists to deny them. The platform resolves the target from
the challenge's own patch.live.service.
424class PlayerShellTicket(CtfyModel): 425 """A single-use ticket for one shell session. 426 427 Two-step because a browser cannot attach an ``Authorization`` header 428 to a WebSocket upgrade. Worth keeping for terminal clients too: it 429 means the long-lived bearer never rides in a URL. 430 """ 431 432 session_id: str 433 #: Burns on first WebSocket use and is never returned again. 434 session_token: str 435 #: Platform-relative; the client makes it absolute against its own 436 #: base URL, since only the client knows the scheme it reached us on. 437 ws_url: str 438 #: The resolved compose container. Empty for the browser, which 439 #: never needs it; the bastion does, because it opens the node 440 #: WebSocket itself rather than going through the platform page. 441 container: str = "" 442 #: Seconds the unburned ticket stays valid. 443 expires_in: int 444 #: The compose service the session will land in. Echoed back so a 445 #: player knows which container they are in without reading the 446 #: challenge's metadata — the platform resolved it, so it says so. 447 service: str
A single-use ticket for one shell session.
Two-step because a browser cannot attach an Authorization header
to a WebSocket upgrade. Worth keeping for terminal clients too: it
means the long-lived bearer never rides in a URL.
101class ProfilePatchRequest(CtfyModel): 102 """Body for ``PATCH /me/profile``. Every field is optional so the 103 UI can send only the diff; the route only writes the fields that 104 were present in the JSON. ``None`` means "clear this field".""" 105 106 # ``Field(default=...)`` with a non-None sentinel is awkward in 107 # pydantic; we instead declare each field as Optional + default-None 108 # and rely on the request's raw dict (via ``model_fields_set``) to 109 # know which keys were actually present. 110 bio: str | None = Field(default=None, max_length=280) 111 country: str | None = None 112 website_url: WebUrl | None = Field(default=None, max_length=200) 113 timezone: str | None = Field(default=None, max_length=80) 114 # Values are *handles*, not URLs — ``{"github": "octocat"}``. The 115 # client owns href construction from a fixed per-provider template 116 # (``https://github.com/<handle>``), and the two providers that do 117 # accept a free-form value run it through ``normalizeUrl`` / 118 # ``mastodonUrl``, both of which only ever emit ``https://…``. So a 119 # scheme constraint here would be the wrong shape: it would reject 120 # every legitimate handle while defending against nothing. What was 121 # actually missing is a size bound. 122 social_links: dict[str, str] | None = None 123 124 @field_validator("social_links") 125 @classmethod 126 def _bound_social_links(cls, v: dict[str, str] | None) -> dict[str, str] | None: 127 if v is None: 128 return None 129 if len(v) > 10: 130 raise ValueError("social_links may contain at most 10 entries") 131 if any(len(handle) > 200 for handle in v.values()): 132 raise ValueError("each social link must be at most 200 characters") 133 return v
Body for PATCH /me/profile. Every field is optional so the
UI can send only the diff; the route only writes the fields that
were present in the JSON. None means "clear this field".
183class ProfileStats(CtfyModel): 184 """Aggregate per-team analytics surfaced on the public profile. 185 186 Every section is independently visibility-gated; non-owner viewers 187 see ``[]`` for a section the team has marked private. Owner and 188 admin always see the full payload.""" 189 190 calendar: list[CalendarBucket] = Field(default_factory=list) 191 by_tag: list[TagStat] = Field(default_factory=list) 192 by_difficulty: list[DifficultyStat] = Field(default_factory=list) 193 solve_trend: list[TrendPoint] = Field(default_factory=list)
Aggregate per-team analytics surfaced on the public profile.
Every section is independently visibility-gated; non-owner viewers
see [] for a section the team has marked private. Owner and
admin always see the full payload.
47class ProvidersResponse(CtfyModel): 48 providers: list[OAuthProviderInfo] = Field(default_factory=list) 49 password_auth: PasswordAuthInfo = Field(default_factory=PasswordAuthInfo) 50 email_code_auth: EmailCodeAuthInfo = Field(default_factory=EmailCodeAuthInfo)
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
514class QaChallengeView(CtfyModel): 515 """One QA challenge as seen by a team on the quiz surface. 516 517 Carries the full question / choices / description data alongside 518 per-team solve and attempt state, so the frontend can render the 519 quiz list with progress badges in a single round-trip. 520 """ 521 522 id: str 523 name: str 524 # Provenance bucket prefix — ``SECQA`` / ``MMLU-CS`` / ``CTI-MCQ`` 525 # / etc — derived from the challenge id (everything before the 526 # trailing numeric suffix). Lets the frontend group / filter 527 # without splitting the id again client-side. 528 bucket: str 529 description: str 530 difficulty: str = "" 531 tags: list[str] = Field(default_factory=list) 532 questions: list[QuestionPublicInfo] = Field(default_factory=list) 533 # Question ids the calling team has already captured. 534 solved_question_ids: list[str] = Field(default_factory=list) 535 # Question ids the team has submitted at least one wrong answer 536 # against but never solved. Drives the "Try again" amber badge. 537 attempted_wrong_question_ids: list[str] = Field(default_factory=list)
One QA challenge as seen by a team on the quiz surface.
Carries the full question / choices / description data alongside per-team solve and attempt state, so the frontend can render the quiz list with progress badges in a single round-trip.
497class QaSubmissionCreate(CtfyModel): 498 """Request body for ``POST /qa/submissions`` — the instance-free 499 submission path for pure question-answer challenges. 500 501 No ``instance_id``: QA challenges have no Docker infra to launch, 502 so there's no per-team instance row to read context off. The 503 caller passes ``challenge_id`` + ``competition_id`` explicitly, 504 and the server resolves the submitter's team for the named 505 competition via the standard auth helper. 506 """ 507 508 challenge_id: str 509 competition_id: str 510 question_id: str = "answer" 511 answer: str | list[str]
Request body for POST /qa/submissions — the instance-free
submission path for pure question-answer challenges.
No instance_id: QA challenges have no Docker infra to launch,
so there's no per-team instance row to read context off. The
caller passes challenge_id + competition_id explicitly,
and the server resolves the submitter's team for the named
competition via the standard auth helper.
471class QuestionAttemptResetInfo(CtfyModel): 472 """Response from the admin reset endpoint. Echoes the new baseline 473 row so the UI can update its local state without re-fetching the 474 instance. 475 """ 476 477 team_id: str 478 challenge_id: str 479 question_id: str 480 reset_at: datetime 481 reset_by_user_id: str 482 reason: str
Response from the admin reset endpoint. Echoes the new baseline row so the UI can update its local state without re-fetching the instance.
375class QuestionAttemptResetRequest(CtfyModel): 376 """Request body for ``POST /admin/teams/.../reset-attempts``. 377 378 ``reason`` is free-form audit metadata recorded onto the activity 379 log row; capped at 500 chars so it round-trips through the SSE 380 payload without bloating the wire frame. 381 """ 382 383 reason: str = Field(default="", max_length=500)
Request body for POST /admin/teams/.../reset-attempts.
reason is free-form audit metadata recorded onto the activity
log row; capped at 500 chars so it round-trips through the SSE
payload without bloating the wire frame.
20class QuestionPublicInfo(CtfyModel): 21 """One question, as exposed to agents and the web UI. 22 23 Carries the prompt + mode + ``choices:`` enum (when present) so the 24 UI can render a radio/checkbox/free-text input without an extra 25 round-trip to fetch the spec. ``answer`` is NEVER projected — the 26 groundtruth lives on the platform either as metadata.yaml or as 27 a per-instance mint. 28 """ 29 30 id: str 31 prompt: str 32 # Grading mode: ``dynamic`` | ``static`` | ``single_select`` | 33 # ``multi_select``. ``None`` only on malformed metadata (which the 34 # audit catches before deploy). 35 mode: str | None = None 36 # Closed-list options for ``single_select`` / ``multi_select``; 37 # ``None`` for free-form modes. 38 choices: list[str] | None = None 39 # Question ids that must be answered correctly first. The 40 # platform gates visibility / submission acceptance on this list 41 # (a question with non-empty ``requires:`` is hidden until every 42 # listed predecessor is solved). 43 requires: list[str] = Field(default_factory=list)
One question, as exposed to agents and the web UI.
Carries the prompt + mode + choices: enum (when present) so the
UI can render a radio/checkbox/free-text input without an extra
round-trip to fetch the spec. answer is NEVER projected — the
groundtruth lives on the platform either as metadata.yaml or as
a per-instance mint.
82class RecentUnlock(CtfyModel): 83 """A single row in the platform-wide recent-unlocks feed.""" 84 85 team_id: str 86 team_name: str 87 achievement_id: str 88 name: str 89 icon: str 90 tier: str 91 unlocked_at: datetime | None = None
A single row in the platform-wide recent-unlocks feed.
74class RegisterRequest(CtfyModel): 75 email: EmailStr 76 # Length minimum mirrors ``MIN_PASSWORD_LENGTH`` in 77 # ``ctfy.server.password``. ``validate_password_strength`` runs the 78 # full check (length + common-password reject) inside the route 79 # handler so the rule lives in one place; this floor is a cheap 80 # client-side hint that catches obviously short input before it 81 # reaches argon2. 82 password: str = Field(min_length=12, max_length=256) 83 display_name: str = Field(default="", max_length=120)
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
36class RegistrationFieldSpec(CtfyModel): 37 """One field the competition's form should render. 38 39 The server drives the form rather than the frontend hard-coding it, 40 so an organiser flipping a field from optional to required takes 41 effect without a redeploy — and so the client cannot disagree with 42 the server about what is mandatory. 43 """ 44 45 name: str 46 policy: RegistrationFieldPolicy 47 # True for the fields the captain answers once for the whole squad 48 # (team logo, team honours) rather than each member answering. 49 team_scoped: bool = False 50 # True for 个人敏感信息 — the frontend renders the consent notice and 51 # the "organisers only" hint against exactly these. 52 sensitive: bool = False
One field the competition's form should render.
The server drives the form rather than the frontend hard-coding it, so an organiser flipping a field from optional to required takes effect without a redeploy — and so the client cannot disagree with the server about what is mandatory.
55class RegistrationFormSpec(CtfyModel): 56 """Everything the client needs to render + gate the wizard.""" 57 58 competition_id: str 59 fields: list[RegistrationFieldSpec] = Field(default_factory=list) 60 registration_starts_at: datetime | None = None 61 registration_ends_at: datetime | None = None 62 # "not_open" | "open" | "closed" — the client shows a countdown, the 63 # form, or a dead end respectively. 64 phase: Literal["not_open", "open", "closed"] = "open" 65 max_team_size: int = 0 66 review_required: bool = False 67 collects_sensitive_pii: bool = False
Everything the client needs to render + gate the wizard.
143class RegistrationRosterRow(CtfyModel): 144 """One entrant on the organiser's roster. 145 146 This is the **only** wire model that carries the sensitive fields, and 147 it is only ever returned from a route gated on 148 ``require_registration_reviewer``. Anything added here is, by 149 construction, organiser-visible personal data — think before widening 150 it. 151 """ 152 153 user_id: str 154 display_name: str = "" 155 email: str = "" 156 team_id: str = "" 157 team_name: str = "" 158 is_captain: bool = False 159 160 real_name: str = "" 161 phone: str = "" 162 mailing_address: str = "" 163 gender: str = "" 164 organization: str = "" 165 academic_stage: str = "" 166 grade: str = "" 167 undergrad_school: str = "" 168 specialties: list[str] = Field(default_factory=list) 169 170 submitted_at: datetime | None = None 171 review_status: RegistrationReviewStatus = "approved"
One entrant on the organiser's roster.
This is the only wire model that carries the sensitive fields, and
it is only ever returned from a route gated on
require_registration_reviewer. Anything added here is, by
construction, organiser-visible personal data — think before widening
it.
70class RegistrationSubmitRequest(CtfyModel): 71 """Body for ``PUT /competitions/{id}/registration``. 72 73 Every field is optional at the type level; which ones are actually 74 mandatory is decided per competition at request time against 75 ``registration_fields``. Encoding "required" in the type would hard- 76 code one event's rules into the wire contract. 77 """ 78 79 real_name: str = Field(default="", max_length=120) 80 phone: str = Field(default="", max_length=40) 81 mailing_address: str = Field(default="", max_length=500) 82 gender: Literal["", "male", "female", "other", "undisclosed"] = "" 83 organization: str = Field(default="", max_length=200) 84 academic_stage: str = Field(default="", max_length=80) 85 grade: str = Field(default="", max_length=80) 86 undergrad_school: str = Field(default="", max_length=200) 87 specialties: list[str] = Field(default_factory=list) 88 # Captain-only; ignored (not rejected) when a non-captain sends them, 89 # so a member re-submitting a form the wizard prefilled does not 422. 90 team_honors: str = Field(default="", max_length=2000) 91 92 @field_validator("phone") 93 @classmethod 94 def _check_phone(cls, v: str) -> str: 95 return validate_phone_shape(v) 96 97 @field_validator("specialties") 98 @classmethod 99 def _check_specialties(cls, v: list[str]) -> list[str]: 100 if len(v) > 12: 101 raise ValueError("specialties may contain at most 12 entries") 102 if any(len(s) > 40 for s in v): 103 raise ValueError("each specialty must be at most 40 characters") 104 return v
Body for PUT /competitions/{id}/registration.
Every field is optional at the type level; which ones are actually
mandatory is decided per competition at request time against
registration_fields. Encoding "required" in the type would hard-
code one event's rules into the wire contract.
208class RegistrationSummary(CtfyModel): 209 """Per-verdict totals for one competition's roster. 210 211 Split from the roster itself because the filter tabs need counts for 212 the *whole* competition while the list is paged — counting the rows 213 in hand would make every tab report the page size. It is also far 214 cheaper: a grouped ``COUNT`` rather than a page of decrypted 215 dossiers, which is why it can be fetched on every tab render. 216 217 Carries no personal data by construction, so it is not audited as a 218 PII read. 219 """ 220 221 competition_id: str 222 total: int = 0 223 pending: int = 0 224 approved: int = 0 225 rejected: int = 0 226 # Teams with at least one member missing a required answer. Counted 227 # in SQL against blank columns, never by reading the dossiers — an 228 # unanswered sealed field is stored empty, so this stays a count and 229 # not a bulk decrypt. Teams rather than people because that is the 230 # unit a reviewer works through. 231 incomplete_teams: int = 0
Per-verdict totals for one competition's roster.
Split from the roster itself because the filter tabs need counts for
the whole competition while the list is paged — counting the rows
in hand would make every tab report the page size. It is also far
cheaper: a grouped COUNT rather than a page of decrypted
dossiers, which is why it can be fetched on every tab render.
Carries no personal data by construction, so it is not audited as a PII read.
174class RegistrationTeamRow(CtfyModel): 175 """One squad on the organiser's review queue. 176 177 Review is a team-level verdict, so the queue is grouped by team with 178 the members nested — an organiser approves a squad after reading all 179 of its members' details, never one person at a time. 180 """ 181 182 team_id: str 183 team_name: str = "" 184 description: str = "" 185 honors: str = "" 186 has_logo: bool = False 187 captain_user_id: str = "" 188 created_at: datetime | None = None 189 review_status: RegistrationReviewStatus = "approved" 190 reviewed_by: str = "" 191 reviewed_by_name: str = "" 192 reviewed_at: datetime | None = None 193 review_note: str = "" 194 members: list[RegistrationRosterRow] = Field(default_factory=list) 195 # Members who have not completed the form yet. Surfaced as a count so 196 # the reviewer can spot an incomplete squad without opening it. 197 incomplete_members: int = 0 198 # True when a member's dossier changed after the verdict was recorded, 199 # which makes that verdict stale. Derived from ``reviewed_at`` against 200 # the members' ``updated_at`` rather than stored: a column would have 201 # to be reset on every review and cleared on every edit, and a missed 202 # write there reads exactly like "nothing changed". The alternative — 203 # reverting the team to ``pending`` on any edit — would force a 204 # re-review over a corrected phone digit. 205 edited_after_review: bool = False
One squad on the organiser's review queue.
Review is a team-level verdict, so the queue is grouped by team with the members nested — an organiser approves a squad after reading all of its members' details, never one person at a time.
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
234class ReviewDecisionRequest(CtfyModel): 235 """Body for the organiser's approve / reject on one team.""" 236 237 status: Literal["approved", "rejected", "pending"] 238 # Mandatory on rejection: a rejected team is told why, and "no reason 239 # given" turns into an organiser support ticket every time. 240 note: str = Field(default="", max_length=2000) 241 242 @field_validator("note") 243 @classmethod 244 def _note_required_on_reject(cls, v: str, info) -> str: 245 if info.data.get("status") == "rejected" and not v.strip(): 246 raise ValueError("a note explaining the rejection is required") 247 return v
Body for the organiser's approve / reject on one team.
549class ScheduledJobInfo(CtfyModel): 550 """One recurring job on ``GET /admin/scheduled-jobs``: a projection of 551 ``ScheduledJobState`` plus the code-side ``default_interval_s`` so the 552 UI can show "(default: 30s)" next to a tuned value.""" 553 554 name: str = "" 555 enabled: bool = True 556 interval_s: int = 0 557 default_interval_s: int = 0 558 last_run_at_ts: float = 0.0 559 last_status: str = "" 560 last_summary: str = "" 561 last_error: str = "" 562 last_duration_s: float = 0.0 563 run_count: int = 0 564 updated_by_user_id: str = ""
One recurring job on GET /admin/scheduled-jobs: a projection of
ScheduledJobState plus the code-side default_interval_s so the
UI can show "(default: 30s)" next to a tuned value.
567class ScheduledJobPatch(CtfyModel): 568 """Body of ``PATCH /admin/scheduled-jobs/{name}``. Either field may be 569 omitted; ``interval_s`` is clamped to ``[5, 86400]`` by the route.""" 570 571 enabled: bool | None = None 572 interval_s: int | None = None
Body of PATCH /admin/scheduled-jobs/{name}. Either field may be
omitted; interval_s is clamped to [5, 86400] by the route.
200class ScopeCatalogEntry(CtfyModel): 201 """One assignable permission category, for the token-creation UI.""" 202 203 key: str 204 label: str 205 description: str 206 competition_scoped: bool 207 max_level: str # highest grantable level: "read" | "write"
One assignable permission category, for the token-creation UI.
714class ScoreBucket(CtfyModel): 715 """One bucket of the scoreboard score-distribution histogram.""" 716 717 label: str 718 count: int = 0
One bucket of the scoreboard score-distribution histogram.
660class ScoreHistoryPoint(CtfyModel): 661 """One sample on a team's score/rank-over-time curve. 662 663 Both metrics travel together so the frontend can toggle 664 Score ⇄ Rank without a refetch. 665 """ 666 667 ts: float = 0.0 # Unix seconds (solve time of the driving event) 668 score: int = 0 # Cumulative flags captured up to ``ts`` 669 rank: int = 0 # 1-based rank among scoring teams at ``ts``
One sample on a team's score/rank-over-time curve.
Both metrics travel together so the frontend can toggle Score ⇄ Rank without a refetch.
672class ScoreHistorySeries(CtfyModel): 673 team_id: str 674 team_name: str 675 points: list[ScoreHistoryPoint] = Field(default_factory=list)
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
595class ScoreboardEntry(CtfyModel): 596 rank: int = 0 597 team_id: str 598 team_name: str 599 # Challenges where the team captured every declared flag. Preserved 600 # for parity with the legacy single-flag scoreboard. 601 solved: int = 0 602 # Total individual flags captured across all challenges. Primary 603 # scoreboard metric now that multi-flag challenges exist — rewards 604 # partial progress (e.g. foothold without root). 605 flags_solved: int = 0 606 # The number this board is ranked by, as decided by the 607 # competition's scoring rule (``ctfy/server/scoring/``). Under the 608 # default ``flag_count`` rule it equals ``flags_solved``; under a 609 # points rule it is the point total. Kept separate from 610 # ``flags_solved`` so the UI can show both "how much progress" and 611 # "how many points" without knowing which rule produced the row. 612 score: int = 0 613 attempts: int = 0 614 last_solve_at: datetime | None = None 615 # Most recent activity of any kind. Distinct from ``last_solve_at`` — 616 # a team may be actively attempting without a solve, and the UI 617 # wants to show that they're not idle. 618 last_active_at: datetime | None = None 619 # OAuth provider avatar URL for the team's earliest identity that 620 # supplies one. Empty for password-only teams; the frontend falls 621 # back to a generated initials avatar in that case. 622 avatar_url: str = "" 623 # The AWD+ split of ``score``. Zero on every other format, where the 624 # board renders one number and these columns stay hidden. 625 # 626 # Both are carried even though ``attack + defence == score``, because 627 # the point of the split *is* the breakdown: a single total never 628 # explains why a team that solved less is ranked higher. Deriving one 629 # from the other in the client would put the format's arithmetic in 630 # two places. 631 attack_score: int = 0 632 defence_score: int = 0 633 # Challenges this team holds a ``fixed`` defence verdict on. A count, 634 # not the ids — the board is a ranking, and the ids are one click 635 # away on the team's own page. 636 defended: int = 0 637 # The team's eligibility verdict, and **only when the viewer is 638 # entitled to it** — a competition admin or reviewer. Everywhere else 639 # this is ``""``. 640 # 641 # ``""`` means "not disclosed", never "approved". Defaulting it to 642 # ``approved`` would have been the natural-looking choice and is the 643 # bug: a public board would then assert that every team passed 644 # review, which is a stronger claim than the platform is entitled to 645 # make and is false during the review backlog. 646 # 647 # Not public because no other surface publishes it either — the 648 # verdict reaches the team's own members and the organisers, and 649 # nobody else. Putting "rejected" next to a team name on a page the 650 # whole event reads is a different act from recording it on the 651 # review queue. 652 # 653 # It never affects ``rank``: review is an eligibility marker, not a 654 # gate, and an unapproved team plays and places exactly as it would 655 # have. ``TestReviewIsNotAGate`` pins that; this field is what makes 656 # the fact *visible* to the organiser without changing it. 657 review_status: str = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
139class SeriesOccurrencePreview(CtfyModel): 140 """One occurrence a series *would* create, without creating it.""" 141 142 occurrence_at: AwareDatetime 143 title: str = "" 144 challenge_ids: list[str] = Field(default_factory=list) 145 #: Empty when this occurrence could be filled. Otherwise why not — 146 #: the pool ran dry, or names challenges the catalog no longer has. 147 refused: str = ""
One occurrence a series would create, without creating it.
150class SeriesPreview(CtfyModel): 151 """The dry run. 152 153 Its own route rather than a flag on the save, for the same reason 154 `GET /awd/plan` is: the answer is what decides whether to commit. A 155 weekly's first real feedback is otherwise a week away, and the 156 mistakes it catches — wrong weekday, wrong timezone, a pool that 157 covers three weeks of a schedule meant to run indefinitely — are all 158 invisible in the form. 159 160 ⚠️ It projects **past the lead window** on purpose. `plan_spawns` 161 answers "what should exist now", which for a freshly-saved series is 162 usually one contest; an organiser needs to see the *fourth* week to 163 know the pool runs out. 164 """ 165 166 series_id: str = "" 167 occurrences: list[SeriesOccurrencePreview] = Field(default_factory=list) 168 #: Occurrences already past, reported for the same reason the job 169 #: reports them: a schedule whose anchor is in the past is a common 170 #: mistake and silently produces nothing. 171 missed: list[AwareDatetime] = Field(default_factory=list)
The dry run.
Its own route rather than a flag on the save, for the same reason
GET /awd/plan is: the answer is what decides whether to commit. A
weekly's first real feedback is otherwise a week away, and the
mistakes it catches — wrong weekday, wrong timezone, a pool that
covers three weeks of a schedule meant to run indefinitely — are all
invisible in the form.
⚠️ It projects past the lead window on purpose. plan_spawns
answers "what should exist now", which for a freshly-saved series is
usually one contest; an organiser needs to see the fourth week to
know the pool runs out.
141class SetPasswordRequest(CtfyModel): 142 # Required iff the caller already has a password identity — absent on 143 # the first-time "attach password to an OAuth account" flow. 144 current_password: str = Field(default="", max_length=256) 145 # See RegisterRequest.password for the length-minimum rationale. 146 new_password: str = Field(min_length=12, max_length=256)
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
357class ShellRecordingInfo(CtfyModel): 358 """One row from ``GET /admin/shell-recordings``: a per-session 359 asciinema v2 cast file the platform wrote when a super-admin 360 opened a docker-exec shell. The frontend renders a playable 361 inline player; the raw bytes are pulled via the per-session 362 download route (also super-admin gated, also audited).""" 363 364 session_id: str = "" 365 path: str = "" 366 size_bytes: int = 0 367 modified_at_ts: float = 0.0
One row from GET /admin/shell-recordings: a per-session
asciinema v2 cast file the platform wrote when a super-admin
opened a docker-exec shell. The frontend renders a playable
inline player; the raw bytes are pulled via the per-session
download route (also super-admin gated, also audited).
393class SshCredential(CtfyModel): 394 """Everything a defender needs to `ssh` into their own box, once. 395 396 Model (b): the platform mints the keypair rather than a player 397 registering one, so both halves are here. The private key is 398 returned exactly once and never stored — there is nothing to leak 399 later and nothing to revoke, because it dies with the certificate's 400 five-minute window. 401 402 ⚠️ **The username is not cosmetic.** The bastion reads the subject 403 back off the login name, and asyncssh refuses a certificate whose 404 signed principal list omits it — so a client that connects under any 405 other name is rejected at the handshake with nothing in the response 406 explaining why. 407 """ 408 409 #: OpenSSH private key. Returned once; the platform keeps no copy. 410 private_key: str 411 #: The certificate over it, signed by the platform's SSH CA. 412 certificate: str 413 #: ``user_id:instance_id`` — what the certificate's principal says. 414 username: str 415 #: Where to present it. Only the operator knows how the bastion is 416 #: published, so the platform reports rather than derives it. 417 host: str 418 port: int 419 #: Seconds the certificate stays valid. It only has to cover 420 #: *connecting*; an established session outlives it. 421 expires_in: int
Everything a defender needs to ssh into their own box, once.
Model (b): the platform mints the keypair rather than a player registering one, so both halves are here. The private key is returned exactly once and never stored — there is nothing to leak later and nothing to revoke, because it dies with the certificate's five-minute window.
⚠️ The username is not cosmetic. The bastion reads the subject back off the login name, and asyncssh refuses a certificate whose signed principal list omits it — so a client that connects under any other name is rejected at the handshake with nothing in the response explaining why.
373class SshExchangeRequest(CtfyModel): 374 """The subject a bastion has already authenticated by certificate. 375 376 ⚠️ **Not the certificate.** That was the first shape and it cannot 377 be built: asyncssh discards the client certificate once it has 378 validated it, so the bastion has nothing to forward. What survives 379 the handshake is the login name, which the CA signs — a certificate 380 whose principal list omits the requested username is refused — so 381 the bastion knows the subject but cannot re-prove it with the 382 credential. The bearer on this request is what makes the claim 383 trustworthy; see ``routes/ssh_exchange.py``. 384 """ 385 386 #: Who the certificate named. Authorisation still runs against them. 387 user_id: str 388 #: Which box. Re-resolved here — it can stop or change hands between 389 #: the certificate mint and the connect. 390 instance_id: str
The subject a bastion has already authenticated by certificate.
⚠️ Not the certificate. That was the first shape and it cannot
be built: asyncssh discards the client certificate once it has
validated it, so the bastion has nothing to forward. What survives
the handshake is the login name, which the CA signs — a certificate
whose principal list omits the requested username is refused — so
the bastion knows the subject but cannot re-prove it with the
credential. The bearer on this request is what makes the claim
trustworthy; see routes/ssh_exchange.py.
106class StarGazerVerifyResponse(CtfyModel): 107 """Reply for ``POST /me/star-gazer/verify``. 108 109 A 200 OK with ``verified=False`` is the friendly path — the user 110 can be told *why* (no_github_identity / not_starred / 111 rate_limited / lookup_failed / repo_not_configured / 112 limit_exceeded) without parsing an HTTP error. Hard 4xx is 113 reserved for auth. 114 """ 115 116 verified: bool 117 # ``"starred"`` on success; one of the StarVerifyResult values + 118 # the route's explicit auxiliary reasons on failure. 119 reason: str = "" 120 achievement: TeamAchievement | None = None 121 already_unlocked: bool = False
Reply for POST /me/star-gazer/verify.
A 200 OK with verified=False is the friendly path — the user
can be told why (no_github_identity / not_starred /
rate_limited / lookup_failed / repo_not_configured /
limit_exceeded) without parsing an HTTP error. Hard 4xx is
reserved for auth.
264class StartRequest(CtfyModel): 265 challenge_id: str 266 proxy_output_dir: str | None = None 267 # Per-instance TTL override in seconds. ``None`` (the default) 268 # defers to the platform's ``default_instance_ttl_s`` setting — 269 # admin-tunable, currently 24h. Explicit values are clamped to 270 # ``max_instance_ttl_s`` so a misbehaving SDK / agent can't pin 271 # a node by requesting an arbitrarily large TTL. 272 ttl: int | None = None 273 # Which competition this instance is for. The platform looks up 274 # the caller's per-comp team via ``(user_id, competition_id)`` and 275 # stamps the instance with that team. Optional only when the 276 # caller is on exactly one team — multi-comp users always pass 277 # it explicitly. 278 competition_id: str = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
441class SubmissionCreate(CtfyModel): 442 # ``instance_id`` is the canonical handle since 0.2: the server 443 # reads challenge_id and competition_id off the instance row, so 444 # callers don't need to repeat them and N instances per 445 # (team, challenge) are unambiguous on submit. 446 instance_id: str 447 # Which question on the challenge the agent is answering. Defaults 448 # to ``"flag"`` so single-question challenges stay one-liner submits; 449 # multi-question challenges (see ``ChallengeInfo.questions``) must 450 # specify the id explicitly. 451 question_id: str = "flag" 452 # The agent's submission. String for ``dynamic`` / ``static`` / 453 # ``single_select`` questions; list of strings for ``multi_select`` 454 # (e.g. ``["idor", "ssrf"]``). The server picks the grader by 455 # consulting ``spec.questions[i].mode`` on the live instance. 456 answer: str | list[str]
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
459class SubmissionResponse(CtfyModel): 460 id: str 461 correct: bool 462 solve_time_s: float = 0 # seconds from instance start to correct submission 463 # 1 = first blood, 2 = second, 3 = third, 4+ = regular solve. 464 # 0 when incorrect or when the team already had a prior correct solve. 465 # Ranks within the specific question — independent of the 466 # full-challenge solve rank. 467 solve_rank: int = 0 468 # The question id this submission targeted. Echoed back to the 469 # caller so simple submission UIs don't have to thread the id 470 # separately. Equal to the request's ``question_id``. 471 question_id: str | None = None 472 # True iff this team has now captured every question declared on 473 # the challenge. Single-question challenges: always equals ``correct``. 474 challenge_fully_solved: bool = False 475 # Remaining wrong-attempt budget for the question this submission 476 # targeted, AFTER this attempt is counted (the cap is on wrongs; 477 # correct submissions don't consume budget). ``None`` when the 478 # question's mode is uncapped (dynamic free-form, or any mode the 479 # operator opted out of via ``question_attempt_caps``). The UI 480 # uses this to decrement its remaining-attempts badge without 481 # re-fetching the instance — see ``ctfy.server.submission_policy``. 482 attempts_remaining: int | None = None 483 # Whether the team's capture of this question is on the 484 # competition's board. Only meaningful when ``correct``. 485 # 486 # ⚠️ **Defaults to False on purpose**, which is the loud direction. 487 # A competition accepts submissions outside its play window and 488 # simply does not count them (only a competition declaring 489 # ``sessions`` refuses), so before this field the player got 490 # ``correct: true`` and scored nothing, with nothing anywhere 491 # saying why. A producer that forgets to set this makes every solve 492 # claim it did not count — visible in a minute; the other default 493 # restores exactly the silence this exists to end. 494 ranked: bool = False
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
157class TagStat(CtfyModel): 158 """One axis on the per-tag strength radar. 159 160 ``solved`` is the team's solve count for challenges carrying this 161 tag; ``total`` is the platform-wide challenge count for the tag, 162 so the frontend can render "team / total" or normalize to a 0–1 163 ratio for the radar polygon.""" 164 165 tag: str = "" 166 solved: int = 0 167 total: int = 0
49class TeamAchievement(CtfyModel): 50 """A badge a team has unlocked.""" 51 52 achievement_id: str 53 name: str 54 description: str 55 icon: str 56 tier: str 57 secret: bool = False 58 unlocked_at: datetime | None = None 59 context: dict[str, Any] = Field(default_factory=dict) 60 points: int = 0 61 earned_by_count: int = 0 62 rarity: str = "unearned"
A badge a team has unlocked.
272class TeamCaptainRequest(CtfyModel): 273 """Body for an organiser handing a team to one of its members.""" 274 275 # The member to promote. Must already be on the team — promoting an 276 # outsider would be a join and a transfer in one call, which is how 277 # a squad ends up with a captain nobody on it recognises. 278 user_id: str = Field(min_length=1)
Body for an organiser handing a team to one of its members.
53class TeamDetail(TeamInfo): 54 """``GET /teams/{id}`` payload — adds the resolved members list.""" 55 56 members: list[TeamMemberInfo] = Field(default_factory=list)
GET /teams/{id} payload — adds the resolved members list.
229class TeamDisqualifyRequest(CtfyModel): 230 """Body for an organiser removing a team from a competition.""" 231 232 # Mandatory, and with a floor rather than merely non-empty. This is 233 # the row an appeal is argued from and the text the team is shown 234 # when their next submission is refused; "cheating" tells them 235 # nothing they can answer. Same reasoning as the admin shell's 236 # mandatory ≥10-char reason, which exists for the same audience. 237 reason: str = Field(min_length=10, max_length=2000)
Body for an organiser removing a team from a competition.
19class TeamInfo(CtfyModel): 20 """A team's public-facing summary. 21 22 Post user/team-split a team is a pure container — no email, no 23 role, no human profile fields. ``solves`` and ``attempts`` aggregate 24 across every member's contribution while they were on this team. 25 """ 26 27 id: str 28 name: str 29 description: str = "" 30 captain_user_id: str = "" 31 captain_display_name: str = "" 32 captain_avatar_url: str = "" 33 member_count: int = 0 34 created_at: datetime | None = None 35 solves: int = 0 36 attempts: int = 0 37 last_active_at: datetime | None = None 38 # Per-competition scoping — every team belongs to exactly one 39 # competition. 40 competition_id: str = ""
203class TeamInviteInfo(CtfyModel): 204 """Public projection of a :class:`TeamInviteState`. 205 206 Surfaces on captain CRUD. ``active`` is server-derived from the 207 expires/use/revoked triple. ``kind`` discriminates code-style 208 invites from directed invites/requests; ``target_user_id`` is 209 the recipient for ``kind="direct"`` (set at mint time) and the 210 requester for ``kind="join_request"`` (set when a member opens 211 a request). 212 """ 213 214 id: str 215 code: str 216 team_id: str 217 created_by_user_id: str 218 created_at: datetime | None = None 219 expires_at: datetime | None = None 220 max_uses: int 221 use_count: int 222 revoked_at: datetime | None = None 223 active: bool = True 224 competition_id: str = "" 225 kind: Literal["code", "direct", "join_request"] = "code" 226 target_user_id: str = ""
Public projection of a TeamInviteState.
Surfaces on captain CRUD. active is server-derived from the
expires/use/revoked triple. kind discriminates code-style
invites from directed invites/requests; target_user_id is
the recipient for kind="direct" (set at mint time) and the
requester for kind="join_request" (set when a member opens
a request).
250class TeamLogoUploadResponse(CtfyModel): 251 """``POST /competitions/{id}/team/logo`` result.""" 252 253 object_key: str 254 content_type: str = "" 255 size_bytes: int = 0
POST /competitions/{id}/team/logo result.
43class TeamMemberInfo(CtfyModel): 44 """One member row on the team-detail page.""" 45 46 user_id: str 47 display_name: str = "" 48 avatar_url: str = "" 49 is_captain: bool = False 50 joined_at: datetime | None = None
One member row on the team-detail page.
240class TeamStandingInfo(CtfyModel): 241 """Whether a team is currently in the event, and why not. 242 243 A separate model from ``TeamDetail`` on purpose: this is what the 244 two enforcement routes return and what the admin table polls, so it 245 stays small enough to fetch per row without dragging a roster and a 246 logo key along. 247 """ 248 249 id: str 250 name: str 251 competition_id: str = "" 252 disqualified: bool = False 253 disqualified_at: datetime | None = None 254 disqualified_by: str = "" 255 disqualification_reason: str = "" 256 257 @classmethod 258 def from_team(cls, team: TeamState) -> TeamStandingInfo: 259 return cls( 260 id=team.id, 261 name=team.name, 262 competition_id=team.competition_id, 263 # Projected as a plain bool so a client never has to know 264 # that "absent timestamp" is how the platform spells "in". 265 disqualified=team.is_disqualified, 266 disqualified_at=team.disqualified_at, 267 disqualified_by=team.disqualified_by, 268 disqualification_reason=team.disqualification_reason, 269 )
Whether a team is currently in the event, and why not.
A separate model from TeamDetail on purpose: this is what the
two enforcement routes return and what the admin table polls, so it
stays small enough to fetch per row without dragging a roster and a
logo key along.
257 @classmethod 258 def from_team(cls, team: TeamState) -> TeamStandingInfo: 259 return cls( 260 id=team.id, 261 name=team.name, 262 competition_id=team.competition_id, 263 # Projected as a plain bool so a client never has to know 264 # that "absent timestamp" is how the platform spells "in". 265 disqualified=team.is_disqualified, 266 disqualified_at=team.disqualified_at, 267 disqualified_by=team.disqualified_by, 268 disqualification_reason=team.disqualification_reason, 269 )
111class TestEmailRequest(CtfyModel): 112 template: str 113 #: Defaults to the caller's own address. An arbitrary recipient is 114 #: allowed on purpose — checking how a template lands in Outlook or 115 #: QQ mail requires mailing an account on that provider — and every 116 #: send is audited with the address. 117 to: str = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
120class TestEmailResult(CtfyModel): 121 sent: bool 122 to: str 123 template: str 124 subject: str 125 #: The provider's id for the accepted message, for cross-referencing 126 #: its delivery dashboard. Empty when the send failed, and also 127 #: legitimately empty on success if the response could not be parsed 128 #: — ``sent`` is the outcome, this is only a handle on it. 129 message_id: str = "" 130 #: Provider-side reason when ``sent`` is false. Empty on success. 131 error: str = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
149class TokenInfo(CtfyModel): 150 id: str 151 kind: str # "user" | "fine_grained" (legacy dumps may still say "agent") 152 label: str = "" 153 created_at: datetime | None = None 154 last_used_at: datetime | None = None 155 # ISO-8601 UTC timestamp; empty means the token never expires. 156 expires_at: datetime | None = None 157 # Client metadata captured when the session was minted. Only populated 158 # for user-kind tokens (browser sessions). 159 ip_address: str = "" 160 user_agent: str = "" 161 # True for the session that made this request — lets the UI show "this 162 # session" and disable the Revoke button so a user can't accidentally 163 # log themselves out. 164 is_current: bool = False 165 # Fine-grained scope summary (empty/"none" for user-kind tokens). 166 competition_access: str = "none" # none | all | selected 167 competition_ids: list[str] = Field(default_factory=list) 168 permissions: dict[str, str] = Field(default_factory=dict)
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
210class TokenScopesResponse(CtfyModel): 211 """``GET /auth/tokens/scopes`` — the catalog the UI renders.""" 212 213 categories: list[ScopeCatalogEntry] = Field(default_factory=list) 214 levels: list[str] = Field(default_factory=lambda: ["none", "read", "write"])
GET /auth/tokens/scopes — the catalog the UI renders.
176class TrendPoint(CtfyModel): 177 """One UTC-day bucket on the solve-trend line.""" 178 179 date: str = "" # YYYY-MM-DD, UTC 180 solves: int = 0
One UTC-day bucket on the solve-trend line.
90class UpdateUserRoleRequest(CtfyModel): 91 """Body for ``PATCH /admin/users/{user_id}/role``. 92 93 Only ``"admin"`` and ``"user"`` are accepted — granting 94 ``"super_admin"`` is reserved for the env allowlist 95 (``CTFY_SUPER_ADMIN_EMAILS``) so the trust anchor stays out-of-band. 96 """ 97 98 role: Literal["admin", "user"]
Body for PATCH /admin/users/{user_id}/role.
Only "admin" and "user" are accepted — granting
"super_admin" is reserved for the env allowlist
(CTFY_SUPER_ADMIN_EMAILS) so the trust anchor stays out-of-band.
15class UserInfo(CtfyModel): 16 """A user's public-facing profile.""" 17 18 id: str 19 display_name: str = "" 20 avatar_url: str = "" 21 created_at: datetime | None = None 22 bio: str | None = None 23 country: str | None = None 24 website_url: str | None = None 25 timezone: str | None = None 26 social_links: dict[str, str] = Field(default_factory=dict) 27 # Lifetime totals across every team the user has ever been on. 28 solves: int = 0 29 attempts: int = 0 30 last_active_at: datetime | None = None
A user's public-facing profile.
735class UserScoreboardEntry(CtfyModel): 736 """One row on the user-ranked global scoreboard. 737 738 Aggregates a single user's solves across every team they've 739 ever been on (each ``SolveState`` carries both ``user_id`` and 740 ``team_id``, so per-user totals are a straight filter over the 741 global solve table). Excludes users with zero solves to keep 742 the public list compact. 743 """ 744 745 rank: int = 0 746 user_id: str 747 display_name: str 748 avatar_url: str = "" 749 country: str = "" 750 # Total flags solved across all teams the user has ever been on. 751 flags_solved: int = 0 752 # Challenges fully solved (every declared flag captured). 753 solved: int = 0 754 last_solve_at: datetime | None = None
One row on the user-ranked global scoreboard.
Aggregates a single user's solves across every team they've
ever been on (each SolveState carries both user_id and
team_id, so per-user totals are a straight filter over the
global solve table). Excludes users with zero solves to keep
the public list compact.
196class UserSolveTrend(CtfyModel): 197 """The calling user's daily solve count for the last ``days`` UTC days, 198 aggregated across every team they have ever been on (matched by 199 ``user_id`` on each :class:`SolveState`). 200 201 Powers the Dashboard's "personal solve trend" chart, which is 202 user-scoped — independent of which competition the user is currently 203 registered for. Multi-flag challenges count once per flag day, the 204 same way :class:`ProfileStats.solve_trend` does it. 205 """ 206 207 solve_trend: list[TrendPoint] = Field(default_factory=list)
The calling user's daily solve count for the last days UTC days,
aggregated across every team they have ever been on (matched by
user_id on each SolveState).
Powers the Dashboard's "personal solve trend" chart, which is
user-scoped — independent of which competition the user is currently
registered for. Multi-flag challenges count once per flag day, the
same way ProfileStats.solve_trend does it.
136class VendorStandardSet(CtfyModel): 137 """A vendor-facing summary of one operator eval standard set. 138 139 A standard set is a competition marked ``eval_standard`` — the fixed 140 challenge subset a vendor self-service campaign runs a model over. This 141 projection is what a ``pv_`` token can see (id / title / size / corpus 142 pin), enough to populate the campaign-launch picker without exposing the 143 full competition surface or requiring a platform user token. 144 """ 145 146 id: str = "" 147 title: str = "" 148 description: str = "" # Markdown 149 challenge_count: int = 0 150 #: corpus snapshot the set was certified against (reproducibility pin). 151 corpus_sha: str = ""
A vendor-facing summary of one operator eval standard set.
A standard set is a competition marked eval_standard — the fixed
challenge subset a vendor self-service campaign runs a model over. This
projection is what a pv_ token can see (id / title / size / corpus
pin), enough to populate the campaign-launch picker without exposing the
full competition surface or requiring a platform user token.
59class VendorStanding(CtfyModel): 60 """One vendor's aggregate scorecard across all its evaluated models. 61 62 The vendor's models are pooled: a challenge counts as *solved* if **any** 63 of the vendor's models solved it (the vendor's collective capability), so 64 a vendor fielding several models isn't double-counted per challenge. 65 """ 66 67 rank: int = 0 68 vendor: str = "" 69 models: int = 0 70 challenges_attempted: int = 0 71 challenges_solved: int = 0 72 solve_rate: float = 0.0 73 questions_solved: int = 0 74 total_tokens: int = 0 75 #: estimated USD cost summed across the vendor's models (0 when unpriced). 76 cost_usd: float = 0.0
One vendor's aggregate scorecard across all its evaluated models.
The vendor's models are pooled: a challenge counts as solved if any of the vendor's models solved it (the vendor's collective capability), so a vendor fielding several models isn't double-counted per challenge.
297class VerifyAnswerRequest(CtfyModel): 298 """Oracle-mode answer check — same grader as /submissions but writes 299 no submission record. Carries the same shape as :class:`SubmissionCreate` 300 minus the audit trail. 301 """ 302 303 # Which question on the challenge the agent is checking. Defaults 304 # to ``"flag"`` for single-question challenges. 305 question_id: str = "flag" 306 # The answer to verify (str for dynamic / static / single_select; 307 # list[str] for multi_select). 308 answer: str | list[str]
Oracle-mode answer check — same grader as /submissions but writes
no submission record. Carries the same shape as SubmissionCreate
minus the audit trail.
311class VerifyAnswerResponse(CtfyModel): 312 correct: bool 313 # Echoes the request's ``question_id`` on a correct match; ``None`` 314 # on a wrong claim. Useful for batched verification calls where the 315 # client wants to confirm which check the response refers to. 316 question_id: str | None = None
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
522class VirtualGhostBoard(CtfyModel): 523 """The source contest's standings at the instant this replay has 524 reached, with the replayer merged in and ranked among them. 525 526 ⚠️ Nothing here is stored. The board is 527 `compute_scoreboard_filtered` with an `until_iso`, which the 528 platform has always supported — the replay's own progress and the 529 source's progression are two reads of facts that already exist. 530 """ 531 532 source_competition_id: str 533 #: How far into the replay the reader is. The client renders a clock 534 #: from this rather than recomputing it from `starts_at`, so the two 535 #: cannot disagree about which minute the ghosts are showing. 536 elapsed_seconds: int = 0 537 #: The instant in the *source* being shown. Bounded by its end. 538 cutoff: datetime | None = None 539 items: list[VirtualGhostRow] = Field(default_factory=list)
The source contest's standings at the instant this replay has reached, with the replayer merged in and ranked among them.
⚠️ Nothing here is stored. The board is
compute_scoreboard_filtered with an until_iso, which the
platform has always supported — the replay's own progress and the
source's progression are two reads of facts that already exist.
511class VirtualGhostRow(ScoreboardEntry): 512 """A standings row on a replay's ghost board. 513 514 `is_me` is the one thing a client cannot derive: the replayer's row 515 comes from a *different competition* than the ghosts', so there is no 516 team id it could compare against. 517 """ 518 519 is_me: bool = False
A standings row on a replay's ghost board.
is_me is the one thing a client cannot derive: the replayer's row
comes from a different competition than the ghosts', so there is no
team id it could compare against.