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 DeceptionStats, 48 DeploymentConfig, 49 LlmBudgetPage, 50 LlmBudgetResetInfo, 51 LlmBudgetResetRequest, 52 LlmBudgetRow, 53 LlmProviderCreate, 54 LlmProviderInfo, 55 LlmProviderPage, 56 LlmProviderProbe, 57 PlatformSettingInfo, 58 PlatformSettingPatch, 59 QuestionAttemptResetInfo, 60 QuestionAttemptResetRequest, 61 ScheduledJobInfo, 62 ScheduledJobPatch, 63 ShellRecordingInfo, 64) 65from ctfy.server.models.announcement import ( 66 AnnouncementCreate, 67 AnnouncementInfo, 68 AnnouncementUpdate, 69) 70from ctfy.server.models.auth import ( 71 AuthTokenResponse, 72 CreateFineGrainedTokenRequest, 73 CreateFineGrainedTokenResponse, 74 DeviceApproveRequest, 75 DeviceCodeResponse, 76 DeviceInfoResponse, 77 DeviceTokenRequest, 78 DeviceTokenResponse, 79 EmailCodeAuthInfo, 80 LinkedIdentity, 81 LinkStartResponse, 82 LoginRequest, 83 OAuthProviderInfo, 84 PasswordAuthInfo, 85 ProvidersResponse, 86 RegisterRequest, 87 ScopeCatalogEntry, 88 SetPasswordRequest, 89 TokenInfo, 90 TokenScopesResponse, 91) 92from ctfy.server.models.awd import ( 93 MAX_FLAGS_PER_SUBMISSION, 94 AwdBoardEntry, 95 AwdBoardResponse, 96 AwdFlagResult, 97 AwdFlagSubmitRequest, 98 AwdFlagSubmitResponse, 99 AwdMatchConfig, 100 AwdMatchInfo, 101 AwdMatchStatus, 102 AwdProvisionPlanInfo, 103) 104from ctfy.server.models.challenge import ( 105 AdminChallengeQuestions, 106 AdminPatchSubmissionDetail, 107 AdminPatchSubmissionInfo, 108 AdminPatchSubmissionPage, 109 AttachmentList, 110 ChallengeBuildKickoffNodeResult, 111 ChallengeBuildKickoffResponse, 112 ChallengeBuildNodeState, 113 ChallengeBuildStateResponse, 114 ChallengeBuildStateRow, 115 ChallengeFacetCount, 116 ChallengeFacets, 117 ChallengeFlagStats, 118 ChallengeInfo, 119 ChallengePullKickoffNodeResult, 120 ChallengePullKickoffResponse, 121 ChallengePullNodeState, 122 ChallengePullStateResponse, 123 ChallengePullStateRow, 124 ChallengeRescanNodeResult, 125 ChallengeRescanResult, 126 ChallengeSolveAttempt, 127 ChallengeSolveAttemptsResponse, 128 ChallengeStats, 129 ChallengeTeamSolveSummary, 130 CompetitionChallengeBreakdown, 131 CompetitionChallengeRow, 132 CompetitionScoreDistribution, 133 CompetitionScoreHistory, 134 DefenceInfo, 135 DefenceSource, 136 MilestoneProgress, 137 MySolveSummary, 138 PatchSubmissionCreate, 139 PatchSubmissionInfo, 140 QaChallengeView, 141 QaSubmissionCreate, 142 QuestionPublicInfo, 143 ScoreboardEntry, 144 ScoreBucket, 145 ScoreHistoryPoint, 146 ScoreHistorySeries, 147 SubmissionCreate, 148 SubmissionResponse, 149 UserScoreboardEntry, 150) 151from ctfy.server.models.competition import ( 152 BulkInviteRequest, 153 BulkInviteResponse, 154 BulkInviteResultRow, 155 CompetitionAdminInfo, 156 CompetitionCreate, 157 CompetitionDetail, 158 CompetitionInfo, 159 CompetitionInviteInfo, 160 CompetitionMembershipInfo, 161 CompetitionRegistrationInfo, 162 CompetitionSessionInfo, 163 CompetitionUpdate, 164 GrantCompetitionAdminRequest, 165 VirtualGhostBoard, 166 VirtualGhostRow, 167) 168from ctfy.server.models.eval import ( 169 EvalLeaderboard, 170 EvalTrendPoint, 171 HarnessModelCell, 172 HarnessStanding, 173 LeaderboardCell, 174 ModelStanding, 175 VendorStandardSet, 176 VendorStanding, 177) 178from ctfy.server.models.feedback import ( 179 AdminFeedbackRow, 180 FeedbackStats, 181 MyReactionsResponse, 182) 183from ctfy.server.models.instance import ( 184 AnswerTraceHit, 185 AnswerTraceRequest, 186 AnswerTraceResponse, 187 CheckAnswerRequest, 188 CheckAnswerResponse, 189 InstanceInfo, 190 InstanceQuestionInfo, 191 InstanceRecordArtifacts, 192 InstanceRecordDetail, 193 InstanceRecordInfo, 194 InstanceRecordInfoDetail, 195 InstanceStatusResponse, 196 InstanceTrafficPage, 197 PlayerShellCreate, 198 PlayerShellTicket, 199 RenewResponse, 200 SshCredential, 201 SshExchangeRequest, 202 StartRequest, 203 StartResponse, 204 StopResponse, 205 VerifyAnswerRequest, 206 VerifyAnswerResponse, 207) 208from ctfy.server.models.meta import ( 209 ErrorResponse, 210 HealthResponse, 211 MetaChallenges, 212 MetaPlatform, 213 MetaResponse, 214) 215from ctfy.server.models.node import ( 216 ClusterInfo, 217 CreateInviteRequest, 218 CreateInviteResponse, 219 NodeHeartbeat, 220 NodeInfo, 221 NodeInviteInfo, 222 NodePatch, 223 NodeRegister, 224 NodeRegisterResponse, 225) 226from ctfy.server.models.notification import ( 227 EmailSuppressionCreate, 228 EmailSuppressionInfo, 229 EmailSuppressionListResponse, 230 EmailTemplateInfo, 231 EmailTemplateListResponse, 232 NotificationCategoryInfo, 233 NotificationChannelCell, 234 NotificationPreferencesResponse, 235 NotificationPreferenceUpdate, 236 TestEmailRequest, 237 TestEmailResult, 238) 239from ctfy.server.models.pagination import BigLimitOffsetPage 240from ctfy.server.models.registration import ( 241 MyRegistrationResponse, 242 RegistrationFieldSpec, 243 RegistrationFormSpec, 244 RegistrationRosterRow, 245 RegistrationSubmitRequest, 246 RegistrationSummary, 247 RegistrationTeamRow, 248 ReviewDecisionRequest, 249 TeamLogoUploadResponse, 250) 251from ctfy.server.models.report import ( 252 MyReportRow, 253 MyReportsResponse, 254 ReportAttachmentInfo, 255 ReportAttachmentsResponse, 256 ReportInfo, 257 ReportRosterResponse, 258 ReportRosterRow, 259 ReportSaveRequest, 260 ReportSaveResponse, 261 ReportVersionsResponse, 262) 263from ctfy.server.models.series import ( 264 CompetitionSeriesConfig, 265 CompetitionSeriesInfo, 266 SeriesOccurrencePreview, 267 SeriesPreview, 268) 269from ctfy.server.models.team import ( 270 AdminTeamRow, 271 CompetitionRoster, 272 CompetitionRosterTeam, 273 InboxAnnouncement, 274 InboxCaptainRequest, 275 InboxCompetitionInvite, 276 InboxIncomingInvite, 277 InboxOutgoingRequest, 278 InboxResponse, 279 TeamCaptainRequest, 280 TeamDetail, 281 TeamDisqualifyRequest, 282 TeamInfo, 283 TeamInviteInfo, 284 TeamMemberInfo, 285 TeamStandingInfo, 286) 287from ctfy.server.models.user import ( 288 PROFILE_VISIBILITY_KEYS, 289 AdminUserInfo, 290 CalendarBucket, 291 DeleteMeRequest, 292 DifficultyStat, 293 MeResponse, 294 ProfilePatchRequest, 295 ProfileStats, 296 TagStat, 297 TrendPoint, 298 UpdateUserRoleRequest, 299 UserInfo, 300 UserSolveTrend, 301) 302 303__all__ = [ 304 "MAX_FLAGS_PER_SUBMISSION", 305 "PROFILE_VISIBILITY_KEYS", 306 "AchievementCatalogEntry", 307 "AchievementProgress", 308 "AchievementSummary", 309 "Activity", 310 "ActivityTimeseries", 311 "ActivityTimeseriesBucket", 312 "AdminChallengeLastError", 313 "AdminChallengeLatencyBucket", 314 "AdminChallengeQuestions", 315 "AdminChallengeStatsRow", 316 "AdminChallengeTimeseries", 317 "AdminChallengeTimeseriesBucket", 318 "AdminFeedbackRow", 319 "AdminHealthFlags", 320 "AdminOverview", 321 "AdminOverviewCounts", 322 "AdminPatchSubmissionDetail", 323 "AdminPatchSubmissionInfo", 324 "AdminPatchSubmissionPage", 325 "AdminRecentError", 326 "AdminSilentChallenge", 327 "AdminSolveCell", 328 "AdminSolveMatrix", 329 "AdminSolveMatrixChallenge", 330 "AdminSolveMatrixTeam", 331 "AdminStuckInstance", 332 "AdminTaskInfo", 333 "AdminTaskListResponse", 334 "AdminTaskLogLine", 335 "AdminTaskLogsResponse", 336 "AdminTaskSubmitRequest", 337 "AdminTeamRow", 338 "AdminTimeseries", 339 "AdminTimeseriesBucket", 340 "AdminTrafficInstanceRow", 341 "AdminTrafficSummary", 342 "AdminTrafficTeamRow", 343 "AdminUnhealthyNode", 344 "AdminUserInfo", 345 "AnnouncementCreate", 346 "AnnouncementInfo", 347 "AnnouncementUpdate", 348 "AnswerTraceHit", 349 "AnswerTraceRequest", 350 "AnswerTraceResponse", 351 "AttachmentList", 352 "AuthTokenResponse", 353 "AwdBoardEntry", 354 "AwdBoardResponse", 355 "AwdFlagResult", 356 "AwdFlagSubmitRequest", 357 "AwdFlagSubmitResponse", 358 "AwdMatchConfig", 359 "AwdMatchInfo", 360 "AwdMatchStatus", 361 "AwdProvisionPlanInfo", 362 "BigLimitOffsetPage", 363 "BulkInviteRequest", 364 "BulkInviteResponse", 365 "BulkInviteResultRow", 366 "CalendarBucket", 367 "ChallengeBuildKickoffNodeResult", 368 "ChallengeBuildKickoffResponse", 369 "ChallengeBuildNodeState", 370 "ChallengeBuildStateResponse", 371 "ChallengeBuildStateRow", 372 "ChallengeFacetCount", 373 "ChallengeFacets", 374 "ChallengeFlagStats", 375 "ChallengeInfo", 376 "ChallengePullKickoffNodeResult", 377 "ChallengePullKickoffResponse", 378 "ChallengePullNodeState", 379 "ChallengePullStateResponse", 380 "ChallengePullStateRow", 381 "ChallengeRescanNodeResult", 382 "ChallengeRescanResult", 383 "ChallengeSolveAttempt", 384 "ChallengeSolveAttemptsResponse", 385 "ChallengeStats", 386 "ChallengeTeamSolveSummary", 387 "CheckAnswerRequest", 388 "CheckAnswerResponse", 389 "ClusterInfo", 390 "CompetitionAdminInfo", 391 "CompetitionChallengeBreakdown", 392 "CompetitionChallengeRow", 393 "CompetitionCreate", 394 "CompetitionDetail", 395 "CompetitionInfo", 396 "CompetitionInviteInfo", 397 "CompetitionMembershipInfo", 398 "CompetitionRegistrationInfo", 399 "CompetitionRoster", 400 "CompetitionRosterTeam", 401 "CompetitionScoreDistribution", 402 "CompetitionScoreHistory", 403 "CompetitionSeriesConfig", 404 "CompetitionSeriesInfo", 405 "CompetitionSessionInfo", 406 "CompetitionUpdate", 407 "CreateFineGrainedTokenRequest", 408 "CreateFineGrainedTokenResponse", 409 "CreateInviteRequest", 410 "CreateInviteResponse", 411 "DeceptionStats", 412 "DefenceInfo", 413 "DefenceSource", 414 "DeleteMeRequest", 415 "DeploymentConfig", 416 "DeviceApproveRequest", 417 "DeviceCodeResponse", 418 "DeviceInfoResponse", 419 "DeviceTokenRequest", 420 "DeviceTokenResponse", 421 "DifficultyStat", 422 "EasterEggClaim", 423 "EmailCodeAuthInfo", 424 "EmailSuppressionCreate", 425 "EmailSuppressionInfo", 426 "EmailSuppressionListResponse", 427 "EmailTemplateInfo", 428 "EmailTemplateListResponse", 429 "ErrorResponse", 430 "EvalLeaderboard", 431 "EvalTrendPoint", 432 "FeedbackStats", 433 "GrantCompetitionAdminRequest", 434 "HarnessModelCell", 435 "HarnessStanding", 436 "HealthResponse", 437 "InboxAnnouncement", 438 "InboxCaptainRequest", 439 "InboxCompetitionInvite", 440 "InboxIncomingInvite", 441 "InboxOutgoingRequest", 442 "InboxResponse", 443 "InstanceInfo", 444 "InstanceQuestionInfo", 445 "InstanceRecordArtifacts", 446 "InstanceRecordDetail", 447 "InstanceRecordInfo", 448 "InstanceRecordInfoDetail", 449 "InstanceStatusResponse", 450 "InstanceTrafficPage", 451 "LeaderboardCell", 452 "LinkStartResponse", 453 "LinkedIdentity", 454 "LlmBudgetPage", 455 "LlmBudgetResetInfo", 456 "LlmBudgetResetRequest", 457 "LlmBudgetRow", 458 "LlmProviderCreate", 459 "LlmProviderInfo", 460 "LlmProviderPage", 461 "LlmProviderProbe", 462 "LoginRequest", 463 "MeResponse", 464 "MetaChallenges", 465 "MetaPlatform", 466 "MetaResponse", 467 "MilestoneProgress", 468 "ModelStanding", 469 "MyAchievementsResponse", 470 "MyReactionsResponse", 471 "MyRegistrationResponse", 472 "MyReportRow", 473 "MyReportsResponse", 474 "MySolveSummary", 475 "NodeHeartbeat", 476 "NodeInfo", 477 "NodeInviteInfo", 478 "NodePatch", 479 "NodeRegister", 480 "NodeRegisterResponse", 481 "NotificationCategoryInfo", 482 "NotificationChannelCell", 483 "NotificationPreferenceUpdate", 484 "NotificationPreferencesResponse", 485 "OAuthProviderInfo", 486 "PasswordAuthInfo", 487 "PatchSubmissionCreate", 488 "PatchSubmissionInfo", 489 "PlatformSettingInfo", 490 "PlatformSettingPatch", 491 "PlayerShellCreate", 492 "PlayerShellTicket", 493 "ProfilePatchRequest", 494 "ProfileStats", 495 "ProvidersResponse", 496 "QaChallengeView", 497 "QaSubmissionCreate", 498 "QuestionAttemptResetInfo", 499 "QuestionAttemptResetRequest", 500 "QuestionPublicInfo", 501 "RecentUnlock", 502 "RegisterRequest", 503 "RegistrationFieldSpec", 504 "RegistrationFormSpec", 505 "RegistrationRosterRow", 506 "RegistrationSubmitRequest", 507 "RegistrationSummary", 508 "RegistrationTeamRow", 509 "RenewResponse", 510 "ReportAttachmentInfo", 511 "ReportAttachmentsResponse", 512 "ReportInfo", 513 "ReportRosterResponse", 514 "ReportRosterRow", 515 "ReportSaveRequest", 516 "ReportSaveResponse", 517 "ReportVersionsResponse", 518 "ReviewDecisionRequest", 519 "ScheduledJobInfo", 520 "ScheduledJobPatch", 521 "ScopeCatalogEntry", 522 "ScoreBucket", 523 "ScoreHistoryPoint", 524 "ScoreHistorySeries", 525 "ScoreboardEntry", 526 "SeriesOccurrencePreview", 527 "SeriesPreview", 528 "SetPasswordRequest", 529 "ShellRecordingInfo", 530 "SshCredential", 531 "SshExchangeRequest", 532 "StarGazerVerifyResponse", 533 "StartRequest", 534 "StartResponse", 535 "StopResponse", 536 "SubmissionCreate", 537 "SubmissionResponse", 538 "TagStat", 539 "TeamAchievement", 540 "TeamCaptainRequest", 541 "TeamDetail", 542 "TeamDisqualifyRequest", 543 "TeamInfo", 544 "TeamInviteInfo", 545 "TeamLogoUploadResponse", 546 "TeamMemberInfo", 547 "TeamStandingInfo", 548 "TestEmailRequest", 549 "TestEmailResult", 550 "TokenInfo", 551 "TokenScopesResponse", 552 "TrendPoint", 553 "UpdateUserRoleRequest", 554 "UserInfo", 555 "UserScoreboardEntry", 556 "UserSolveTrend", 557 "VendorStandardSet", 558 "VendorStanding", 559 "VerifyAnswerRequest", 560 "VerifyAnswerResponse", 561 "VirtualGhostBoard", 562 "VirtualGhostRow", 563]
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.
52class AdminChallengeQuestions(CtfyModel): 53 """Every question a challenge declares, gated ones included. 54 55 The organiser's counterpart to the catalog projection, which omits 56 gated entries (see ``projections.question_infos``). Without this an 57 organiser running an exercise has no way to answer "which milestones 58 exist, and why did this team not get credit for one" short of reading 59 ``metadata.yaml`` off disk — and the corpus is a submodule the 60 deployment mounts read-only, not something the admin console can see. 61 62 Deliberately the **same** ``QuestionPublicInfo`` rows rather than a 63 wider admin model: this route changes *who may read*, not *what is 64 said*. The groundtruth is still never projected — a static ``answer`` 65 lives in metadata and a dynamic one is minted per instance, and 66 neither belongs on a page. ``AdminTeamRow`` is a separate model 67 because it adds a field the public one must not carry; there is no 68 such field here. 69 """ 70 71 challenge_id: str 72 questions: list[QuestionPublicInfo] = Field(default_factory=list) 73 # How many of ``questions`` are gated behind ``requires``. Served 74 # rather than derived so a client cannot disagree with the server 75 # about what the catalog is withholding. 76 gated_count: int = 0
Every question a challenge declares, gated ones included.
The organiser's counterpart to the catalog projection, which omits
gated entries (see projections.question_infos). Without this an
organiser running an exercise has no way to answer "which milestones
exist, and why did this team not get credit for one" short of reading
metadata.yaml off disk — and the corpus is a submodule the
deployment mounts read-only, not something the admin console can see.
Deliberately the same QuestionPublicInfo rows rather than a
wider admin model: this route changes who may read, not what is
said. The groundtruth is still never projected — a static answer
lives in metadata and a dynamic one is minted per instance, and
neither belongs on a page. AdminTeamRow is a separate model
because it adds a field the public one must not carry; there is no
such field here.
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.
901class AdminPatchSubmissionDetail(AdminPatchSubmissionInfo): 902 """One submission with the code it carried. 903 904 This is what keeping the bytes was *for*: a team disputes a verdict, 905 or a checker bug is found on day two, and someone has to read what 906 was actually submitted. Without a reader the stored blobs bought 907 nothing. 908 """ 909 910 #: ``{path: base64(bytes)}``. Base64 rather than text because a 911 #: patch target may legitimately be binary, and decoding it into a 912 #: string would corrupt it into something that merely looks like a 913 #: bad submission. 914 files_b64: dict[str, str] = Field(default_factory=dict) 915 #: ``{path: sha256}`` — an equality match across teams, which is 916 #: what is left once uniqueness has stopped flag sharing but nothing 917 #: stops *patch* sharing. 918 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.
880class AdminPatchSubmissionInfo(PatchSubmissionInfo): 881 """One row of the organiser's defence queue. 882 883 The player's view plus the four things only an organiser asks: 884 *whose* it is, and — when a verdict is disputed — which worker 885 decided it after how many tries. 886 887 Still **without the code**, and here that is a stronger requirement 888 than on the parent: this is a competition-wide listing, so shipping 889 bodies would mean reading every stored patch of the event to render 890 a table of statuses. The bytes live on 891 :class:`AdminPatchSubmissionDetail`, one row at a time. 892 """ 893 894 team_id: str = "" 895 user_id: str = "" 896 #: Which worker judged it, for correlating with that node's logs. 897 node_id: str = "" 898 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.
921class AdminPatchSubmissionPage(CtfyModel): 922 """A page of the defence queue, with the total behind it. 923 924 ``total`` is its own query rather than ``len(items)`` so a pager can 925 say "1-20 of 340" — and, with a ``status`` filter, it is also the 926 answer to "is the queue draining". 927 """ 928 929 items: list[AdminPatchSubmissionInfo] = Field(default_factory=list) 930 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.
614class AdminTaskInfo(CtfyModel): 615 """One background task on ``GET /admin/tasks`` / detail. A projection 616 of ``TaskState`` onto the wire (these routes are admin-only, so the 617 full ``error_detail`` traceback tail is included).""" 618 619 id: str = "" 620 kind: str = "" 621 status: str = "" 622 params: dict[str, Any] = Field(default_factory=dict) 623 result: dict[str, Any] = Field(default_factory=dict) 624 progress: float = 0.0 625 progress_message: str = "" 626 error: str = "" 627 error_detail: str = "" 628 cancel_requested: bool = False 629 created_by_user_id: str = "" 630 created_by_name: str = "" 631 created_at_ts: float = 0.0 632 started_at_ts: float = 0.0 633 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).
636class AdminTaskListResponse(CtfyModel): 637 """Paginated task list.""" 638 639 items: list[AdminTaskInfo] = Field(default_factory=list) 640 total: int = 0 641 offset: int = 0 642 limit: int = 50
Paginated task list.
645class AdminTaskLogLine(CtfyModel): 646 """One verbose log line for a task (admin-only).""" 647 648 seq: int = 0 649 ts: float = 0.0 650 level: str = "info" 651 message: str = ""
One verbose log line for a task (admin-only).
654class AdminTaskLogsResponse(CtfyModel): 655 """A cursor page of task logs. ``next_after`` is the ``seq`` to pass as 656 ``?after=`` on the next poll (the last item's seq, or the request's 657 ``after`` when the page is empty — so an empty poll never rewinds the 658 cursor).""" 659 660 items: list[AdminTaskLogLine] = Field(default_factory=list) 661 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).
664class AdminTaskSubmitRequest(CtfyModel): 665 """Body of ``POST /admin/tasks``. ``kind`` is validated against the 666 runner's handler registry; ``params`` is the kind-specific input 667 (e.g. ``{"competition_id": ...}`` or ``{"job": "ttl_sweep"}``).""" 668 669 kind: str = "" 670 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.
85class AdminUserInfo(UserInfo): 86 """Admin-only view of a user. Carries the privilege tier and the 87 most recent role-change audit fields.""" 88 89 email: str = "" 90 role: Literal["user", "admin", "super_admin"] = "user" 91 promoted_by: str | None = None 92 promoted_at: datetime | None = None 93 # OAuth providers the user has bound (e.g. ``["github", "google"]``), 94 # so the admin user list can surface which SSO accounts are linked. 95 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".
237class AnswerTraceHit(CtfyModel): 238 """One instance that was minted the traced answer. 239 240 ``live`` distinguishes a still-running instance (matched against the 241 in-memory minted map) from an archived one (matched against the 242 persisted digest). Both are authoritative; the flag only says 243 whether the instance can still be inspected or stopped. 244 """ 245 246 instance_id: str = "" 247 question_id: str = "" 248 team_id: str = "" 249 team_name: str = "" 250 challenge_id: str = "" 251 competition_id: str = "" 252 live: bool = False 253 started_at: float = 0.0 254 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.
223class AnswerTraceRequest(CtfyModel): 224 """Body for ``POST /admin/answer-trace``. 225 226 The answer travels in the body rather than a query parameter 227 because a live flag in a URL lands in every access log, proxy 228 cache, and browser history between the operator and the app. 229 230 Accepted in either the ``FLAG{...}`` or bare form — the server 231 normalises both to one digest, matching how submissions grade. 232 """ 233 234 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.
257class AnswerTraceResponse(CtfyModel): 258 """Result of ``POST /admin/answer-trace``. 259 260 ``hits`` is normally empty or length 1. More than one means two 261 instances genuinely drew the same value — astronomically unlikely 262 for a 128-bit token, but reported rather than silently collapsed 263 because attributing a leak to the wrong team is worse than 264 reporting ambiguity. 265 266 ``traceable_records`` is the number of archived records that carry 267 a digest at all. Instances archived before answer fingerprinting 268 shipped have none, so an empty ``hits`` on a low count means "this 269 predates tracing", not "this flag was never minted here" — the 270 distinction matters when an organiser is deciding whether to act. 271 """ 272 273 answer_hash: str = "" 274 hits: list[AnswerTraceHit] = Field(default_factory=list) 275 searched_live: int = 0 276 searched_records: int = 0 277 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.
180class AttachmentList(CtfyModel): 181 """Response payload for ``GET /challenges/{id}/attachments``. 182 183 Same data as ``ChallengeInfo.attachments`` but addressable directly 184 so pre-launch UI / agent tooling can fetch it without paging through 185 the catalog. Order matches the on-disk sort. 186 """ 187 188 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.
555class BulkInviteRequest(CtfyModel): 556 """Body for ``POST /admin/competitions/{id}/invites/bulk``. 557 558 Organisers arrive with a qualifying list — a column pasted out of a 559 spreadsheet, newline- or comma-separated, usually with stray spaces 560 and the odd duplicate. Splitting it server-side keeps every client 561 (web, CLI, SDK) from having to agree on the same rules. 562 """ 563 564 # One blob rather than a parsed list: a textarea is what the organiser 565 # actually has, and asking the client to split it invites three 566 # slightly different splitters. 567 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.
583class BulkInviteResponse(CtfyModel): 584 """Per-address outcomes plus the tallies the toast reports. 585 586 Every address is echoed back rather than only the failures: an 587 organiser pasting 300 lines needs to see that 300 were understood, 588 and a silent drop is indistinguishable from success. 589 """ 590 591 results: list[BulkInviteResultRow] = Field(default_factory=list) 592 invited: int = 0 593 pending: int = 0 594 already: int = 0 595 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.
570class BulkInviteResultRow(CtfyModel): 571 """What happened to one address. ``status`` is one of: 572 573 ``invited`` — a new allowlist row (the user existed); 574 ``pending`` — recorded against the address, waiting for a sign-up; 575 ``already`` — invited before, so nothing changed; 576 ``invalid`` — not an email address, skipped. 577 """ 578 579 email: str 580 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.
154class CalendarBucket(CtfyModel): 155 """One UTC-day cell on the contribution calendar. 156 157 ``count`` aggregates correct submissions + first-time solves on that 158 day; effectively "did anything productive happen". The series is 159 dense — every day in the requested window is present, zero-filled.""" 160 161 date: str = "" # YYYY-MM-DD, UTC 162 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.
318class ChallengeBuildKickoffNodeResult(CtfyModel): 319 """One node's response to a build kickoff (single or all). 320 321 ``queued`` is populated only on the ``build-all`` fan-out; for the 322 single-challenge variant the platform inspects ``status`` to learn 323 what the node accepted. 324 """ 325 326 node_id: str 327 ok: bool 328 status: str = "" 329 queued: list[str] = Field(default_factory=list) 330 skipped_built: list[str] = Field(default_factory=list) 331 skipped_in_progress: list[str] = Field(default_factory=list) 332 error: str = ""
335class ChallengeBuildKickoffResponse(CtfyModel): 336 """Admin ``POST /admin/challenges/{id}/build`` and ``…/build-all`` response.""" 337 338 challenge_id: str = "" # empty for build-all 339 nodes: list[ChallengeBuildKickoffNodeResult] = Field(default_factory=list)
Admin POST /admin/challenges/{id}/build and …/build-all response.
275class ChallengeBuildNodeState(CtfyModel): 276 """One worker node's view of a single challenge's build state. 277 278 ``status`` is one of ``unbuilt`` / ``building`` / ``built`` / 279 ``failed``. ``node_error`` is non-empty only when the platform 280 could not reach the node at all (heartbeat stale / 5xx) — in that 281 case ``status`` is forced to ``unbuilt`` for the aggregate. 282 """ 283 284 node_id: str 285 status: str = "unbuilt" 286 built_at: float = 0.0 287 error: str = "" 288 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.
312class ChallengeBuildStateResponse(CtfyModel): 313 """Admin ``GET /admin/challenges/build-state`` response.""" 314 315 rows: list[ChallengeBuildStateRow] = Field(default_factory=list)
Admin GET /admin/challenges/build-state response.
291class ChallengeBuildStateRow(CtfyModel): 292 """Per-challenge build state aggregated across every online node. 293 294 ``aggregated`` rolls up ``nodes`` using a worst-case rule so the 295 table's single status column behaves intuitively: 296 297 * any ``building`` → ``building`` (yellow) 298 * any ``failed`` → ``failed`` (red) 299 * any ``unbuilt`` → ``unbuilt`` (grey) 300 * else → ``built`` (green) 301 302 A node that couldn't be reached at all contributes ``unbuilt`` so 303 the aggregate stays conservative — the admin still sees a 304 "missing" pip and can drill into the modal to find out why. 305 """ 306 307 challenge_id: str 308 aggregated: str = "unbuilt" 309 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.
191class ChallengeFacetCount(CtfyModel): 192 """One bucket on the challenge catalog facet summary. 193 194 ``value`` is the difficulty / tag string; ``total`` is how many 195 challenges in scope carry it. ``solved`` is the *calling* user's 196 solved count in that bucket (across every team they have been on); 197 it is 0 for anonymous callers and for tag buckets (the UI only 198 renders a solved/total ratio for difficulty). 199 """ 200 201 value: str 202 total: int = 0 203 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).
206class ChallengeFacets(CtfyModel): 207 """Catalog aggregates for the Challenges page side panels + filter 208 pill counts — served by ``GET /challenges/facets`` so the page no 209 longer derives them from a bulk fetch of the whole catalog. 210 211 Scoped by ``competition_id`` (when given) exactly like the list 212 endpoint, so the pills/charts reflect the same subset the paged 213 list pages through. 214 """ 215 216 total: int = 0 217 difficulty: list[ChallengeFacetCount] = Field(default_factory=list) 218 # Per-category counts (only categories with ≥1 challenge in scope 219 # show up). Ordered by the canonical category sequence so the 220 # filter pills always render web → pwn → reverse → crypto → misc. 221 category: list[ChallengeFacetCount] = Field(default_factory=list) 222 tags: list[ChallengeFacetCount] = Field(default_factory=list) 223 # Per-status counts for the calling caller (``solved`` / ``unsolved`` 224 # / ``running``). Drives the count chip on each status filter pill 225 # so the page doesn't N+1 a status-filtered list per pill. Buckets 226 # are always present (in canonical order) for layout stability; on 227 # anonymous callers every count is 0 — never leaking another 228 # team's state. 229 status: list[ChallengeFacetCount] = Field(default_factory=list) 230 # Per-source-bucket counts — the prefix of a challenge id 231 # (``CVE-2021-1234`` → ``CVE``), i.e. the benchmark suite an item 232 # was ingested from. Ordered by count descending then id, so a 233 # caller rendering a top-N breakdown gets a stable, meaningful 234 # slice order without re-sorting. ``solved`` is always 0 here: 235 # this facet answers "what is the catalog made of", not "how am I 236 # doing", and the landing page that consumes it is anonymous. 237 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.
407class ChallengeFlagStats(CtfyModel): 408 """Per-flag aggregate for one challenge (for the detail page).""" 409 410 flag_id: str 411 solves_count: int = 0
Per-flag aggregate for one challenge (for the detail page).
134class ChallengeInfo(CtfyModel): 135 id: str 136 name: str 137 category: ChallengeCategory = ChallengeCategory.WEB 138 difficulty: str = "" 139 description: str = "" 140 # Full-challenge solves: teams that captured every declared 141 # question (across all modes — dynamic + static + select). 142 solves_count: int = 0 143 # Times this challenge has been instantiated, ever — one per 144 # archived InstanceRecord (every terminal path writes one). A 145 # "how much has this been run" signal for the challenge card; 146 # platform-wide, not scoped to the viewing team or competition. 147 launch_count: int = 0 148 tags: list[str] = Field(default_factory=list) 149 # Every question declared on this challenge, in author-declared 150 # order. Single-question challenges have a single entry with 151 # id ``"flag"``. ``QuestionPublicInfo`` exposes prompt/mode/choices 152 # but never the answer. 153 questions: list[QuestionPublicInfo] = Field(default_factory=list) 154 # Per-question solve counts (how many teams captured each 155 # question). Keyed by question id. Useful for the challenge 156 # detail page's progress breakdown. Counts span every mode; 157 # the multi_select grader records one solve per fully-correct 158 # submission, not per chosen choice. 159 flag_solves: dict[str, int] = Field(default_factory=dict) 160 # Files shipped under the challenge's ``attachments/`` directory, 161 # downloadable via ``GET /challenges/{id}/attachments/{name}``. 162 # Empty for pure-network challenges that ship nothing. 163 attachments: list[AttachmentInfo] = Field(default_factory=list) 164 # True for pure question-answer challenges (``category: misc``, 165 # no ``docker-compose.yml`` on disk, every declared question 166 # carries a static ``answer:``). The frontend keys off this flag 167 # to switch the competition surface to a quiz UI and skip the 168 # launch-confirm flow. Source of truth lives in 169 # :attr:`ctfy.core.challenge.ChallengeSpec.is_qa_only`, ultimately 170 # derived from :attr:`BenchmarkContext.is_qa_only`. 171 is_qa_only: bool = False 172 # Set for AWD+ challenges; ``None`` for every other kind. Same role 173 # as ``is_qa_only`` one field up — the flag a client keys off to 174 # switch surface — but an object rather than a bool because a 175 # defence surface needs to know *what* it may send, not only that it 176 # may send something. 177 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.
388class ChallengePullKickoffNodeResult(CtfyModel): 389 """One node's response to a pull kickoff (single or all).""" 390 391 node_id: str 392 ok: bool 393 status: str = "" 394 queued: list[str] = Field(default_factory=list) 395 skipped_pulled: list[str] = Field(default_factory=list) 396 skipped_in_progress: list[str] = Field(default_factory=list) 397 error: str = ""
One node's response to a pull kickoff (single or all).
400class ChallengePullKickoffResponse(CtfyModel): 401 """Admin ``POST /admin/challenges/{id}/pull`` and ``…/pull-all`` response.""" 402 403 challenge_id: str = "" # empty for pull-all 404 nodes: list[ChallengePullKickoffNodeResult] = Field(default_factory=list)
Admin POST /admin/challenges/{id}/pull and …/pull-all response.
349class ChallengePullNodeState(CtfyModel): 350 """One worker node's view of a single challenge's pull state. 351 352 ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` / 353 ``failed``. ``node_error`` is non-empty only when the platform could 354 not reach the node at all — ``status`` is then forced to ``unpulled`` 355 for the aggregate. 356 """ 357 358 node_id: str 359 status: str = "unpulled" 360 pulled_at: float = 0.0 361 error: str = "" 362 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.
382class ChallengePullStateResponse(CtfyModel): 383 """Admin ``GET /admin/challenges/pull-state`` response.""" 384 385 rows: list[ChallengePullStateRow] = Field(default_factory=list)
Admin GET /admin/challenges/pull-state response.
365class ChallengePullStateRow(CtfyModel): 366 """Per-challenge pull state aggregated across every online node. 367 368 ``aggregated`` rolls up ``nodes`` worst-case, mirroring the build 369 row: 370 371 * any ``pulling`` → ``pulling`` (yellow) 372 * any ``failed`` → ``failed`` (red) 373 * any ``unpulled`` → ``unpulled`` (grey) 374 * else → ``pulled`` (green) 375 """ 376 377 challenge_id: str 378 aggregated: str = "unpulled" 379 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)
240class ChallengeRescanNodeResult(CtfyModel): 241 """Outcome of fanning the rescan out to one worker node. 242 243 ``ok`` is False for an offline node (skipped, never contacted) or a 244 node that errored; ``total`` is the node's post-rescan challenge 245 count when reached, else ``None``. One unreachable node never fails 246 the whole operation — the platform rescan still stands. 247 """ 248 249 node_id: str 250 ok: bool 251 total: int | None = None 252 error: str | None = None
255class ChallengeRescanResult(CtfyModel): 256 """Admin ``POST /admin/challenges/rescan`` response. 257 258 ``total`` / ``added`` / ``removed`` describe the platform's own 259 catalog after re-scanning ``challenges_dir``; ``nodes`` carries the 260 per-worker fan-out outcome so a newly added challenge is confirmed 261 launchable, not just listable. 262 """ 263 264 total: int 265 added: list[str] = Field(default_factory=list) 266 removed: list[str] = Field(default_factory=list) 267 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.
428class ChallengeSolveAttempt(CtfyModel): 429 """One archived instance for a challenge — used to visualise per-team 430 multi-solve attempts on the challenge detail page.""" 431 432 instance_id: str 433 team_id: str = "" 434 team_name: str = "" 435 display_name: str = "" 436 challenge_id: str = "" 437 started_at: float = 0.0 438 stopped_at: float = 0.0 439 duration_s: float = 0.0 440 attempts: int = 0 441 solved: bool = False 442 solved_flags: list[str] = Field(default_factory=list) 443 stop_reason: str = ""
One archived instance for a challenge — used to visualise per-team multi-solve attempts on the challenge detail page.
460class ChallengeSolveAttemptsResponse(CtfyModel): 461 challenge_id: str 462 attempts: list[ChallengeSolveAttempt] = Field(default_factory=list) 463 per_team: list[ChallengeTeamSolveSummary] = Field(default_factory=list) 464 # Total archived instances (incl. unsolved) — lets the UI show 465 # "showing N solved of M total" when the response is filtered. 466 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.
414class ChallengeStats(CtfyModel): 415 challenge_id: str 416 name: str = "" 417 category: ChallengeCategory | None = None 418 difficulty: str = "" 419 # Teams that captured every declared flag on this challenge. 420 solves_count: int = 0 421 total_attempts: int = 0 422 success_rate: float = 0.0 423 # Per-flag breakdown. Single-flag challenges have a single entry with 424 # ``flag_id == "flag"`` and ``solves_count == solves_count`` above. 425 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.
446class ChallengeTeamSolveSummary(CtfyModel): 447 """Per-team aggregate for the challenge detail solve-attempts panel.""" 448 449 team_id: str 450 team_name: str = "" 451 display_name: str = "" 452 solve_count: int = 0 453 attempt_count: int = 0 454 best_duration_s: float = 0.0 455 total_duration_s: float = 0.0 456 first_solved_at: float = 0.0 457 last_solved_at: float = 0.0
Per-team aggregate for the challenge detail solve-attempts panel.
393class CheckAnswerRequest(CtfyModel): 394 """Run the checker for one *checker-mode* question on an instance. 395 396 Proof-of-exploitation: the platform execs the question's 397 author-supplied checker inside the trusted judge sidecar; the player 398 submits no answer string — the sidecar emits the minted token only 399 when the exploit's effect is verified. 400 """ 401 402 # Which checker question to verify. Checker questions usually carry a 403 # descriptive id (``"deface"`` / ``"persist"`` / …); defaults to 404 # ``"flag"`` for the single-question shape. 405 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.
408class CheckAnswerResponse(CtfyModel): 409 # True when the checker confirmed the exploit (its stdout matched the 410 # instance's minted token for this question) and a solve was recorded. 411 passed: bool 412 # Echoes the question id on a pass; ``None`` otherwise. 413 question_id: str | None = None 414 # Checker process exit code — diagnostic only; the token match is the 415 # real signal. ``None`` when the checker couldn't be run at all. 416 exit_code: int | None = None 417 # True when the checker exceeded its wall-clock budget. 418 timed_out: bool = False 419 # True when the team had already solved this question (short-circuit, 420 # no node call made). 421 already_solved: bool = False 422 # First-blood rank on a fresh solve (1/2/3 = 1st/2nd/3rd blood), 0 423 # otherwise. Mirrors ``SubmissionResponse.solve_rank``. 424 solve_rank: int = 0 425 # True when this check completed the challenge (every question solved). 426 challenge_fully_solved: bool = False 427 # Server clock (epoch seconds) when the check ran, for the UI's 428 # "last checked" line. 429 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.
171class ClusterInfo(CtfyModel): 172 """Returned by ``GET /cluster-info``. 173 174 Provides the bits the ``ctfy-admin server invite`` CLI and admin UI wizard 175 need to build a runnable join command. The *registration token* is 176 minted separately via ``POST /nodes/invites`` — it isn't on this 177 payload because cluster info is queried speculatively by the UI on 178 every admin dashboard load, and we never want to hand an invite 179 token to a page that didn't explicitly ask for one. 180 """ 181 182 platform_url: str 183 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.
512class CompetitionAdminInfo(CtfyModel): 513 """One per-competition admin grant, for the super-admin management 514 card on the competition detail page.""" 515 516 user_id: str 517 display_name: str = "" 518 email: str = "" 519 role: CompetitionGrantRole = "admin" 520 granted_by: str = "" 521 granted_at: datetime | None = None
One per-competition admin grant, for the super-admin management card on the competition detail page.
758class CompetitionChallengeBreakdown(CtfyModel): 759 challenges: list[CompetitionChallengeRow] = Field(default_factory=list) 760 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.
736class CompetitionChallengeRow(CtfyModel): 737 """Per-challenge competition aggregate: solve count, attempt 738 success rate, and first blood. Reconstructed from the solve log 739 with the same window / team filter as the scoreboard so the 740 numbers always agree with the leaderboard.""" 741 742 challenge_id: str 743 name: str = "" 744 category: ChallengeCategory | None = None 745 difficulty: str = "" 746 # Teams that captured every declared flag (matches scoreboard's 747 # ``solved`` tally). 748 solves_count: int = 0 749 # In-window submissions for this challenge across registered teams. 750 attempts: int = 0 751 success_rate: float = 0.0 752 # First team to solve this challenge in-window. Empty when unsolved. 753 first_blood_team_id: str = "" 754 first_blood_team_name: str = "" 755 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.
136class CompetitionCreate(CtfyModel): 137 """Request body for ``POST /admin/competitions``. 138 139 Time fields accept ISO 8601 strings; empty means "no bound" (live 140 immediately / never expires). ``challenge_ids`` is validated against 141 the spec registry inside the route handler — unknown ids return 422. 142 """ 143 144 title: str = Field(min_length=1, max_length=200) 145 description: str = Field(default="", max_length=20000) 146 starts_at: datetime | None = None 147 ends_at: datetime | None = None 148 # No fixed length cap: the real bound is ``_check_challenge_ids`` in 149 # ``admin_competitions`` (every id must be a known spec, so a valid list 150 # can never exceed the catalog). A magic number here is arbitrary and a 151 # recurring foot-gun as the corpus grows — bucket comps already exceed 1000. 152 challenge_ids: list[str] = Field(default_factory=list) 153 # New competitions start hidden ("draft") so admins can prepare them 154 # privately and publish when ready. 155 status: Literal["draft", "published", "archived"] = "draft" 156 # Participation access (orthogonal to ``status``). ``public`` is open 157 # to everyone; ``private_listed`` / ``private_hidden`` require an admin 158 # invite to register (the latter also hides the comp from non-invited 159 # users). Defaults to ``public`` so ad-hoc API callers stay open. 160 access: Literal["public", "private_listed", "private_hidden"] = "public" 161 # Marks this competition as an operator-curated eval **standard set** — 162 # only such a competition may back an ``official`` EvalCampaign 163 # (docs/model-eval-vendor-tenancy.md §4.4). ``corpus_sha`` optionally pins 164 # the benchmark-corpus snapshot it was certified against. Off by default. 165 eval_standard: bool = False 166 corpus_sha: str = "" 167 # Which rule ranks this competition's board, and its tuning. Empty 168 # selects the platform default (``flag_count``), so an API caller 169 # that says nothing gets exactly the historic behaviour. Set it here 170 # rather than later where you can: the rule freezes once the 171 # competition goes live (see ``scoring_locked``). 172 scoring_rule: str = "" 173 scoring_params: dict[str, Any] = Field(default_factory=dict) 174 # Silent exercise mode — the competition tells the player nothing. 175 # See ``CompetitionState.silent`` and the plan's §4b. Orthogonal to 176 # ``scoring_rule`` on purpose, so a range can be run silently for a 177 # human exercise and loudly for an agent benchmark. 178 silent: bool = False 179 # Playable slices inside the window. Empty (the default) means the 180 # whole ``starts_at``..``ends_at`` window is playable, which is how 181 # every competition has always behaved. Supplying sessions also 182 # *sets* the window: ``CompetitionState`` derives ``starts_at`` / 183 # ``ends_at`` from the envelope, so an organiser sending sessions 184 # need not compute the outer dates and cannot contradict them. 185 sessions: list[CompetitionSessionInfo] = Field(default_factory=list) 186 # Seconds after the play window closes during which the engagement 187 # report may still be saved. ``0`` (the default) means the report 188 # closes with the event, which is exactly where the play gate closed 189 # it before this field existed. See ``CompetitionState.report_window_s``. 190 report_window_s: int = 0 191 # --- registration --------------------------------------------------- 192 # The window sign-ups are open, distinct from the play window above: 193 # a real event closes entries well before the first challenge unlocks 194 # so the organiser can vet entrants and print certificates. 195 registration_starts_at: datetime | None = None 196 registration_ends_at: datetime | None = None 197 # Per-competition roster cap. 0 defers to the platform-wide setting, 198 # so two events on one deployment can carry different rules. 199 max_team_size: int = Field(default=0, ge=0) 200 # When true a newly registered team lands ``pending`` and cannot play 201 # until an organiser approves it. 202 registration_review: bool = False 203 # field name -> "hidden" | "optional" | "required". Keys are validated 204 # against REGISTRATION_FIELDS. Anything absent is treated as hidden, 205 # so an unconfigured competition collects nothing extra. 206 registration_fields: dict[str, RegistrationFieldPolicy] = Field(default_factory=dict) 207 # Days after ``ends_at`` before the retention sweep blanks the three 208 # sealed columns (real name, phone, postal address). ``0`` keeps them 209 # indefinitely, which is what every competition predating this reads 210 # as — the purge is irreversible, so turning it on is a decision 211 # somebody makes about one event, never a default. 212 pii_retention_days: int = Field(default=0, ge=0) 213 214 @field_validator("registration_fields") 215 @classmethod 216 def _known_registration_fields( 217 cls, v: dict[str, RegistrationFieldPolicy] 218 ) -> dict[str, RegistrationFieldPolicy]: 219 return _check_registration_fields(v) 220 221 _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.
473class CompetitionDetail(CompetitionInfo): 474 """Full detail payload — includes a *preview* of the resolved 475 challenge summaries and the caller's registration state when 476 authenticated. 477 478 ⚠️ ``challenges`` is bounded by 479 :data:`~ctfy.server.routes.competitions.COMPETITION_CHALLENGE_PREVIEW` 480 and is **not** the competition's challenge set. It used to be, and a 481 bucket competition put hundreds of full ``ChallengeInfo`` rows — 482 each carrying questions, attachments and a defence block — through 483 a payload whose only reader renders six cards. 484 485 Anything that means *all of them* must page 486 ``GET /competitions/{id}/challenges`` (SDK: 487 ``client.competition(id).challenges(offset, limit)``), and anything 488 that means *how many* must read ``challenge_count``. Both existed as 489 ``len(challenges)`` before the bound and would have kept working 490 while reporting the preview size — which is why the two aggregates 491 below are served rather than left to the caller to derive. 492 """ 493 494 challenges: list[ChallengeInfo] = Field(default_factory=list) 495 # The true totals, over the whole resolved set rather than the 496 # preview. Derived here because a client cannot recover them from a 497 # bounded list, and two clients deriving them differently is two 498 # clients disagreeing about how big the competition is. 499 challenge_count: int = 0 500 difficulty_counts: dict[str, int] = Field(default_factory=dict) 501 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.
286class CompetitionInfo(CtfyModel): 287 """Summary row used in the list view. 288 289 Per-viewer fields (``is_registered`` / ``my_*``) are populated only 290 when the request carries an authenticated user; for anonymous 291 requests they fall back to sentinels (empty strings, ``-1``) so 292 the response shape stays stable. Front-end treats ``my_rank < 0`` 293 as "no value" rather than "rank zero". 294 """ 295 296 id: str 297 title: str 298 description: str 299 starts_at: datetime | None = None 300 ends_at: datetime | None = None 301 created_at: datetime | None = None 302 updated_at: datetime | None = None 303 created_by: str 304 created_by_name: str 305 challenge_ids: list[str] = Field(default_factory=list) 306 # Visibility gate. Only ``published`` rows reach non-admins; admins 307 # and per-competition admins also receive ``draft`` / ``archived``. 308 status: Literal["draft", "published", "archived"] = "published" 309 # Participation access. ``public`` is open; ``private_listed`` stays 310 # discoverable but invite-only to register; ``private_hidden`` is also 311 # hidden from non-invited users. Default ``public`` for backward 312 # compat (legacy rows / anonymous responses). 313 access: Literal["public", "private_listed", "private_hidden"] = "public" 314 #: The series this contest is an occurrence of, empty for a one-off. 315 #: Carried so a client can reach the **ladder** — before this the id 316 #: was on no wire model, so the whole rating surface was unreachable 317 #: from the browser however public its route was. Not a disclosure: 318 #: it names a series whose ladder the reader may already read, and 319 #: nothing about the series' pool or cadence travels with it. 320 series_id: str = "" 321 # Eval standard-set marker + pinned corpus snapshot (admin-only meaning; 322 # see CompetitionState). Surfaced so the admin form can show/edit it. 323 eval_standard: bool = False 324 corpus_sha: str = "" 325 # Registration window + form config. The window is genuinely public 326 # information — an entrant needs to know when sign-ups close, and it 327 # differs from the play window — and the rest lets the admin edit form 328 # prefill without a second fetch. Nothing here is sensitive: the same 329 # field policy is already served to any signed-in user by 330 # ``GET /competitions/{id}/registration``. 331 registration_starts_at: datetime | None = None 332 registration_ends_at: datetime | None = None 333 max_team_size: int = 0 334 registration_review: bool = False 335 registration_fields: dict[str, RegistrationFieldPolicy] = Field(default_factory=dict) 336 # Public deliberately: how long entrants' personal data is kept is 337 # part of the notice they are owed, not an internal setting. 338 pii_retention_days: int = 0 339 # Which rule ranks this board, and its tuning. Empty name means the 340 # platform default (``flag_count``). Public because the players are 341 # entitled to know how they are being scored. 342 scoring_rule: str = "" 343 scoring_params: dict[str, Any] = Field(default_factory=dict) 344 # Silent exercise mode — the competition tells the player nothing. 345 # See ``CompetitionState.silent`` and the plan's §4b. Orthogonal to 346 # ``scoring_rule`` on purpose, so a range can be run silently for a 347 # human exercise and loudly for an agent benchmark. 348 silent: bool = False 349 # Server-derived: whether the rule is now frozen. The admin form 350 # disables the control off this rather than re-deriving the 351 # draft/phase rule client-side and disagreeing with the server. 352 scoring_locked: bool = False 353 # Server-derived projection. ``"upcoming"`` / ``"running"`` / ``"past"`` 354 # — saves the frontend from re-implementing the same string-compare 355 # the backend already does. 356 phase: Literal["upcoming", "running", "past"] = "running" 357 # Declared play sessions, empty for a single-window competition. 358 # Public: an entrant has to know the event runs 08:00–16:00 daily, 359 # and the schedule is the first thing a competition page shows. 360 sessions: list[CompetitionSessionInfo] = Field(default_factory=list) 361 # Server-derived: whether play is open *right now*. Deliberately not 362 # folded into ``phase`` — a competition between its 16:00 and the 363 # next 08:00 is still running (board live, registration open, scoring 364 # window unmoved), and re-classifying it would change what every 365 # existing ``phase`` consumer says about a live event. 366 # 367 # ``True`` for every competition with no sessions, which is the whole 368 # existing corpus: they are gated by nothing, so play is never shut. 369 play_open: bool = True 370 # When play reopens. ``None`` means there is no next session — either 371 # none are declared, or the last one has ended. The closed-for-now 372 # card counts down to this, so it is the one field it needs. 373 next_session_starts_at: datetime | None = None 374 # When the current session closes. ``None`` outside a session, and 375 # also for a competition that declares none — there is no session to 376 # name, and reporting the window's end here would put a "session ends 377 # at" countdown on an event that never had sessions. 378 current_session_ends_at: datetime | None = None 379 # How long after the event the engagement report stays writable. 380 # Public for the same reason ``sessions`` is: a team writing one has 381 # to know when it locks. ``0`` means it closes with the event. 382 report_window_s: int = 0 383 # Server-derived: when the report window shuts. ``None`` for an 384 # open-ended competition — there is no instant to count from, and a 385 # fabricated deadline is worse than none. 386 # 387 # ⚠️ Deliberately *not* a ``report_open`` bool. That answer moves with 388 # the clock, so a cached page would assert a state that has since 389 # changed; an instant stays true and the client counts down from it. 390 report_closes_at: datetime | None = None 391 # Whether this competition runs a classic AWD tick loop. Server-derived 392 # from the ``awd_matches`` side table through ``core.awd.is_awd_match``, 393 # never re-derived per client: it decides which board a player is sent 394 # to (``/awd`` vs ``/scoreboard``) and whether a gamebox page exists at 395 # all, so two clients answering it differently is two clients 396 # disagreeing about how the event is played. 397 # 398 # A bool rather than a ``format`` enum on purpose. ``docs/awd-plan.md`` 399 # §14.1 refuses a ``GameMode`` taxonomy at N=2 — a format is a point on 400 # six orthogonal axes, not a subclass — and this field claims only the 401 # fact the side table already stores. ``False`` is every competition 402 # that predates AWD, which is the whole existing corpus. 403 is_awd_match: bool = False 404 # Whether every team owes this competition a written engagement 405 # report — ``core.competitions.expects_report``, the same verdict 406 # ``CompetitionMembershipInfo.expects_report`` already carries for 407 # the sidebar. 408 # 409 # ⚠️ **Denormalized rather than composed by the client**, even though 410 # both halves of the disjunction (``silent`` and ``scoring_rule``) 411 # are already on this model. That is exactly what makes a `||` in a 412 # component tempting, and it would be the *third* place the rule is 413 # spelled — after which two clients disagree about whether an event 414 # has a deliverable. Same argument that put ``is_awd_match`` and 415 # ``can_replay`` here rather than in a page. 416 # 417 # ⚠️ **Needed on the *public* model, not just the membership one**, 418 # because the sidebar entry it already feeds is membership-gated — 419 # correctly, since that block is the registered player's nav. So a 420 # reader deciding whether to enter had no surface anywhere saying 421 # this event is graded on a written report, which is the one thing 422 # about the exercise format they most need before they sign up. 423 # 424 # Defaults ``False``: reading a missing field as true hangs a report 425 # card on every Jeopardy competition, which is the claim the 426 # player-side link waited to be able to make. 427 expects_report: bool = False 428 # The competition this one is a *virtual sitting* of, empty for a 429 # real one. 430 # 431 # On the wire because the marker is a rendering decision: the row's 432 # ``title`` is the source's verbatim, deliberately, so a "(virtual)" 433 # suffix baked into the database would be untranslatable English in 434 # every locale's listing. A client reads this and labels it itself. 435 virtual_of: str = "" 436 # Whether this contest can be replayed on your own clock — the fact 437 # a client renders its "Replay" button from, so it does not have to 438 # POST and read a 400 to find out. 439 # 440 # Derived server-side from ``core.virtual.can_replay``, never per 441 # client, for the same reason ``is_awd_match`` is: a client working 442 # the refusal set out itself is a second implementation of 443 # ``plan_virtual_run``, and it would drift once, silently, into a 444 # button that fails the moment it is pressed. 445 # 446 # ⚠️ Deliberately **not** viewer-scoped, unlike ``can_participate``. 447 # "Am I signed in" the client already knows, and "have I already 448 # started one" is answered by the route being idempotent — asking it 449 # here would put a per-user allowlist read inside a projection that 450 # runs once per row of the listing, which is the N+1 shape three 451 # other fields on this model have already had to have removed. 452 # Defaults ``False``: a button that always 400s is worse than none. 453 can_replay: bool = False 454 # Total registered teams. Surfaced on cards so users can gauge 455 # popularity at a glance. 456 registered_count: int = 0 457 # Viewer-scoped fields — see class docstring. 458 is_registered: bool = False 459 # Whether the viewer may register for this comp. Always ``True`` for 460 # ``public`` comps; for private comps it's ``True`` only when the 461 # viewer is invited or can administer it. Lets the frontend render a 462 # "private — invitation required" locked card without a probe POST. 463 # Defaults ``True`` so public / anonymous-on-public responses are open. 464 can_participate: bool = True 465 my_team_id: str = "" 466 my_team_name: str = "" 467 my_role: Literal["captain", "member", ""] = "" 468 my_rank: int = -1 469 my_score: int = -1 470 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".
538class CompetitionInviteInfo(CtfyModel): 539 """One participation-allowlist entry, for the admin "Invitations" 540 card on a private competition. The body for POST is the shared 541 ``GrantCompetitionAdminRequest`` (resolve by id or email).""" 542 543 user_id: str 544 display_name: str = "" 545 email: str = "" 546 invited_by: str = "" 547 invited_at: datetime | None = None 548 # True while the invitation is still waiting for its person: the 549 # address was invited before anyone signed up with it, so there is no 550 # account to name yet. ``user_id`` is empty in that state, which is 551 # why the card keys its rows on ``email``. 552 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 # Whether this competition runs silent (§4b) — the same fact 63 # ``CompetitionInfo.silent`` carries, denormalized here for the same 64 # reader and the same reason ``is_awd_match`` is: ``Sidebar`` is 65 # mounted above the routes, so it cannot see the competition 66 # ``CompetitionLayout`` already loaded, and it *can* see this through 67 # ``AuthContext`` with no request of its own. 68 # 69 # ⚠️ Without it the per-competition nav offers a Scoreboard entry 70 # for the whole of a silent engagement, and every standing route 71 # behind it refuses — a link that cannot work, for the one format 72 # where "how am I doing" is the question the platform exists to 73 # decline. 74 silent: bool = False 75 # Whether this competition expects an engagement report — the 76 # ``core.competitions.expects_report`` verdict, denormalized here for 77 # the same reader and the same reason the two flags above are. 78 # 79 # ⚠️ **Not the same question as ``silent``**, and reusing that one is 80 # the tempting mistake in both directions: a *loud* exercise 81 # competition grades reports and would get no link, while the 82 # disjunction is what a nav entry is entitled to claim. It is derived 83 # server-side rather than ``||``-ed by each client for the reason 84 # ``is_awd_match`` is: two clients composing it differently is two 85 # clients disagreeing about whether this event has a deliverable. 86 expects_report: bool = False 87 # Rail-enrichment fields (denormalized so the Slack-style rail can 88 # render instance count / rank / a time-progress ring from one /me 89 # call, no per-tile fetch fan-out). 90 # 91 # Live instances for THIS team in THIS comp. Mirrors 92 # InstanceCountContext semantics (all non-terminal InstanceState 93 # rows for the team — terminal ones already left the live store as 94 # InstanceRecord); 0 when none. 95 running_instances: int = 0 96 # Competition window, copied straight off the CompetitionState the 97 # /me loop already fetches. "" start = live immediately, "" end = 98 # never ends (same convention as CompetitionState / phase()). 99 starts_at: datetime | None = None 100 ends_at: datetime | None = None 101 # Caller's current rank in this comp. -1 sentinel = "not computed" 102 # (upcoming phase, or the per-request rank budget was exhausted) — 103 # matches the existing my_rank<0 frontend convention. 104 my_rank: int = -1 105 # Registered-team count, the denominator the UI shows as "#3 / 40". 106 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.
504class CompetitionRegistrationInfo(CtfyModel): 505 """One row in the admin "who's registered" table.""" 506 507 team_id: str 508 team_name: str = "" 509 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.
770class CompetitionScoreDistribution(CtfyModel): 771 """Aggregate companion to the (now server-paginated) competition 772 scoreboard: the score-distribution histogram + the true team count. 773 774 The standings table pages server-side, so the histogram / "N teams" 775 figure can't be re-derived from one page in the browser — this 776 bins every registered team's ``flags_solved`` with the same 777 adaptive scheme the frontend used to do client-side. 778 """ 779 780 total_teams: int = 0 781 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.
727class CompetitionScoreHistory(CtfyModel): 728 """Top-N teams' score/rank progression, reconstructed from the 729 competition's solve log (no snapshot dependency — exact for past 730 and live competitions alike).""" 731 732 series: list[ScoreHistorySeries] = Field(default_factory=list) 733 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.
129class CompetitionSessionInfo(CtfyModel): 130 """One playable slice of a competition's window, on the wire.""" 131 132 starts_at: datetime 133 ends_at: datetime
One playable slice of a competition's window, on the wire.
224class CompetitionUpdate(CtfyModel): 225 """PATCH body — every field optional, ``None`` means "leave alone".""" 226 227 title: str | None = Field(default=None, min_length=1, max_length=200) 228 description: str | None = Field(default=None, max_length=20000) 229 starts_at: datetime | None = None 230 ends_at: datetime | None = None 231 challenge_ids: list[str] | None = Field(default=None) # no cap — see CompetitionCreate 232 status: Literal["draft", "published", "archived"] | None = None 233 access: Literal["public", "private_listed", "private_hidden"] | None = None 234 eval_standard: bool | None = None 235 corpus_sha: str | None = None 236 # Rejected with 409 once the competition is live — re-submitting the 237 # value it already holds is a no-op, so the edit form can keep 238 # sending every field when the admin saves any of them. 239 scoring_rule: str | None = None 240 scoring_params: dict[str, Any] | None = None 241 # ⚠️ Optional like every field here: ``None`` means untouched. 242 # Flipping silence mid-event is a real organiser action (a range run 243 # loud for a practice round, then silent for the graded one), so it 244 # is deliberately *not* frozen the way ``scoring_rule`` is — silence 245 # changes what the player is told from here on, it does not re-rank a 246 # board they have already seen. 247 silent: bool | None = None 248 #: ``None`` leaves the schedule alone; ``[]`` clears every session and 249 #: returns the competition to a single continuous window. 250 sessions: list[CompetitionSessionInfo] | None = None 251 # ⚠️ Optional like every field here: ``None`` means untouched. Not 252 # frozen the way ``scoring_rule`` is — extending the report period on 253 # the day is ordinary event work, and it re-ranks nothing. 254 report_window_s: int | None = None 255 # --- registration --------------------------------------------------- 256 # The window sign-ups are open, distinct from the play window above: 257 # a real event closes entries well before the first challenge unlocks 258 # so the organiser can vet entrants and print certificates. 259 registration_starts_at: datetime | None = None 260 registration_ends_at: datetime | None = None 261 # Per-competition roster cap. 0 defers to the platform-wide setting, 262 # so two events on one deployment can carry different rules. 263 max_team_size: int | None = Field(default=None, ge=0) 264 # When true a newly registered team lands ``pending`` and cannot play 265 # until an organiser approves it. 266 registration_review: bool | None = None 267 # field name -> "hidden" | "optional" | "required". Keys are validated 268 # against REGISTRATION_FIELDS. Anything absent is treated as hidden, 269 # so an unconfigured competition collects nothing extra. 270 registration_fields: dict[str, RegistrationFieldPolicy] | None = None 271 #: ``None`` leaves it alone, same as every other field here. 272 pii_retention_days: int | None = Field(default=None, ge=0) 273 274 _norm_times = field_validator( 275 "starts_at", "ends_at", "registration_starts_at", "registration_ends_at", mode="before" 276 )(_blank_to_none) 277 278 @field_validator("registration_fields") 279 @classmethod 280 def _known_registration_fields( 281 cls, v: dict[str, RegistrationFieldPolicy] | None 282 ) -> dict[str, RegistrationFieldPolicy] | None: 283 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.
133class CreateInviteRequest(CtfyModel): 134 """Body for ``POST /api/v1/nodes/invites``. 135 136 ``ttl_seconds`` controls how long the minted registration token stays 137 valid — it cannot be renewed, only re-created. 138 """ 139 140 ttl_seconds: int = Field(default=3600, ge=60, le=7 * 24 * 3600) 141 # Optional vendor binding: a node registered with this invite is stamped 142 # as that vendor's bring-your-own-node (docs/model-eval-vendor-tenancy.md 143 # §6.3). "" = a platform-shared node (the default). Validated to an 144 # existing vendor at mint time. 145 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.
148class CreateInviteResponse(CtfyModel): 149 """Plaintext registration token is returned **exactly once** here. 150 151 Subsequent ``GET /nodes/invites`` lookups only expose metadata; the 152 plaintext is never persisted — only ``token_hash`` is stored. 153 """ 154 155 id: str 156 registration_token: str 157 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.
699class DeceptionStats(CtfyModel): 700 """Three states, of which only two are counted. 701 702 ``contacted`` and ``complied`` are each a stored count of crossings; 703 ``resisted`` is the difference. There is no third counter because 704 there is no moment at which a refusal happens for something to 705 count — resistance is the absence of compliance after contact, and 706 an absence cannot be recorded at the time it fails to occur. 707 """ 708 709 contacted: int = 0 710 complied: int = 0 711 resisted: int = 0 712 #: ``complied / contacted``. Absent rather than zero when nothing has 713 #: been contacted: 0/0 is "no data", and rendering it as a 0% rate 714 #: would assert that no agent was fooled, which is a claim the 715 #: platform has not earned. 716 comply_rate: float | None = None 717 #: ⚠️ Rides on the wire so a reader cannot get the number without it. 718 caveat: str = ( 719 "resisted is an upper bound: the probe runs inside a container the " 720 "player can compromise, so a missing compliance record is not " 721 "evidence of restraint." 722 )
Three states, of which only two are counted.
contacted and complied are each a stored count of crossings;
resisted is the difference. There is no third counter because
there is no moment at which a refusal happens for something to
count — resistance is the absence of compliance after contact, and
an absence cannot be recorded at the time it fails to occur.
109class DefenceInfo(CtfyModel): 110 """What a defender may do on this challenge — AWD+ only. 111 112 Present iff the challenge declares a ``patch:`` block. ``None`` 113 everywhere else, which is what lets a client decide whether to render 114 a defence surface at all: without this the only way to find out was 115 to POST a submission and read the refusal, so the web UI had no way 116 to know AWD+ existed. 117 118 ``targets`` is deliberately public. It is a whitelist, not a secret — 119 a defender is *given* this source (they can read it in their own 120 box), the refusal message already names a rejected path, and a UI 121 that cannot say which files it will accept sends players guessing. 122 """ 123 124 #: Challenge-relative paths a submission may replace. Anything else 125 #: is refused at submit time rather than at judge time. 126 targets: list[str] = Field(default_factory=list) 127 #: Whether ``patch.live`` declares a defender's shell, i.e. whether 128 #: ``ctfy patch shell`` / the terminal button will work here. False 129 #: means uploads are the only channel — a real and supported shape, 130 #: not a degraded one, so the UI says so rather than hiding. 131 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.
79class DefenceSource(CtfyModel): 80 """The patchable source of an AWD+ challenge, as the defender gets it. 81 82 AWD+ hands every team the same vulnerable service **and its source** 83 (§15.1) — you cannot patch what you cannot read. Until this there 84 was no way to get it: the only channel was `patch.live`, i.e. ssh 85 into your own box, which is the *classic AWD* shape and leaves 86 virtual participation and upsolve impossible, since after the event 87 there is no box to enter. That is the feature §15.5 calls decisive 88 for a weekly series. 89 90 ⚠️ **Exactly `patch.targets`, never the tree.** A benchmark 91 directory also holds `solution/exploit.py` and the checker, and for 92 some challenges the groundtruth — serving the directory would hand a 93 player the reference exploit and the thing that grades them. The 94 whitelist is already the format's one real security boundary (see 95 `routes/patches.py`), so the download reuses it rather than 96 inventing a second, weaker one. 97 98 ``files`` is keyed by challenge-relative path with **base64** 99 values, which is deliberately the shape `POST /patches` accepts: a 100 defender — or an agent — round-trips download → edit → upload with 101 no transformation, and a binary target does not need a second code 102 path. 103 """ 104 105 challenge_id: str 106 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.
144class DeleteMeRequest(CtfyModel): 145 """Body for ``DELETE /me``. ``confirm_display_name`` must match the 146 caller's user display name exactly (case-sensitive) — the typo 147 gate that stops a fat-fingered click from cascading the account's 148 data away. (Falls back to email when display_name is empty.) 149 """ 150 151 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 = ""
178class DifficultyStat(CtfyModel): 179 difficulty: str = "" # easy | medium | hard | ... 180 solved: int = 0 181 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.
524class GrantCompetitionAdminRequest(CtfyModel): 525 """Body for ``PUT /admin/competitions/{id}/admins`` — resolve the 526 target user by id or (case-insensitive) email. 527 528 ``role`` picks the grant tier: ``admin`` (full organiser control of 529 this event) or ``reviewer`` (roster read + eligibility verdicts + 530 export, nothing that removes a team). Defaults to ``admin`` so an 531 older client that omits it keeps the prior behaviour.""" 532 533 user_id: str = "" 534 email: str = "" 535 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.
72class InstanceInfo(CtfyModel): 73 id: str 74 challenge_id: str = "" 75 team_id: str = "" 76 # Competition the instance is scoped to. Stamped from the resolved 77 # per-comp team at start time; empty for legacy rows or unscoped 78 # admin instances. Frontend keys per-comp instance pages off this 79 # field so a user registered for two comps doesn't see comp A's 80 # instances in comp B's tab. 81 competition_id: str = "" 82 # Worker node currently hosting this instance. Empty only for 83 # records created before nodes were tracked (legacy). 84 node_id: str = "" 85 # Display names for the two ids above, so an admin table can render 86 # what it links to rather than a truncated uuid. Resolved in bulk at 87 # the route from a pre-loaded map; ``""`` means "not resolved", which 88 # is what a caller that passes no map gets and what the client 89 # already renders today (the id, truncated). 90 # 91 # ⚠️ Empty is never the id. `EntityCell` branches on name-vs-id to 92 # decide whether it has something human to show, so echoing the id 93 # here would make "we know the name" indistinguishable from "we 94 # don't" — and every row would render as though it had been resolved. 95 team_name: str = "" 96 node_name: str = "" 97 name: str = "" 98 category: ChallengeCategory | None = None 99 difficulty: str = "" 100 status: str = InstanceStatus.STARTING 101 started_at: float = 0.0 102 ttl: int = 0 103 expires_at: float = 0.0 104 services: list[ServiceEndpoint] = Field(default_factory=list) 105 description: str = "" 106 # Per-instance question view: same shape as ``ChallengeInfo.questions`` 107 # but additionally carries the agent-visible state. ``unlocked`` is 108 # ``False`` when the question's ``requires:`` predecessors haven't 109 # all been answered correctly yet; the UI hides the prompt for locked 110 # questions to avoid leaking route hints. ``answered_correctly`` is 111 # ``True`` once this team has captured this question on this instance. 112 questions: list[InstanceQuestionInfo] = Field(default_factory=list) 113 # Mirrored from ``ChallengeInfo.attachments`` so players who jump 114 # straight to the instance page (e.g. via a launch link) see the 115 # download list without re-fetching the catalog row. 116 attachments: list[AttachmentInfo] = Field(default_factory=list) 117 # How the platform exposes this instance to the player. Always 118 # surfaced (defaults to ``"simple"``) so the agent can branch on 119 # `if info.network_topology == "engagement": ...` without dealing 120 # with a missing field. 121 network_topology: Literal["simple", "engagement"] = "simple" 122 # Which VPN an engagement instance runs. Meaningless in simple 123 # mode, where it stays at the default. Surfaced so the UI can name 124 # the right client command and an agent can branch without 125 # downloading the config to find out what it is. 126 vpn_backend: Literal["openvpn", "wireguard"] = "openvpn" 127 # Set only when ``network_topology == "engagement"``. Carries the 128 # tunnel endpoint host:port + the URL the client downloads the 129 # config body from. ``None`` in simple mode. 130 vpn_endpoint: VpnEndpoint | None = None 131 # Landing-page hints declared in the challenge's ``metadata.yaml`` 132 # (``entry_urls:`` field, validated by META013). Surfaced verbatim 133 # to the frontend so the VPN / services panel can render a 134 # "start here" list without the player having to read the 135 # description for the canonical first URL. 136 entry_urls: list[str] = Field(default_factory=list) 137 # Which supply this box came from. ``on_demand`` is a player's own 138 # launch and behaves the way every instance always has; ``match`` is 139 # an arena gamebox an organiser provisioned, and it is a genuinely 140 # different object to its owner — it never expires, stopping it is 141 # not theirs to do, and it is the one the round loop rotates flags 142 # into. The client cannot tell them apart from `challenge_id` alone, 143 # because a player may also launch the gamebox on demand. 144 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. 31 32 ⚠️ **A question the calling team can neither see nor has captured is 33 absent from the list entirely**, rather than present with a blanked 34 ``prompt`` — see ``instances._project_instance_questions`` for why 35 the id, the ``requires`` edges, ``choices`` and the row *count* are 36 each disclosure in their own right. So ``unlocked`` is ``False`` on 37 exactly one shape: a question the team has already captured whose 38 predecessor they never formally answered (a report-credited 39 sentinel), where the prompt stays empty and the row stays because it 40 is a fact about their own work. 41 """ 42 43 id: str 44 prompt: str 45 mode: str | None = None 46 choices: list[str] | None = None 47 requires: list[str] = Field(default_factory=list) 48 # ``True`` once every id in ``requires`` has been captured by the 49 # calling team on this instance. ``False`` keeps the prompt empty 50 # to avoid leaking the question text early. 51 unlocked: bool = True 52 # ``True`` once the calling team has submitted a correct answer 53 # for this question against this instance. Drives the UI checkmark. 54 answered_correctly: bool = False 55 # Wrong-attempt budget remaining for the calling team on this 56 # specific question. ``None`` when the question's mode is uncapped 57 # (dynamic free-form, or any mode the operator opted out of via 58 # ``question_attempt_caps``). The UI renders an ``X/N attempts`` 59 # badge from this on first page load and decrements it locally on 60 # each wrong submit (the submission response also carries it). 61 attempts_remaining: int | None = None 62 # The cap that ``attempts_remaining`` is being measured against — 63 # echoed so the UI can render ``X/N`` without recomputing N from 64 # the question's mode + current platform settings. 65 attempts_cap: int | None = None 66 # ``True`` for a checker question whose ``check.poll`` is on: the 67 # platform auto-verifies it in the background, so the UI shows an 68 # "auto-verify on" affordance. Never exposes the checker command. 69 auto_check: bool = False
One question, projected for a specific running instance.
Mirrors QuestionPublicInfo plus per-instance state.
⚠️ A question the calling team can neither see nor has captured is
absent from the list entirely, rather than present with a blanked
prompt — see instances._project_instance_questions for why
the id, the requires edges, choices and the row count are
each disclosure in their own right. So unlocked is False on
exactly one shape: a question the team has already captured whose
predecessor they never formally answered (a report-credited
sentinel), where the prompt stays empty and the row stays because it
is a fact about their own work.
197class InstanceRecordArtifacts(CtfyModel): 198 """Per-artifact presence flags + counts for an archived instance. 199 200 Returned alongside the full :class:`InstanceRecord` from 201 ``GET /admin/instance-records/{id}`` so the admin UI can show 202 ``Manifest / Events / Submissions / Container log`` tabs at a glance 203 without a round-trip per tab. 204 """ 205 206 has_manifest: bool = False 207 has_container_log: bool = False 208 events_count: int = 0 209 submissions_count: int = 0 210 traffic_count: int = 0 211 # Bytes on disk for the archived ``traffic.pcap``. 0 when no capture 212 # was persisted (sidecar disabled, archive disabled, or fetch 213 # failed). The admin UI uses this both to gate the download button 214 # and to render an "X MB" hint next to it. 215 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.
218class InstanceRecordDetail(CtfyModel): 219 record: InstanceRecordInfoDetail 220 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.
147class InstanceRecordInfo(CtfyModel): 148 """Archived-instance summary for the admin history list. 149 150 Mirrors :class:`ctfy.core.state.models.InstanceRecord` minus the heavy 151 fields (``spec``, ``surface``). Used by ``GET /admin/instance-records``. 152 """ 153 154 id: str 155 team_id: str = "" 156 challenge_id: str = "" 157 node_id: str = "" 158 # Same contract as ``InstanceInfo.team_name`` / ``node_name`` — see 159 # there. Declared again rather than inherited because this is a 160 # sibling class, not a subclass, so a field added to one is genuinely 161 # absent from the other. The archive needs them at least as much as 162 # the live table: it is read after the event, when the team it names 163 # is likeliest to have been deleted. 164 team_name: str = "" 165 node_name: str = "" 166 name: str = "" 167 category: ChallengeCategory | None = None 168 difficulty: str = "" 169 status: str = "" 170 stop_reason: str = "" 171 error: str = "" 172 # Full node diagnostic (e.g. ``docker compose up`` stderr tail). 173 # Admin-only: this model backs ``/admin/instance-records*`` only; 174 # the player-facing ``InstanceInfo`` / ``InstanceStatusResponse`` 175 # deliberately omit it. 176 error_detail: str = "" 177 started_at: float = 0.0 178 stopped_at: float = 0.0 179 duration_s: float = 0.0 180 ttl: int = 0 181 solved: bool = False 182 attempts: int = 0 183 # Total HTTP requests captured by the mitmproxy sidecar during the 184 # instance's lifetime. 0 when the archive sink was disabled or no 185 # traffic was captured. 186 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.
189class InstanceRecordInfoDetail(InstanceRecordInfo): 190 """Full record — includes spec + surface, returned by detail endpoint.""" 191 192 spec: dict[str, Any] = Field(default_factory=dict) 193 surface: AttackSurface | None = None 194 artifact_dir: str = ""
Full record — includes spec + surface, returned by detail endpoint.
280class InstanceStatusResponse(CtfyModel): 281 id: str 282 status: str = InstanceStatus.STARTING 283 attack_surface: AttackSurface | None = None 284 error: str = "" 285 # CA volume for the per-instance mitmproxy. Operators trust this CA 286 # if they want to MITM HTTPS; HTTP traffic is captured transparently 287 # without it. 288 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.
303class InstanceTrafficPage(CtfyModel): 304 """One poll's worth of captured flows, plus where to resume. 305 306 ``cursor`` is opaque: it is this platform's private encoding of how 307 far each capture file has been read, and nothing outside 308 ``ctfy.server.traffic_tail`` may parse it. A client stores whatever 309 it was handed and sends it back. 310 311 ⚠️ **An empty cursor means "start over", so a client must not invent 312 one.** Sending `""` re-reads the whole capture — which is exactly 313 the cost the tail read exists to remove, arriving through the client 314 rather than through the server. 315 """ 316 317 flows: FlowRecords = Field(default_factory=list) 318 cursor: str = "" 319 # A bound was hit and more is already on disk. A client catching up 320 # should poll again immediately rather than waiting out its 321 # interval — the alternative is a defender watching an attack 322 # through a window that fills at one page per interval. 323 truncated: bool = False 324 #: ``""`` / ``"on"`` / ``"off"`` — whether a proxy sidecar exists for 325 #: this instance at all. 326 #: 327 #: ⚠️ **Tri-state, and ``""`` is the default, because the other two 328 #: values are claims.** An empty page means "nothing yet" when a 329 #: sidecar is running and "nothing, ever" when none is, and those 330 #: render as the same empty table — which reads as the feature being 331 #: broken. But a node that never answered has told us neither, and a 332 #: boolean has nowhere to put that. Same rule as 333 #: ``ScoreboardEntry.review_status`` defaulting to ``""`` rather 334 #: than to ``approved``. 335 capture: str = ""
One poll's worth of captured flows, plus where to resume.
cursor is opaque: it is this platform's private encoding of how
far each capture file has been read, and nothing outside
ctfy.server.traffic_tail may parse it. A client stores whatever
it was handed and sends it back.
⚠️ An empty cursor means "start over", so a client must not invent
one. Sending "" re-reads the whole capture — which is exactly
the cost the tail read exists to remove, arriving through the client
rather than through the server.
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.
580class LlmBudgetPage(CtfyModel): 581 """The organiser's LLM-spend roster. 582 583 ``gateway_configured`` is on the envelope for the reason 584 ``webhook_configured`` is on the email-suppression list: an empty 585 table otherwise reads as "nobody has spent anything" when it means 586 "this deployment enforces no budgets at all", and those call for 587 opposite actions. 588 """ 589 590 items: list[LlmBudgetRow] 591 total: int 592 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.
537class LlmBudgetRow(CtfyModel): 538 """One team's LLM spend on one challenge. 539 540 ⚠️ ``budget_tokens`` is the cap **as configured right now**, not the 541 cap that applied when the spend happened: it is one deployment-wide 542 setting the operator can retune mid-event. So a row can read 543 ``spent 90k of 200k`` for a team that really was refused at 50k, and 544 the honest place to see what happened *then* is the 545 ``llm_quota_exhausted`` activity row, which carries the cap it 546 crossed. Reporting a per-row historical cap would mean storing one, 547 which is a column that exists only to be believed. 548 549 ``exhausted`` is derived from the pair rather than stored, for the 550 same reason: it is a statement about the two numbers on this row and 551 must not disagree with them. 552 """ 553 554 team_id: str 555 team_name: str = "" 556 competition_id: str 557 challenge_id: str 558 spent_tokens: int 559 budget_tokens: int 560 #: Whether this team still holds a live grant for this challenge. 561 #: A budget outlives the box that spent it — the key is 562 #: ``(team, competition, challenge)`` precisely so a fresh instance 563 #: does not refill — so a row with no live grant is the normal 564 #: shape after a box stops, not an orphan. 565 live: bool = False 566 567 @computed_field 568 @property 569 def exhausted(self) -> bool: 570 """⚠️ A **`computed_field`**, unlike ``TeamState.is_disqualified`` 571 which is deliberately a plain ``@property``. Opposite reasons, 572 and the distinction is the model's kind: that one is *state*, so 573 ``model_dump`` feeds ``to_orm`` and every dumped key has to be a 574 column; this is a *wire* model whose whole purpose is to reach a 575 client, and a plain property simply never leaves the process. 576 """ 577 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.
567 @computed_field 568 @property 569 def exhausted(self) -> bool: 570 """⚠️ A **`computed_field`**, unlike ``TeamState.is_disqualified`` 571 which is deliberately a plain ``@property``. Opposite reasons, 572 and the distinction is the model's kind: that one is *state*, so 573 ``model_dump`` feeds ``to_orm`` and every dumped key has to be a 574 column; this is a *wire* model whose whole purpose is to reach a 575 client, and a plain property simply never leaves the process. 576 """ 577 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.
507class LlmProviderCreate(CtfyModel): 508 """A provider as an operator types it. 509 510 ⚠️ Validated **here**, not only at the gateway. ``POST /keys/sync`` 511 reports a ``rejected`` list whose whole meaning is "the platform and 512 the gateway have drifted, somebody look" — which is only true if a 513 row the gateway would refuse could not have been stored. Without 514 this, a refusal there would be the ordinary result of a typo and 515 nobody would read it. 516 """ 517 518 key_id: str = Field(min_length=1, max_length=64) 519 api_key: str = Field(min_length=1, max_length=512) 520 flavor: str 521 base_url: str = "" 522 model: str = "" 523 524 @field_validator("flavor") 525 @classmethod 526 def _known_flavor(cls, value: str) -> str: 527 # Imported here rather than at module scope: this is a wire model 528 # and `keys.py` sits in the gateway package, so a top-level 529 # import would drag the gateway into every model importer. 530 from ctfy.server.llm_gateway.keys import FLAVORS 531 532 if value not in FLAVORS: 533 raise ValueError(f"unknown LLM flavor {value!r}; expected one of {', '.join(FLAVORS)}") 534 return value
A provider as an operator types it.
⚠️ Validated here, not only at the gateway. POST /keys/sync
reports a rejected list whose whole meaning is "the platform and
the gateway have drifted, somebody look" — which is only true if a
row the gateway would refuse could not have been stored. Without
this, a refusal there would be the ordinary result of a typo and
nobody would read it.
413class LlmProviderInfo(CtfyModel): 414 """One upstream credential as an operator may see it. 415 416 ⚠️ **No ``api_key`` field, and that absence is the model.** The 417 column is sealed precisely so the secret cannot be read back, and a 418 wire model carrying it would undo that for every caller at once. A 419 last-four ``hint`` is included because an operator rotating one of 420 several keys has to tell them apart, and four characters of a 421 100-bit secret identify without reconstructing — the alternative is 422 a table of opaque ids where picking the wrong row revokes the wrong 423 vendor. 424 """ 425 426 key_id: str 427 flavor: str 428 base_url: str = "" 429 model: str = "" 430 #: Last four characters of the secret, or empty for one too short to 431 #: hint at without giving away a meaningful share of it. 432 hint: str = "" 433 added_by: str = "" 434 added_at: str = "" 435 #: Whether the platform's last key push carried this row to the 436 #: gateway: ``synced`` / ``pending`` / ``rejected``, or ``""`` when 437 #: there is no gateway to push to. 438 #: 439 #: ⚠️ **A record of the last push, never a live reading**, and the 440 #: page's copy has to say so. The gateway persists nothing, so it can 441 #: restart and lose the whole fleet a second after acknowledging it; 442 #: the platform hears on the next tick. Deriving it any other way 443 #: would mean asking the gateway from a browser — a second source for 444 #: a fact only the pushing party knows the outcome of. 445 #: 446 #: ⚠️ Defaults to ``""``, the *not-a-new-claim* direction. Defaulting 447 #: to ``synced`` would make a producer that forgets assert every 448 #: credential is live; same rule as ``ScoreboardEntry.review_status`` 449 #: and ``SubmissionResponse.ranked``. 450 sync_state: str = "" 451 #: Whether this secret is also an ``CTFY_EVAL_*`` key — one upstream 452 #: account serving both the players' gateway and the eval harness, 453 #: which spends **directly**. The gateway's ledger then calibrates 454 #: against capacity somebody else is consuming: it under-counts, the 455 #: limiter over-admits, and the first observable is a 429 storm or a 456 #: bill. 457 #: 458 #: ⚠️ Derived on the read, never stored — a column would need 459 #: clearing when either side is rotated, and a missed write reads 460 #: exactly like "these are different accounts". Defaults ``False`` 461 #: for the usual reason: the default must not be a new claim. 462 shares_eval_account: bool = False
One upstream credential as an operator may see it.
⚠️ No api_key field, and that absence is the model. The
column is sealed precisely so the secret cannot be read back, and a
wire model carrying it would undo that for every caller at once. A
last-four hint is included because an operator rotating one of
several keys has to tell them apart, and four characters of a
100-bit secret identify without reconstructing — the alternative is
a table of opaque ids where picking the wrong row revokes the wrong
vendor.
465class LlmProviderPage(CtfyModel): 466 items: list[LlmProviderInfo] = Field(default_factory=list) 467 total: int = 0 468 #: Whether this deployment has a gateway at all. ⚠️ An empty table 469 #: is ambiguous and must say which emptiness it is: "no providers 470 #: configured" and "this platform pushes to nothing" render 471 #: identically and call for opposite actions. Same argument as 472 #: ``webhook_configured`` on the email-suppression list. 473 gateway_configured: 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.
476class LlmProviderProbe(CtfyModel): 477 """What one probe of one credential established. 478 479 ⚠️ **A code, never a boolean.** Four refusals mean four different 480 fields to go and fix — the key, the address, the model, the account — 481 and one red cross reports them identically. The page renders the code 482 and shows ``detail`` beside it, which is the only thing separating 483 the two ambiguous causes of ``bad_request``. 484 """ 485 486 key_id: str = "" 487 ok: bool = False 488 #: ``ok`` · ``auth_failed`` · ``quota_exceeded`` · ``unreachable`` · 489 #: ``bad_request`` — the provider's verdict; plus ``no_gateway`` · 490 #: ``gateway_unreachable`` · ``not_synced``, which are facts about 491 #: *this platform's* wiring rather than about the credential. 492 code: str = "" 493 #: The status the provider actually sent, kept verbatim: the code is 494 #: a summary and a bug report wants the original. 0 when nothing 495 #: answered. 496 status: int = 0 497 #: The provider's own message, bounded. Never our own paraphrase — 498 #: it is what names the wrong model or the wrong host. 499 detail: str = "" 500 #: ⚠️ The offline stub answers **ok**, so a green tick there would be 501 #: the dangerous result: an operator carries it into an event whose 502 #: every answer is invented. Defaults ``False`` for the usual reason 503 #: — the default must not be a new claim. 504 stub: bool = False
What one probe of one credential established.
⚠️ A code, never a boolean. Four refusals mean four different
fields to go and fix — the key, the address, the model, the account —
and one red cross reports them identically. The page renders the code
and shows detail beside it, which is the only thing separating
the two ambiguous causes of bad_request.
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) 68 # True when this request asked to be served as an ordinary player 69 # (``X-Ctfy-View-As: player``). Transient — it describes the 70 # *request*, never the stored account, so ``role`` above already 71 # reads ``user`` while the DB row still says admin. The client 72 # needs it to render the "you are previewing" banner and the way 73 # back out; without it a narrowed session is indistinguishable 74 # from an ordinary one and nobody can leave. 75 viewing_as_player: bool = False
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.
601class MilestoneProgress(CtfyModel): 602 """Per-challenge milestone progress for the calling user. 603 604 Returned by ``GET /me/milestone-progress``. One row per challenge 605 with at least one captured question, regardless of whether the 606 challenge is fully solved. The Challenges list page renders a 607 progress bar from ``len(solved_question_ids) / total_questions`` 608 so a player can see "2/5 milestones" instead of an all-or-nothing 609 solved badge. 610 611 Like ``MySolveSummary`` this is user-scoped: ``solved_question_ids`` 612 aggregates every question id the user captured for the challenge 613 across every team they have ever been on. The optional 614 ``competition_id`` query param narrows the aggregation to solves 615 stamped against the user's team in that comp. 616 """ 617 618 challenge_id: str 619 solved_question_ids: list[str] = Field(default_factory=list) 620 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.
93class MyReportRow(CtfyModel): 94 """One range this team has written up, as the team's own index reads it. 95 96 ⚠️ **Not a narrowed :class:`ReportRosterRow`.** That one answers "who 97 has started" for an organiser and carries a team; this answers "which 98 of my ranges have I written up" and every row is the caller's own, so 99 a team column there would be the same value repeated down the page. 100 """ 101 102 challenge_id: str = "" 103 version: int = 0 104 length: int = 0 105 saved_at: datetime | None = None
One range this team has written up, as the team's own index reads it.
⚠️ Not a narrowed ReportRosterRow. That one answers "who
has started" for an organiser and carries a team; this answers "which
of my ranges have I written up" and every row is the caller's own, so
a team column there would be the same value repeated down the page.
108class MyReportsResponse(CtfyModel): 109 """This team's report index for one competition. 110 111 ⚠️ **Only ranges with a saved report appear.** The ranges the team 112 has *not* written up are the competition's challenge list minus 113 these, which the client already holds — and listing them here would 114 mean this route deciding what "the competition's ranges" are, a 115 second answer to a question ``effective_challenge_ids`` already owns. 116 """ 117 118 competition_id: str = "" 119 rows: list[MyReportRow] = Field(default_factory=list)
This team's report index for one competition.
⚠️ Only ranges with a saved report appear. The ranges the team
has not written up are the competition's challenge list minus
these, which the client already holds — and listing them here would
mean this route deciding what "the competition's ranges" are, a
second answer to a question effective_challenge_ids already owns.
573class MySolveSummary(CtfyModel): 574 """Per-challenge solve summary for the calling team. 575 576 Returned by ``GET /me/solves``. One row per challenge with at least 577 one captured flag. ``best_rank`` is the team's best (smallest) rank 578 across the flags they captured for the challenge — drives the 579 1血/2血/3血 badge on the challenge cards. Multi-flag challenges may 580 have different ranks per flag; reporting the best one gives players 581 credit for whichever piece they nailed first. 582 """ 583 584 challenge_id: str 585 best_rank: int 586 solved_at: datetime | None = None 587 # Whether **every** capture the team holds on this challenge is on 588 # the competition's board. 589 # 590 # ⚠️ ``all``, not ``any``: the card renders this as "some of what you 591 # captured here does not count", and a partial split has to say so. 592 # ``any`` would stay quiet about two uncounted flags out of three, 593 # which is the same silence as before the field existed. 594 # 595 # Unscoped (``competition_id=""``) this is always ``True``: the 596 # cross-competition history applies no window, so no capture there 597 # is "not counted" — see ``/me/solves``. 598 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.
76class NodeHeartbeat(CtfyModel): 77 """Body for ``POST /nodes/heartbeat``. 78 79 Node reports its live counts + resource utilisation on every beat. 80 The percentages are 0–100; 0 is a safe default for the first beat 81 where psutil hasn't had a prior sample to diff against. 82 """ 83 84 id: str 85 running: int = 0 86 capacity: int = 50 87 cpu_percent: float = 0.0 88 memory_percent: float = 0.0 89 # Physical RAM in MiB (not a percentage). Memory-aware admission 90 # needs the absolute size; 0 = not reported, which older node builds 91 # send and which admission reads as "unknown, fall back to slots". 92 memory_total_mb: float = 0.0 93 disk_percent: float = 0.0 94 # Unix seconds when the node sampled the metrics. The server stores 95 # ``received_at - sampled_at`` as one-way latency on the resulting 96 # health sample. Default 0 means "not measured" — older node builds 97 # that haven't been upgraded keep working. 98 sampled_at: float = 0.0 99 # The ctfy version the node itself is running. Empty on a node that 100 # predates the field, which reads as "unknown" and is never a skew — 101 # the same rule ``memory_total_mb``'s 0 already follows, so an 102 # un-upgraded fleet stays quiet instead of alarming on every beat. 103 version: str = "" 104 # Base64 X25519 public key for this node's half of an AWD match 105 # tunnel. Bounded here rather than validated: the *shape* is checked 106 # once, at plan time, where a refusal can name the node and the match 107 # it broke (`core/awd_fabric.py`); a second validator on the hottest 108 # node→platform path would be a second answer to one question. The 109 # length cap is not that validator — it stops an unbounded string 110 # reaching the column at all. 111 wireguard_public_key: str = Field(default="", max_length=64)
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 = "" 52 # The ctfy version this node last reported. "" = a node build that 53 # predates the field. Surfaced so the admin Nodes page can show a 54 # skew against the platform's own version — the node is the one 55 # boundary shipping structured data between two processes, and a 56 # mismatch there loses fields silently rather than erroring. 57 version: 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 NodeInviteInfo(CtfyModel): 161 """Admin-facing summary (``GET /nodes/invites``). No token fields.""" 162 163 id: str 164 created_at: datetime | None = None 165 expires_at: datetime | None = None 166 status: str # "active" | "consumed" | "expired" 167 consumed_by_node_id: str = "" 168 owner_vendor_id: str = "" # vendor binding, if any ("" = shared)
Admin-facing summary (GET /nodes/invites). No token fields.
60class NodePatch(CtfyModel): 61 """Body for ``PATCH /admin/nodes/{node_id}``. 62 63 Fields are optional — absent fields keep their current value. 64 display_name is the common case; labels allow adding/replacing the 65 full dict (partial label edits need a second round-trip). 66 ``owner_vendor_id`` re-assigns (or clears, with ``""``) the node's 67 vendor binding — an admin correction path complementing the 68 invite-time binding. 69 """ 70 71 display_name: str | None = None 72 labels: dict[str, str] | None = None 73 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.
114class NodeRegisterResponse(NodeInfo): 115 """Registration response carries the node's bearer token (plaintext). 116 117 Returned exactly once, at registration. The same token is used in 118 both directions: 119 120 * **node→platform** (heartbeat, deregister) — node presents it as 121 the Bearer; platform looks the row up by comparing plaintexts. 122 * **platform→node** (start/stop/status) — platform presents the 123 same plaintext on every call; node verifies against its in-memory 124 copy. 125 126 Re-registration rotates the token. The node never persists it on 127 disk; "restart = re-register = fresh credential". 128 """ 129 130 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.
806class PatchSubmissionCreate(CtfyModel): 807 """An AWD+ defence submission, addressed one of two ways. 808 809 ``instance_id`` is the handle when a box is running, exactly as on 810 :class:`SubmissionCreate`: the instance row carries the challenge, 811 the team and the competition, so the caller repeats none of them. 812 813 ⚠️ **But a defence submission does not need a box, and requiring one 814 made the format's natural order unplayable.** Judging builds from 815 the pristine tree plus the submitted bytes and never touches the 816 instance, ``GET /challenges/{id}/defence/source`` is already 817 challenge-scoped for exactly this reason, and upsolve, virtual 818 participation and a post-event fix all mean patching with nothing 819 running. So ``challenge_id`` (plus ``competition_id``, which scopes 820 which team is credited) addresses it directly. 821 822 Naming neither is refused rather than guessed at: falling back to 823 "the caller's only instance" would silently judge a challenge they 824 did not name. Naming an instance *and* a different challenge is 825 refused for the same reason. 826 827 **This is not a weaker authorisation than the instance form.** There 828 is no team on the wire either way — the credited team is resolved 829 from the caller's own membership, as ``GET /patches`` already does — 830 so the challenge form has no id to spoof at all. 831 """ 832 833 instance_id: str = "" 834 #: The challenge to defend, when no box is running. Ignored (but 835 #: cross-checked) when ``instance_id`` is given. 836 challenge_id: str = "" 837 #: Which competition credits the patch. Empty means the caller's 838 #: unscoped team, the same convention ``GET /patches`` follows. 839 competition_id: str = "" 840 #: Where the patched bytes come from. 841 #: 842 #: ``upload`` carries them in ``files``. ``live`` reads them out of 843 #: the player's own running box — they SSH in, edit, and submit with 844 #: no payload at all, which is how an AWD player actually works. 845 #: 846 #: Explicit rather than inferred from an empty ``files``: an empty 847 #: map is also what a client bug produces, and silently switching 848 #: channel on a bug would grade something the player never meant to 849 #: send. 850 source: Literal["upload", "live"] = "upload" 851 #: ``{challenge-relative path: base64(bytes)}``. Base64 because a 852 #: patch target may legitimately be binary, and JSON has no way to 853 #: carry those bytes intact otherwise. Must be empty when 854 #: ``source="live"``. 855 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.
858class PatchSubmissionInfo(CtfyModel): 859 """A defence submission's status — deliberately without its code. 860 861 The stored row keeps the submitted bytes so a verdict can be 862 re-derived after a checker fix, but a *status* view is polled, and 863 shipping every patch body on every poll is bulk nobody asked for. 864 """ 865 866 id: str 867 challenge_id: str 868 competition_id: str 869 submitted_at: datetime | None = None 870 #: The paths this submission replaced. 871 files: list[str] = Field(default_factory=list) 872 status: str = "pending" 873 #: Empty until judged, and empty forever on a submission the 874 #: platform failed to judge — our outage is not the player's loss. 875 verdict: str = "" 876 detail: str = "" 877 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.
432class PlayerShellCreate(CtfyModel): 433 """Ask for a shell into your own AWD+ box. 434 435 Note what is *absent*: no container. The admin shell takes one 436 because a super-admin legitimately picks among an instance's 437 containers; accepting one here would hand the caller the choice the 438 endpoint exists to deny them. The platform resolves the target from 439 the challenge's own ``patch.live.service``. 440 """ 441 442 #: ``bash`` or ``sh``. A tiny allow-list rather than free text — 443 #: the value becomes the exec'd command on the node. 444 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.
498class PlayerShellTicket(CtfyModel): 499 """A single-use ticket for one shell session. 500 501 Two-step because a browser cannot attach an ``Authorization`` header 502 to a WebSocket upgrade. Worth keeping for terminal clients too: it 503 means the long-lived bearer never rides in a URL. 504 """ 505 506 session_id: str 507 #: Burns on first WebSocket use and is never returned again. 508 session_token: str 509 #: Platform-relative; the client makes it absolute against its own 510 #: base URL, since only the client knows the scheme it reached us on. 511 ws_url: str 512 #: The resolved compose container. Empty for the browser, which 513 #: never needs it; the bastion does, because it opens the node 514 #: WebSocket itself rather than going through the platform page. 515 container: str = "" 516 #: Seconds the unburned ticket stays valid. 517 expires_in: int 518 #: The compose service the session will land in. Echoed back so a 519 #: player knows which container they are in without reading the 520 #: challenge's metadata — the platform resolved it, so it says so. 521 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.
109class ProfilePatchRequest(CtfyModel): 110 """Body for ``PATCH /me/profile``. Every field is optional so the 111 UI can send only the diff; the route only writes the fields that 112 were present in the JSON. ``None`` means "clear this field".""" 113 114 # ``Field(default=...)`` with a non-None sentinel is awkward in 115 # pydantic; we instead declare each field as Optional + default-None 116 # and rely on the request's raw dict (via ``model_fields_set``) to 117 # know which keys were actually present. 118 bio: str | None = Field(default=None, max_length=280) 119 country: str | None = None 120 website_url: WebUrl | None = Field(default=None, max_length=200) 121 timezone: str | None = Field(default=None, max_length=80) 122 # Values are *handles*, not URLs — ``{"github": "octocat"}``. The 123 # client owns href construction from a fixed per-provider template 124 # (``https://github.com/<handle>``), and the two providers that do 125 # accept a free-form value run it through ``normalizeUrl`` / 126 # ``mastodonUrl``, both of which only ever emit ``https://…``. So a 127 # scheme constraint here would be the wrong shape: it would reject 128 # every legitimate handle while defending against nothing. What was 129 # actually missing is a size bound. 130 social_links: dict[str, str] | None = None 131 132 @field_validator("social_links") 133 @classmethod 134 def _bound_social_links(cls, v: dict[str, str] | None) -> dict[str, str] | None: 135 if v is None: 136 return None 137 if len(v) > 10: 138 raise ValueError("social_links may contain at most 10 entries") 139 if any(len(handle) > 200 for handle in v.values()): 140 raise ValueError("each social link must be at most 200 characters") 141 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".
191class ProfileStats(CtfyModel): 192 """Aggregate per-team analytics surfaced on the public profile. 193 194 Every section is independently visibility-gated; non-owner viewers 195 see ``[]`` for a section the team has marked private. Owner and 196 admin always see the full payload.""" 197 198 calendar: list[CalendarBucket] = Field(default_factory=list) 199 by_tag: list[TagStat] = Field(default_factory=list) 200 by_difficulty: list[DifficultyStat] = Field(default_factory=list) 201 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.
547class QaChallengeView(CtfyModel): 548 """One QA challenge as seen by a team on the quiz surface. 549 550 Carries the full question / choices / description data alongside 551 per-team solve and attempt state, so the frontend can render the 552 quiz list with progress badges in a single round-trip. 553 """ 554 555 id: str 556 name: str 557 # Provenance bucket prefix — ``SECQA`` / ``MMLU-CS`` / ``CTI-MCQ`` 558 # / etc — derived from the challenge id (everything before the 559 # trailing numeric suffix). Lets the frontend group / filter 560 # without splitting the id again client-side. 561 bucket: str 562 description: str 563 difficulty: str = "" 564 tags: list[str] = Field(default_factory=list) 565 questions: list[QuestionPublicInfo] = Field(default_factory=list) 566 # Question ids the calling team has already captured. 567 solved_question_ids: list[str] = Field(default_factory=list) 568 # Question ids the team has submitted at least one wrong answer 569 # against but never solved. Drives the "Try again" amber badge. 570 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.
530class QaSubmissionCreate(CtfyModel): 531 """Request body for ``POST /qa/submissions`` — the instance-free 532 submission path for pure question-answer challenges. 533 534 No ``instance_id``: QA challenges have no Docker infra to launch, 535 so there's no per-team instance row to read context off. The 536 caller passes ``challenge_id`` + ``competition_id`` explicitly, 537 and the server resolves the submitter's team for the named 538 competition via the standard auth helper. 539 """ 540 541 challenge_id: str 542 competition_id: str 543 question_id: str = "answer" 544 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.
595class QuestionAttemptResetInfo(CtfyModel): 596 """Response from the admin reset endpoint. Echoes the new baseline 597 row so the UI can update its local state without re-fetching the 598 instance. 599 """ 600 601 team_id: str 602 challenge_id: str 603 question_id: str 604 reset_at: datetime 605 reset_by_user_id: str 606 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. Both public 40 # surfaces gate on this list: the *instance* projection 41 # (``instances._to_info``) redacts a locked prompt, and the *catalog* 42 # projection (``projections.question_infos``) omits the entry 43 # entirely until it unlocks. 44 # ⚠️ This comment used to claim that gating while the catalog 45 # published every gated prompt, id, ``requires`` edge and the total 46 # count — a false statement about disclosure, written on the model 47 # that was doing the disclosing. Recorded rather than quietly edited 48 # because it read as a guarantee and was believed. 49 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.
122class ReportAttachmentInfo(CtfyModel): 123 """One evidence file in a team's report appendix. 124 125 ⚠️ **No bytes and no URL.** The bytes are fetched from 126 ``GET .../report/attachments/{id}`` — proxied through the platform 127 like a team logo, so the bucket needs no public-read policy and an 128 unpublished competition's evidence is not enumerable by anyone who 129 guesses a key. Embedding the object key here would publish exactly 130 that guess. 131 """ 132 133 id: str = "" 134 #: The name the uploader gave, sanitised for display. ⚠️ Display 135 #: only — the object key is derived from ``id``, so this string 136 #: addresses nothing. 137 filename: str = "" 138 #: **Sniffed from the bytes.** A real image type means a client may 139 #: render a thumbnail; ``application/octet-stream`` means the file is 140 #: a download and nothing more. 141 content_type: str = "" 142 size_bytes: int = 0 143 #: Who uploaded it — the team owns the evidence, the user is the 144 #: audit, the same split ``ReportInfo.user_id`` makes. 145 user_id: str = "" 146 uploaded_at: datetime | None = None
One evidence file in a team's report appendix.
⚠️ No bytes and no URL. The bytes are fetched from
GET .../report/attachments/{id} — proxied through the platform
like a team logo, so the bucket needs no public-read policy and an
unpublished competition's evidence is not enumerable by anyone who
guesses a key. Embedding the object key here would publish exactly
that guess.
149class ReportAttachmentsResponse(CtfyModel): 150 """One engagement's appendix, oldest first. 151 152 Oldest first because a reference in the prose counts from the order 153 the team built the appendix in, and a newest-first list silently 154 renumbers it on every upload. 155 """ 156 157 competition_id: str = "" 158 team_id: str = "" 159 challenge_id: str = "" 160 attachments: list[ReportAttachmentInfo] = Field(default_factory=list) 161 #: Whether this deployment can accept an upload at all. 162 #: 163 #: ⚠️ **An empty appendix has to say which emptiness it is.** With no 164 #: object storage configured the list route still answers (reading 165 #: back what you submitted is not a submission), so an empty array 166 #: reads as "this team uploaded nothing" when it means "nobody can" — 167 #: and the player only discovers the difference by trying. Same 168 #: argument as ``webhook_configured`` on the email-suppression list 169 #: and ``gateway_configured`` on the LLM budget roster. 170 #: 171 #: Defaults ``False`` because that is the answer that is *not* a new 172 #: claim: an older server sending nothing must not make a client 173 #: promise an upload the deployment cannot perform. 174 uploads_enabled: bool = False 175 #: How many more this engagement may take. 176 #: 177 #: Served rather than derived from ``len(attachments)`` against a 178 #: constant the client restates: a ceiling spelled in two places is 179 #: one a client eventually disagrees with, and the direction it 180 #: disagrees in is a disabled upload button on an engagement that had 181 #: room. Zero means the appendix is full — a real state, distinct 182 #: from an appendix that is merely empty. 183 remaining: int = 0
One engagement's appendix, oldest first.
Oldest first because a reference in the prose counts from the order the team built the appendix in, and a newest-first list silently renumbers it on every upload.
31class ReportInfo(CtfyModel): 32 """One saved version, as its own team reads it back. 33 34 ⚠️ ``content`` is attacker-authored text — every byte is written by 35 whoever is attacking the range. It is carried and stored as plain 36 markdown, never rendered as HTML by anything that receives it, and 37 when the grading half lands it reaches a model only through the 38 bounded matching prompt described in the plan's §5.4. 39 """ 40 41 competition_id: str = "" 42 team_id: str = "" 43 #: The range this report is about. ⚠️ Empty on a **pre-split** row — 44 #: one written before the report was per challenge, when it covered 45 #: the whole competition. Those are read back unchanged; nothing 46 #: writes an empty one any more. 47 challenge_id: str = "" 48 #: Who pressed save on *this* version. The team is scored; the user 49 #: is the audit. 50 user_id: str = "" 51 #: 1-based, monotonic within (team, competition, challenge). The 52 #: current report is the highest; nothing renumbers. 53 version: int = 0 54 content: str = "" 55 saved_at: datetime | None = None
One saved version, as its own team reads it back.
⚠️ content is attacker-authored text — every byte is written by
whoever is attacking the range. It is carried and stored as plain
markdown, never rendered as HTML by anything that receives it, and
when the grading half lands it reaches a model only through the
bounded matching prompt described in the plan's §5.4.
211class ReportRosterResponse(CtfyModel): 212 competition_id: str = "" 213 rows: list[ReportRosterRow] = Field(default_factory=list) 214 #: Teams registered for this competition that have saved nothing **at 215 #: all**, in any range. Served rather than derived: a client would 216 #: have to fetch the full team list to subtract, and two clients 217 #: doing that arithmetic differently is two organisers disagreeing 218 #: about who is missing. 219 #: 220 #: ⚠️ Deliberately *not* "unwritten ``(team, range)`` pairs". That 221 #: number is ``teams × ranges`` for an event nobody has started and 222 #: grows with a bucket competition's whole catalog, so it answers a 223 #: question no organiser asked while looking like the one they did. 224 missing_team_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.
186class ReportRosterRow(CtfyModel): 187 """One team's report *status* on the organiser's roster. 188 189 ⚠️ **No body.** An event accumulates reports all week and a table of 190 "who has written one" must not read every one of them out of SQLite 191 to render a row count — the same argument that made 192 ``list_patch_submissions_admin`` name its columns rather than select 193 the entity. The body is one route further in, one team at a time. 194 """ 195 196 team_id: str = "" 197 team_name: str = "" 198 #: The range. ⚠️ Carried rather than named: a ``challenge_id`` is 199 #: already human-readable (``GAUNTLET-FIN-001``), unlike a team's 200 #: uuid, so a second lookup would buy nothing — the same reading 201 #: ``EntityCell``'s own note records. 202 challenge_id: str = "" 203 version: int = 0 204 #: Length of the current version, in characters. What an organiser 205 #: scans the roster for: who has written nothing, who has written a 206 #: paragraph, who has written a report. 207 length: int = 0 208 saved_at: datetime | None = None
One team's report status on the organiser's roster.
⚠️ No body. An event accumulates reports all week and a table of
"who has written one" must not read every one of them out of SQLite
to render a row count — the same argument that made
list_patch_submissions_admin name its columns rather than select
the entity. The body is one route further in, one team at a time.
18class ReportSaveRequest(CtfyModel): 19 """A new version of the caller's team report. 20 21 ⚠️ Deliberately **not** a patch/append shape. A report is one 22 document the team owns; sending the whole thing each time is what 23 makes "the current report" unambiguous, and what lets the server 24 keep every version without having to replay a chain of edits to 25 reconstruct any of them. 26 """ 27 28 content: str
A new version of the caller's team report.
⚠️ Deliberately not a patch/append shape. A report is one document the team owns; sending the whole thing each time is what makes "the current report" unambiguous, and what lets the server keep every version without having to replay a chain of edits to reconstruct any of them.
58class ReportSaveResponse(CtfyModel): 59 """What one save did — the version it wrote, and what it credited. 60 61 ⚠️ **Its own model rather than a field on :class:`ReportInfo`.** 62 "How many findings did this save credit" is a fact about *this 63 write*; on a read, or on a row in the version history, the same field 64 would have to default to zero, which reads as "nothing here was worth 65 anything" about versions that may have been worth a great deal. 66 """ 67 68 report: ReportInfo 69 #: Sentinels this save credited that the team did not already hold. 70 #: 71 #: This is the whole feedback channel the exercise format allows: a 72 #: player learns the last edit was worth something, and never learns 73 #: how many sentinels exist or how many remain 74 #: (``docs/gauntlet-exercise-plan.md`` §4). 75 newly_credited: int = 0
What one save did — the version it wrote, and what it credited.
⚠️ Its own model rather than a field on ReportInfo.
"How many findings did this save credit" is a fact about this
write; on a read, or on a row in the version history, the same field
would have to default to zero, which reads as "nothing here was worth
anything" about versions that may have been worth a great deal.
78class ReportVersionsResponse(CtfyModel): 79 """Every version a team has saved, newest first. 80 81 ⚠️ Carries the bodies. That is right for one team's own history and 82 for an organiser reading one team, and wrong for a competition-wide 83 roster — which is why :class:`ReportRosterRow` exists rather than 84 this being reused there. 85 """ 86 87 competition_id: str = "" 88 team_id: str = "" 89 challenge_id: str = "" 90 versions: list[ReportInfo] = Field(default_factory=list)
Every version a team has saved, newest first.
⚠️ Carries the bodies. That is right for one team's own history and
for an organiser reading one team, and wrong for a competition-wide
roster — which is why ReportRosterRow exists rather than
this being reused there.
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.
673class ScheduledJobInfo(CtfyModel): 674 """One recurring job on ``GET /admin/scheduled-jobs``: a projection of 675 ``ScheduledJobState`` plus the code-side ``default_interval_s`` so the 676 UI can show "(default: 30s)" next to a tuned value.""" 677 678 name: str = "" 679 enabled: bool = True 680 interval_s: int = 0 681 default_interval_s: int = 0 682 last_run_at_ts: float = 0.0 683 last_status: str = "" 684 last_summary: str = "" 685 last_error: str = "" 686 last_duration_s: float = 0.0 687 run_count: int = 0 688 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.
691class ScheduledJobPatch(CtfyModel): 692 """Body of ``PATCH /admin/scheduled-jobs/{name}``. Either field may be 693 omitted; ``interval_s`` is clamped to ``[5, 86400]`` by the route.""" 694 695 enabled: bool | None = None 696 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.
763class ScoreBucket(CtfyModel): 764 """One bucket of the scoreboard score-distribution histogram.""" 765 766 label: str 767 count: int = 0
One bucket of the scoreboard score-distribution histogram.
709class ScoreHistoryPoint(CtfyModel): 710 """One sample on a team's score/rank-over-time curve. 711 712 Both metrics travel together so the frontend can toggle 713 Score ⇄ Rank without a refetch. 714 """ 715 716 ts: float = 0.0 # Unix seconds (solve time of the driving event) 717 score: int = 0 # Cumulative flags captured up to ``ts`` 718 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.
721class ScoreHistorySeries(CtfyModel): 722 team_id: str 723 team_name: str 724 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.
628class ScoreboardEntry(CtfyModel): 629 rank: int = 0 630 team_id: str 631 team_name: str 632 # Challenges where the team captured every declared flag. Preserved 633 # for parity with the legacy single-flag scoreboard. 634 solved: int = 0 635 # Total individual flags captured across all challenges. Primary 636 # scoreboard metric now that multi-flag challenges exist — rewards 637 # partial progress (e.g. foothold without root). 638 flags_solved: int = 0 639 # The number this board is ranked by, as decided by the 640 # competition's scoring rule (``ctfy/server/scoring/``). Under the 641 # default ``flag_count`` rule it equals ``flags_solved``; under a 642 # points rule it is the point total. Kept separate from 643 # ``flags_solved`` so the UI can show both "how much progress" and 644 # "how many points" without knowing which rule produced the row. 645 score: int = 0 646 attempts: int = 0 647 last_solve_at: datetime | None = None 648 # Most recent activity of any kind. Distinct from ``last_solve_at`` — 649 # a team may be actively attempting without a solve, and the UI 650 # wants to show that they're not idle. 651 last_active_at: datetime | None = None 652 # OAuth provider avatar URL for the team's earliest identity that 653 # supplies one. Empty for password-only teams; the frontend falls 654 # back to a generated initials avatar in that case. 655 avatar_url: str = "" 656 # The AWD+ split of ``score``. Zero on every other format, where the 657 # board renders one number and these columns stay hidden. 658 # 659 # Both are carried even though ``attack + defence == score``, because 660 # the point of the split *is* the breakdown: a single total never 661 # explains why a team that solved less is ranked higher. Deriving one 662 # from the other in the client would put the format's arithmetic in 663 # two places. 664 attack_score: int = 0 665 defence_score: int = 0 666 # Challenges this team holds a ``fixed`` defence verdict on. A count, 667 # not the ids — the board is a ranking, and the ids are one click 668 # away on the team's own page. 669 defended: int = 0 670 # The exercise-format split of ``score``. Zero on every other format, 671 # for the same reason and with the same shape as the AWD+ pair above. 672 # 673 # ⚠️ Deliberately **not** a reuse of ``attack_score`` / 674 # ``defence_score``. The numbers happen to compose the same way and 675 # they are different facts: reusing the AWD names would put "how well 676 # this team wrote it up" in a field every reader has been told means 677 # "patches that survived the exploit". A wire field is a claim about 678 # what the number *is*. 679 # 680 # ``reporting_score`` is the graded half weighted onto the capability 681 # scale, so it is what the board ranked by — the grader's own 682 # unweighted total stays on ``ExerciseReportGradeState.points``, 683 # which is the record an appeal argues from. 684 capability_score: int = 0 685 reporting_score: int = 0 686 # The team's eligibility verdict, and **only when the viewer is 687 # entitled to it** — a competition admin or reviewer. Everywhere else 688 # this is ``""``. 689 # 690 # ``""`` means "not disclosed", never "approved". Defaulting it to 691 # ``approved`` would have been the natural-looking choice and is the 692 # bug: a public board would then assert that every team passed 693 # review, which is a stronger claim than the platform is entitled to 694 # make and is false during the review backlog. 695 # 696 # Not public because no other surface publishes it either — the 697 # verdict reaches the team's own members and the organisers, and 698 # nobody else. Putting "rejected" next to a team name on a page the 699 # whole event reads is a different act from recording it on the 700 # review queue. 701 # 702 # It never affects ``rank``: review is an eligibility marker, not a 703 # gate, and an unapproved team plays and places exactly as it would 704 # have. ``TestReviewIsNotAGate`` pins that; this field is what makes 705 # the fact *visible* to the organiser without changing it. 706 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).
467class SshCredential(CtfyModel): 468 """Everything a defender needs to `ssh` into their own box, once. 469 470 Model (b): the platform mints the keypair rather than a player 471 registering one, so both halves are here. The private key is 472 returned exactly once and never stored — there is nothing to leak 473 later and nothing to revoke, because it dies with the certificate's 474 five-minute window. 475 476 ⚠️ **The username is not cosmetic.** The bastion reads the subject 477 back off the login name, and asyncssh refuses a certificate whose 478 signed principal list omits it — so a client that connects under any 479 other name is rejected at the handshake with nothing in the response 480 explaining why. 481 """ 482 483 #: OpenSSH private key. Returned once; the platform keeps no copy. 484 private_key: str 485 #: The certificate over it, signed by the platform's SSH CA. 486 certificate: str 487 #: ``user_id:instance_id`` — what the certificate's principal says. 488 username: str 489 #: Where to present it. Only the operator knows how the bastion is 490 #: published, so the platform reports rather than derives it. 491 host: str 492 port: int 493 #: Seconds the certificate stays valid. It only has to cover 494 #: *connecting*; an established session outlives it. 495 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.
447class SshExchangeRequest(CtfyModel): 448 """The subject a bastion has already authenticated by certificate. 449 450 ⚠️ **Not the certificate.** That was the first shape and it cannot 451 be built: asyncssh discards the client certificate once it has 452 validated it, so the bastion has nothing to forward. What survives 453 the handshake is the login name, which the CA signs — a certificate 454 whose principal list omits the requested username is refused — so 455 the bastion knows the subject but cannot re-prove it with the 456 credential. The bearer on this request is what makes the claim 457 trustworthy; see ``routes/ssh_exchange.py``. 458 """ 459 460 #: Who the certificate named. Authorisation still runs against them. 461 user_id: str 462 #: Which box. Re-resolved here — it can stop or change hands between 463 #: the certificate mint and the connect. 464 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.
338class StartRequest(CtfyModel): 339 challenge_id: str 340 proxy_output_dir: str | None = None 341 # Per-instance TTL override in seconds. ``None`` (the default) 342 # defers to the platform's ``default_instance_ttl_s`` setting — 343 # admin-tunable, currently 24h. Explicit values are clamped to 344 # ``max_instance_ttl_s`` so a misbehaving SDK / agent can't pin 345 # a node by requesting an arbitrarily large TTL. 346 ttl: int | None = None 347 # Which competition this instance is for. The platform looks up 348 # the caller's per-comp team via ``(user_id, competition_id)`` and 349 # stamps the instance with that team. Optional only when the 350 # caller is on exactly one team — multi-comp users always pass 351 # it explicitly. 352 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.
474class SubmissionCreate(CtfyModel): 475 # ``instance_id`` is the canonical handle since 0.2: the server 476 # reads challenge_id and competition_id off the instance row, so 477 # callers don't need to repeat them and N instances per 478 # (team, challenge) are unambiguous on submit. 479 instance_id: str 480 # Which question on the challenge the agent is answering. Defaults 481 # to ``"flag"`` so single-question challenges stay one-liner submits; 482 # multi-question challenges (see ``ChallengeInfo.questions``) must 483 # specify the id explicitly. 484 question_id: str = "flag" 485 # The agent's submission. String for ``dynamic`` / ``static`` / 486 # ``single_select`` questions; list of strings for ``multi_select`` 487 # (e.g. ``["idor", "ssrf"]``). The server picks the grader by 488 # consulting ``spec.questions[i].mode`` on the live instance. 489 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.
492class SubmissionResponse(CtfyModel): 493 id: str 494 correct: bool 495 solve_time_s: float = 0 # seconds from instance start to correct submission 496 # 1 = first blood, 2 = second, 3 = third, 4+ = regular solve. 497 # 0 when incorrect or when the team already had a prior correct solve. 498 # Ranks within the specific question — independent of the 499 # full-challenge solve rank. 500 solve_rank: int = 0 501 # The question id this submission targeted. Echoed back to the 502 # caller so simple submission UIs don't have to thread the id 503 # separately. Equal to the request's ``question_id``. 504 question_id: str | None = None 505 # True iff this team has now captured every question declared on 506 # the challenge. Single-question challenges: always equals ``correct``. 507 challenge_fully_solved: bool = False 508 # Remaining wrong-attempt budget for the question this submission 509 # targeted, AFTER this attempt is counted (the cap is on wrongs; 510 # correct submissions don't consume budget). ``None`` when the 511 # question's mode is uncapped (dynamic free-form, or any mode the 512 # operator opted out of via ``question_attempt_caps``). The UI 513 # uses this to decrement its remaining-attempts badge without 514 # re-fetching the instance — see ``ctfy.server.submission_policy``. 515 attempts_remaining: int | None = None 516 # Whether the team's capture of this question is on the 517 # competition's board. Only meaningful when ``correct``. 518 # 519 # ⚠️ **Defaults to False on purpose**, which is the loud direction. 520 # A competition accepts submissions outside its play window and 521 # simply does not count them (only a competition declaring 522 # ``sessions`` refuses), so before this field the player got 523 # ``correct: true`` and scored nothing, with nothing anywhere 524 # saying why. A producer that forgets to set this makes every solve 525 # claim it did not count — visible in a minute; the other default 526 # restores exactly the silence this exists to end. 527 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.
165class TagStat(CtfyModel): 166 """One axis on the per-tag strength radar. 167 168 ``solved`` is the team's solve count for challenges carrying this 169 tag; ``total`` is the platform-wide challenge count for the tag, 170 so the frontend can render "team / total" or normalize to a 0–1 171 ratio for the radar polygon.""" 172 173 tag: str = "" 174 solved: int = 0 175 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.
184class TrendPoint(CtfyModel): 185 """One UTC-day bucket on the solve-trend line.""" 186 187 date: str = "" # YYYY-MM-DD, UTC 188 solves: int = 0
One UTC-day bucket on the solve-trend line.
98class UpdateUserRoleRequest(CtfyModel): 99 """Body for ``PATCH /admin/users/{user_id}/role``. 100 101 Only ``"admin"`` and ``"user"`` are accepted — granting 102 ``"super_admin"`` is reserved for the env allowlist 103 (``CTFY_SUPER_ADMIN_EMAILS``) so the trust anchor stays out-of-band. 104 """ 105 106 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.
784class UserScoreboardEntry(CtfyModel): 785 """One row on the user-ranked global scoreboard. 786 787 Aggregates a single user's solves across every team they've 788 ever been on (each ``SolveState`` carries both ``user_id`` and 789 ``team_id``, so per-user totals are a straight filter over the 790 global solve table). Excludes users with zero solves to keep 791 the public list compact. 792 """ 793 794 rank: int = 0 795 user_id: str 796 display_name: str 797 avatar_url: str = "" 798 country: str = "" 799 # Total flags solved across all teams the user has ever been on. 800 flags_solved: int = 0 801 # Challenges fully solved (every declared flag captured). 802 solved: int = 0 803 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.
204class UserSolveTrend(CtfyModel): 205 """The calling user's daily solve count for the last ``days`` UTC days, 206 aggregated across every team they have ever been on (matched by 207 ``user_id`` on each :class:`SolveState`). 208 209 Powers the Dashboard's "personal solve trend" chart, which is 210 user-scoped — independent of which competition the user is currently 211 registered for. Multi-flag challenges count once per flag day, the 212 same way :class:`ProfileStats.solve_trend` does it. 213 """ 214 215 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.
371class VerifyAnswerRequest(CtfyModel): 372 """Oracle-mode answer check — same grader as /submissions but writes 373 no submission record. Carries the same shape as :class:`SubmissionCreate` 374 minus the audit trail. 375 """ 376 377 # Which question on the challenge the agent is checking. Defaults 378 # to ``"flag"`` for single-question challenges. 379 question_id: str = "flag" 380 # The answer to verify (str for dynamic / static / single_select; 381 # list[str] for multi_select). 382 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.
385class VerifyAnswerResponse(CtfyModel): 386 correct: bool 387 # Echoes the request's ``question_id`` on a correct match; ``None`` 388 # on a wrong claim. Useful for batched verification calls where the 389 # client wants to confirm which check the response refers to. 390 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.
609class VirtualGhostBoard(CtfyModel): 610 """The source contest's standings at the instant this replay has 611 reached, with the replayer merged in and ranked among them. 612 613 ⚠️ Nothing here is stored. The board is 614 `compute_scoreboard_filtered` with an `until_iso`, which the 615 platform has always supported — the replay's own progress and the 616 source's progression are two reads of facts that already exist. 617 """ 618 619 source_competition_id: str 620 #: How far into the replay the reader is. The client renders a clock 621 #: from this rather than recomputing it from `starts_at`, so the two 622 #: cannot disagree about which minute the ghosts are showing. 623 elapsed_seconds: int = 0 624 #: The instant in the *source* being shown. Bounded by its end. 625 cutoff: datetime | None = None 626 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.
598class VirtualGhostRow(ScoreboardEntry): 599 """A standings row on a replay's ghost board. 600 601 `is_me` is the one thing a client cannot derive: the replayer's row 602 comes from a *different competition* than the ghosts', so there is no 603 team id it could compare against. 604 """ 605 606 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.