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 PlatformSettingInfo, 48 PlatformSettingPatch, 49 QuestionAttemptResetInfo, 50 QuestionAttemptResetRequest, 51 ScheduledJobInfo, 52 ScheduledJobPatch, 53 ShellRecordingInfo, 54) 55from ctfy.server.models.announcement import ( 56 AnnouncementCreate, 57 AnnouncementInfo, 58 AnnouncementUpdate, 59) 60from ctfy.server.models.auth import ( 61 AuthTokenResponse, 62 CreateFineGrainedTokenRequest, 63 CreateFineGrainedTokenResponse, 64 DeviceApproveRequest, 65 DeviceCodeResponse, 66 DeviceInfoResponse, 67 DeviceTokenRequest, 68 DeviceTokenResponse, 69 LinkedIdentity, 70 LinkStartResponse, 71 LoginRequest, 72 OAuthProviderInfo, 73 PasswordAuthInfo, 74 ProvidersResponse, 75 RegisterRequest, 76 ScopeCatalogEntry, 77 SetPasswordRequest, 78 TokenInfo, 79 TokenScopesResponse, 80) 81from ctfy.server.models.challenge import ( 82 AttachmentList, 83 ChallengeBuildKickoffNodeResult, 84 ChallengeBuildKickoffResponse, 85 ChallengeBuildNodeState, 86 ChallengeBuildStateResponse, 87 ChallengeBuildStateRow, 88 ChallengeFacetCount, 89 ChallengeFacets, 90 ChallengeFlagStats, 91 ChallengeInfo, 92 ChallengePullKickoffNodeResult, 93 ChallengePullKickoffResponse, 94 ChallengePullNodeState, 95 ChallengePullStateResponse, 96 ChallengePullStateRow, 97 ChallengeRescanNodeResult, 98 ChallengeRescanResult, 99 ChallengeSolveAttempt, 100 ChallengeSolveAttemptsResponse, 101 ChallengeStats, 102 ChallengeTeamSolveSummary, 103 CompetitionChallengeBreakdown, 104 CompetitionChallengeRow, 105 CompetitionScoreDistribution, 106 CompetitionScoreHistory, 107 MilestoneProgress, 108 MySolveSummary, 109 QaChallengeView, 110 QaSubmissionCreate, 111 QuestionPublicInfo, 112 ScoreboardEntry, 113 ScoreBucket, 114 ScoreHistoryPoint, 115 ScoreHistorySeries, 116 SubmissionCreate, 117 SubmissionResponse, 118 UserScoreboardEntry, 119) 120from ctfy.server.models.competition import ( 121 CompetitionAdminInfo, 122 CompetitionCreate, 123 CompetitionDetail, 124 CompetitionInfo, 125 CompetitionInviteInfo, 126 CompetitionMembershipInfo, 127 CompetitionRegistrationInfo, 128 CompetitionUpdate, 129 GrantCompetitionAdminRequest, 130) 131from ctfy.server.models.feedback import ( 132 AdminFeedbackRow, 133 FeedbackStats, 134 MyReactionsResponse, 135) 136from ctfy.server.models.instance import ( 137 InstanceInfo, 138 InstanceQuestionInfo, 139 InstanceRecordArtifacts, 140 InstanceRecordDetail, 141 InstanceRecordInfo, 142 InstanceRecordInfoDetail, 143 InstanceStatusResponse, 144 RenewResponse, 145 StartRequest, 146 StartResponse, 147 StopResponse, 148 VerifyAnswerRequest, 149 VerifyAnswerResponse, 150) 151from ctfy.server.models.meta import ( 152 ErrorResponse, 153 HealthResponse, 154 MetaChallenges, 155 MetaPlatform, 156 MetaResponse, 157) 158from ctfy.server.models.node import ( 159 ClusterInfo, 160 CreateInviteRequest, 161 CreateInviteResponse, 162 NodeHeartbeat, 163 NodeInfo, 164 NodeInviteInfo, 165 NodePatch, 166 NodeRegister, 167 NodeRegisterResponse, 168) 169from ctfy.server.models.pagination import BigLimitOffsetPage 170from ctfy.server.models.team import ( 171 InboxAnnouncement, 172 InboxCaptainRequest, 173 InboxCompetitionInvite, 174 InboxIncomingInvite, 175 InboxOutgoingRequest, 176 InboxResponse, 177 TeamDetail, 178 TeamInfo, 179 TeamInviteInfo, 180 TeamMemberInfo, 181) 182from ctfy.server.models.user import ( 183 PROFILE_VISIBILITY_KEYS, 184 AdminUserInfo, 185 CalendarBucket, 186 DeleteMeRequest, 187 DifficultyStat, 188 MeResponse, 189 ProfilePatchRequest, 190 ProfileStats, 191 TagStat, 192 TrendPoint, 193 UpdateUserRoleRequest, 194 UserInfo, 195 UserSolveTrend, 196) 197 198__all__ = [ 199 "PROFILE_VISIBILITY_KEYS", 200 "AchievementCatalogEntry", 201 "AchievementProgress", 202 "AchievementSummary", 203 "Activity", 204 "ActivityTimeseries", 205 "ActivityTimeseriesBucket", 206 "AdminChallengeLastError", 207 "AdminChallengeLatencyBucket", 208 "AdminChallengeStatsRow", 209 "AdminChallengeTimeseries", 210 "AdminChallengeTimeseriesBucket", 211 "AdminFeedbackRow", 212 "AdminHealthFlags", 213 "AdminOverview", 214 "AdminOverviewCounts", 215 "AdminRecentError", 216 "AdminSilentChallenge", 217 "AdminSolveCell", 218 "AdminSolveMatrix", 219 "AdminSolveMatrixChallenge", 220 "AdminSolveMatrixTeam", 221 "AdminStuckInstance", 222 "AdminTaskInfo", 223 "AdminTaskListResponse", 224 "AdminTaskLogLine", 225 "AdminTaskLogsResponse", 226 "AdminTaskSubmitRequest", 227 "AdminTimeseries", 228 "AdminTimeseriesBucket", 229 "AdminTrafficInstanceRow", 230 "AdminTrafficSummary", 231 "AdminTrafficTeamRow", 232 "AdminUnhealthyNode", 233 "AdminUserInfo", 234 "AnnouncementCreate", 235 "AnnouncementInfo", 236 "AnnouncementUpdate", 237 "AttachmentList", 238 "AuthTokenResponse", 239 "BigLimitOffsetPage", 240 "CalendarBucket", 241 "ChallengeBuildKickoffNodeResult", 242 "ChallengeBuildKickoffResponse", 243 "ChallengeBuildNodeState", 244 "ChallengeBuildStateResponse", 245 "ChallengeBuildStateRow", 246 "ChallengeFacetCount", 247 "ChallengeFacets", 248 "ChallengeFlagStats", 249 "ChallengeInfo", 250 "ChallengePullKickoffNodeResult", 251 "ChallengePullKickoffResponse", 252 "ChallengePullNodeState", 253 "ChallengePullStateResponse", 254 "ChallengePullStateRow", 255 "ChallengeRescanNodeResult", 256 "ChallengeRescanResult", 257 "ChallengeSolveAttempt", 258 "ChallengeSolveAttemptsResponse", 259 "ChallengeStats", 260 "ChallengeTeamSolveSummary", 261 "ClusterInfo", 262 "CompetitionAdminInfo", 263 "CompetitionChallengeBreakdown", 264 "CompetitionChallengeRow", 265 "CompetitionCreate", 266 "CompetitionDetail", 267 "CompetitionInfo", 268 "CompetitionInviteInfo", 269 "CompetitionMembershipInfo", 270 "CompetitionRegistrationInfo", 271 "CompetitionScoreDistribution", 272 "CompetitionScoreHistory", 273 "CompetitionUpdate", 274 "CreateFineGrainedTokenRequest", 275 "CreateFineGrainedTokenResponse", 276 "CreateInviteRequest", 277 "CreateInviteResponse", 278 "DeleteMeRequest", 279 "DeviceApproveRequest", 280 "DeviceCodeResponse", 281 "DeviceInfoResponse", 282 "DeviceTokenRequest", 283 "DeviceTokenResponse", 284 "DifficultyStat", 285 "EasterEggClaim", 286 "ErrorResponse", 287 "FeedbackStats", 288 "GrantCompetitionAdminRequest", 289 "HealthResponse", 290 "InboxAnnouncement", 291 "InboxCaptainRequest", 292 "InboxCompetitionInvite", 293 "InboxIncomingInvite", 294 "InboxOutgoingRequest", 295 "InboxResponse", 296 "InstanceInfo", 297 "InstanceQuestionInfo", 298 "InstanceRecordArtifacts", 299 "InstanceRecordDetail", 300 "InstanceRecordInfo", 301 "InstanceRecordInfoDetail", 302 "InstanceStatusResponse", 303 "LinkStartResponse", 304 "LinkedIdentity", 305 "LoginRequest", 306 "MeResponse", 307 "MetaChallenges", 308 "MetaPlatform", 309 "MetaResponse", 310 "MilestoneProgress", 311 "MyAchievementsResponse", 312 "MyReactionsResponse", 313 "MySolveSummary", 314 "NodeHeartbeat", 315 "NodeInfo", 316 "NodeInviteInfo", 317 "NodePatch", 318 "NodeRegister", 319 "NodeRegisterResponse", 320 "OAuthProviderInfo", 321 "PasswordAuthInfo", 322 "PlatformSettingInfo", 323 "PlatformSettingPatch", 324 "ProfilePatchRequest", 325 "ProfileStats", 326 "ProvidersResponse", 327 "QaChallengeView", 328 "QaSubmissionCreate", 329 "QuestionAttemptResetInfo", 330 "QuestionAttemptResetRequest", 331 "QuestionPublicInfo", 332 "RecentUnlock", 333 "RegisterRequest", 334 "RenewResponse", 335 "ScheduledJobInfo", 336 "ScheduledJobPatch", 337 "ScopeCatalogEntry", 338 "ScoreBucket", 339 "ScoreHistoryPoint", 340 "ScoreHistorySeries", 341 "ScoreboardEntry", 342 "SetPasswordRequest", 343 "ShellRecordingInfo", 344 "StarGazerVerifyResponse", 345 "StartRequest", 346 "StartResponse", 347 "StopResponse", 348 "SubmissionCreate", 349 "SubmissionResponse", 350 "TagStat", 351 "TeamAchievement", 352 "TeamDetail", 353 "TeamInfo", 354 "TeamInviteInfo", 355 "TeamMemberInfo", 356 "TokenInfo", 357 "TokenScopesResponse", 358 "TrendPoint", 359 "UpdateUserRoleRequest", 360 "UserInfo", 361 "UserScoreboardEntry", 362 "UserSolveTrend", 363 "VerifyAnswerRequest", 364 "VerifyAnswerResponse", 365]
28class AchievementCatalogEntry(CtfyModel): 29 """One badge as advertised to the frontend.""" 30 31 id: str 32 name: str 33 description: str 34 icon: str 35 tier: str 36 secret: bool = False 37 # Only populated for locked entries on /me/achievements — the 38 # general catalog endpoint and unlocked entries don't carry it. 39 progress: AchievementProgress | None = None 40 # Score weight per tier (bronze=10, silver=25, gold=50, secret=100). 41 points: int = 0 42 # Number of distinct teams that have unlocked this badge. 43 earned_by_count: int = 0 44 # ``common`` | ``uncommon`` | ``rare`` | ``epic`` | ``mythic`` | 45 # ``unearned``. 46 rarity: str = "unearned"
One badge as advertised to the frontend.
14class AchievementProgress(CtfyModel): 15 """Quantifiable progress toward a locked badge. 16 17 ``current`` is what the team has so far, ``target`` is the 18 threshold the rule predicate checks against. Provided only when 19 the achievement has a registered progress provider (decathlete, 20 centurion, completionist, sisyphus, unicorn). Time-of-day / 21 first-blood / easter-egg badges return ``progress = null``. 22 """ 23 24 current: int 25 target: int
Quantifiable progress toward a locked badge.
current is what the team has so far, target is the
threshold the rule predicate checks against. Provided only when
the achievement has a registered progress provider (decathlete,
centurion, completionist, sisyphus, unicorn). Time-of-day /
first-blood / easter-egg badges return progress = null.
65class AchievementSummary(CtfyModel): 66 """Aggregate roll-up for the Achievements page header.""" 67 68 unlocked_count: int = 0 69 total_count: int = 0 70 score: int = 0 71 total_score: int = 0
Aggregate roll-up for the Achievements page header.
18class Activity(CtfyModel): 19 """A single activity event in the platform log.""" 20 21 id: str = "" 22 timestamp: datetime | None = None 23 event: str # See PlatformEvent enum for valid values 24 team_id: str = "" 25 team_name: str = "" 26 challenge_id: str = "" 27 # Who triggered this event — surfaced on the public feed so admins 28 # can filter by actor and auditors can trace back. Values mirror 29 # the ActivityState fields in core.state.models. 30 actor_id: str = "" 31 actor_name: str = "" 32 actor_type: str = "" 33 # Denormalised actor avatar (GitHub/Google), surfaced on the feed so 34 # each event can render the actor's real face. Empty ⇒ the frontend 35 # renders a generated initials chip instead. 36 actor_avatar_url: str = "" 37 detail: ActivityDetail = Field(default_factory=ActivityDetail)
A single activity event in the platform log.
55class ActivityTimeseries(CtfyModel): 56 """Bucketed activity counts for the per-team activity histogram. 57 58 Same ``ts`` semantics as ``AdminTimeseries`` — oldest-first, right 59 edge anchored to "now". ``events`` is the sorted union of event 60 types that appear in any bucket, so the frontend has a stable list 61 of stack segments to render even when individual buckets are empty. 62 """ 63 64 window_s: int = 0 65 bucket_s: int = 0 66 events: list[str] = Field(default_factory=list) 67 buckets: list[ActivityTimeseriesBucket] = Field(default_factory=list)
Bucketed activity counts for the per-team activity histogram.
Same ts semantics as AdminTimeseries — oldest-first, right
edge anchored to "now". events is the sorted union of event
types that appear in any bucket, so the frontend has a stable list
of stack segments to render even when individual buckets are empty.
40class ActivityTimeseriesBucket(CtfyModel): 41 """One bucket of the team's activity histogram. 42 43 ``counts`` is keyed by event type (``flag_correct``, ``instance_started``, 44 …) so the frontend can stack each event as its own bar segment without 45 a follow-up shape-change to add new event types. 46 47 See :class:`AdminTimeseriesBucket` for the ``partial`` semantics. 48 """ 49 50 ts: float = 0.0 # right edge, Unix seconds 51 counts: dict[str, int] = Field(default_factory=dict) 52 partial: bool = False
One bucket of the team's activity histogram.
counts is keyed by event type (flag_correct, instance_started,
…) so the frontend can stack each event as its own bar segment without
a follow-up shape-change to add new event types.
See AdminTimeseriesBucket for the partial semantics.
69class AdminChallengeLastError(CtfyModel): 70 """Most-recent error against a challenge, surfaced on the per-challenge 71 admin row so an admin can spot a broken challenge without opening 72 instance history.""" 73 74 ts: float = 0.0 # stopped_at of the failing instance 75 message: str = "" 76 instance_id: str = ""
Most-recent error against a challenge, surfaced on the per-challenge admin row so an admin can spot a broken challenge without opening instance history.
111class AdminChallengeLatencyBucket(CtfyModel): 112 """One fixed bucket of the cluster-wide launch-duration histogram. 113 114 The Challenges admin table paginates server-side, so the pooled 115 "how slow are launches overall" picture can't be re-derived in the 116 browser from one page of rows — this endpoint pools every 117 challenge's recent successful launches and bins them with the same 118 absolute edges the per-row drawer uses, so cross-page the shape 119 stays comparable. 120 """ 121 122 label: str 123 count: int = 0
One fixed bucket of the cluster-wide launch-duration histogram.
The Challenges admin table paginates server-side, so the pooled "how slow are launches overall" picture can't be re-derived in the browser from one page of rows — this endpoint pools every challenge's recent successful launches and bins them with the same absolute edges the per-row drawer uses, so cross-page the shape stays comparable.
79class AdminChallengeStatsRow(CtfyModel): 80 """One row of the per-challenge admin dashboard. 81 82 Aggregates over ``InstanceRecord`` (lifetime) plus live ``InstanceState`` 83 (running). Launch-duration percentiles are computed only over rows with 84 both ``requested_at`` and ``ready_at`` populated — error rows that never 85 reached READY contribute to ``errors`` / ``error_rate`` but not to p50/p95. 86 """ 87 88 challenge_id: str 89 name: str = "" 90 difficulty: str = "" 91 # Spec metadata folded in so the admin table no longer has to join 92 # a second /challenges bulk fetch in the browser to show / sort / 93 # filter by these (it paginates server-side now). 94 category: str = "" 95 tags: list[str] = Field(default_factory=list) 96 question_count: int = 0 97 launches: int = 0 98 errors: int = 0 99 error_rate: float = 0.0 100 avg_launch_s: float = 0.0 101 p50_launch_s: float = 0.0 102 p95_launch_s: float = 0.0 103 solve_rate: float = 0.0 104 running_count: int = 0 105 last_error: AdminChallengeLastError | None = None 106 # Newest-first launch durations, capped at 20, used for the inline 107 # sparkline. Recorded only for successful launches. 108 recent_launch_durations: list[float] = Field(default_factory=list)
One row of the per-challenge admin dashboard.
Aggregates over InstanceRecord (lifetime) plus live InstanceState
(running). Launch-duration percentiles are computed only over rows with
both requested_at and ready_at populated — error rows that never
reached READY contribute to errors / error_rate but not to p50/p95.
225class AdminChallengeTimeseries(CtfyModel): 226 """Per-challenge bucketed health metrics for the admin drilldown. 227 228 Backs the row-level drawer on /admin/challenges that surfaces "did 229 this challenge's error rate spike at some specific time" without 230 forcing the operator to scrub through raw activity. 231 """ 232 233 challenge_id: str = "" 234 window_s: int = 0 235 bucket_s: int = 0 236 buckets: list[AdminChallengeTimeseriesBucket] = Field(default_factory=list)
Per-challenge bucketed health metrics for the admin drilldown.
Backs the row-level drawer on /admin/challenges that surfaces "did this challenge's error rate spike at some specific time" without forcing the operator to scrub through raw activity.
206class AdminChallengeTimeseriesBucket(CtfyModel): 207 """One bucket of the per-challenge health time-series. 208 209 Carries enough fields for the admin UI to render error-rate **and** 210 launch-duration trends from a single response — no need for the 211 frontend to issue three calls or do its own bucketing. 212 213 See :class:`AdminTimeseriesBucket` for the ``partial`` semantics. 214 """ 215 216 ts: float = 0.0 # right edge, Unix seconds 217 launches: int = 0 218 errors: int = 0 219 error_rate: float = 0.0 # errors / launches; 0 when no launches 220 p50_launch_s: float = 0.0 221 p95_launch_s: float = 0.0 222 partial: bool = False
One bucket of the per-challenge health time-series.
Carries enough fields for the admin UI to render error-rate and launch-duration trends from a single response — no need for the frontend to issue three calls or do its own bucketing.
See AdminTimeseriesBucket for the partial semantics.
43class AdminFeedbackRow(CtfyModel): 44 """One row on the admin feedback audit list. 45 46 Mirrors ``SolveFeedbackState`` plus denormalised user identity for 47 triage convenience. Only ``require_admin``-gated endpoints expose 48 this shape; the public ``stats`` endpoint never includes per-user 49 fields. 50 """ 51 52 user_id: str 53 user_display_name: str = "" 54 user_email: str = "" 55 challenge_id: str 56 team_id: str 57 competition_id: str 58 reaction: Reaction 59 created_at: datetime | None = None 60 updated_at: datetime | None = None
One row on the admin feedback audit list.
Mirrors SolveFeedbackState plus denormalised user identity for
triage convenience. Only require_admin-gated endpoints expose
this shape; the public stats endpoint never includes per-user
fields.
167class AdminHealthFlags(CtfyModel): 168 """Aggregated red-flag panels for the admin Overview "needs attention" 169 section. All four lists are independently populated and may be empty.""" 170 171 stuck_starting: list[AdminStuckInstance] = Field(default_factory=list) 172 recent_errors: list[AdminRecentError] = Field(default_factory=list) 173 unhealthy_nodes: list[AdminUnhealthyNode] = Field(default_factory=list) 174 silent_challenges: list[AdminSilentChallenge] = Field(default_factory=list)
Aggregated red-flag panels for the admin Overview "needs attention" section. All four lists are independently populated and may be empty.
239class AdminOverview(CtfyModel): 240 """Single aggregate payload for the admin landing page.""" 241 242 nodes: AdminOverviewCounts = Field(default_factory=AdminOverviewCounts) 243 capacity: int = 0 244 running_instances: int = 0 245 teams_total: int = 0 246 solves_total: int = 0 247 solves_today: int = 0 248 # User-engagement counters — the "are people actually on the platform right 249 # now?" signal the infra/challenge/team metrics above don't answer. 250 # ``users_active_24h`` is distinct humans who triggered a write action 251 # (launch / submit / solve / join / answer) in the last 24h, not raw logins 252 # (pure browsing leaves no activity row). ``users_new_24h`` is registrations 253 # in the same window. All default to 0 so the field set stays additive. 254 users_total: int = 0 255 users_active_24h: int = 0 256 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.
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.
388class AdminTaskInfo(CtfyModel): 389 """One background task on ``GET /admin/tasks`` / detail. A projection 390 of ``TaskState`` onto the wire (these routes are admin-only, so the 391 full ``error_detail`` traceback tail is included).""" 392 393 id: str = "" 394 kind: str = "" 395 status: str = "" 396 params: dict[str, Any] = Field(default_factory=dict) 397 result: dict[str, Any] = Field(default_factory=dict) 398 progress: float = 0.0 399 progress_message: str = "" 400 error: str = "" 401 error_detail: str = "" 402 cancel_requested: bool = False 403 created_by_user_id: str = "" 404 created_by_name: str = "" 405 created_at_ts: float = 0.0 406 started_at_ts: float = 0.0 407 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).
410class AdminTaskListResponse(CtfyModel): 411 """Paginated task list.""" 412 413 items: list[AdminTaskInfo] = Field(default_factory=list) 414 total: int = 0 415 offset: int = 0 416 limit: int = 50
Paginated task list.
419class AdminTaskLogLine(CtfyModel): 420 """One verbose log line for a task (admin-only).""" 421 422 seq: int = 0 423 ts: float = 0.0 424 level: str = "info" 425 message: str = ""
One verbose log line for a task (admin-only).
428class AdminTaskLogsResponse(CtfyModel): 429 """A cursor page of task logs. ``next_after`` is the ``seq`` to pass as 430 ``?after=`` on the next poll (the last item's seq, or the request's 431 ``after`` when the page is empty — so an empty poll never rewinds the 432 cursor).""" 433 434 items: list[AdminTaskLogLine] = Field(default_factory=list) 435 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).
438class AdminTaskSubmitRequest(CtfyModel): 439 """Body of ``POST /admin/tasks``. ``kind`` is validated against the 440 runner's handler registry; ``params`` is the kind-specific input 441 (e.g. ``{"competition_id": ...}`` or ``{"job": "ttl_sweep"}``).""" 442 443 kind: str = "" 444 params: dict[str, Any] = Field(default_factory=dict)
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.
274class AdminTrafficInstanceRow(CtfyModel): 275 """One instance × team × challenge row for the admin traffic dashboard.""" 276 277 instance_id: str 278 team_id: str 279 team_name: str = "" 280 challenge_id: str = "" 281 name: str = "" 282 status: str = "" 283 request_count: int = 0 284 started_at: float = 0.0 285 stopped_at: float = 0.0 286 # ``True`` if the instance is still running (request_count is approximate 287 # — refreshed only when the dashboard re-fetches). 288 live: bool = False
One instance × team × challenge row for the admin traffic dashboard.
291class AdminTrafficSummary(CtfyModel): 292 """Top-level response for ``GET /admin/traffic/summary``.""" 293 294 teams: list[AdminTrafficTeamRow] = Field(default_factory=list) 295 instances: list[AdminTrafficInstanceRow] = Field(default_factory=list) 296 total_requests: int = 0
Top-level response for GET /admin/traffic/summary.
259class AdminTrafficTeamRow(CtfyModel): 260 """Per-team aggregate row for the admin traffic dashboard. 261 262 Sums HTTP request counts across every archived instance the team has 263 ever launched, plus the live count for any instances the team is 264 still running. 265 """ 266 267 team_id: str 268 team_name: str = "" 269 instance_count: int = 0 270 request_count: int = 0 271 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.
72class AdminUserInfo(UserInfo): 73 """Admin-only view of a user. Carries the privilege tier and the 74 most recent role-change audit fields.""" 75 76 email: str = "" 77 role: Literal["user", "admin", "super_admin"] = "user" 78 promoted_by: str | None = None 79 promoted_at: datetime | None = None 80 # OAuth providers the user has bound (e.g. ``["github", "google"]``), 81 # so the admin user list can surface which SSO accounts are linked. 82 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".
85class AttachmentList(CtfyModel): 86 """Response payload for ``GET /challenges/{id}/attachments``. 87 88 Same data as ``ChallengeInfo.attachments`` but addressable directly 89 so pre-launch UI / agent tooling can fetch it without paging through 90 the catalog. Order matches the on-disk sort. 91 """ 92 93 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.
72class AuthTokenResponse(CtfyModel): 73 """Returned by ``POST /auth/register`` and ``POST /auth/login``. 74 75 The plaintext user token is returned directly (not via fragment) since 76 these endpoints are called by first-party XHR, not redirects. 77 """ 78 79 token: str 80 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.
122class CalendarBucket(CtfyModel): 123 """One UTC-day cell on the contribution calendar. 124 125 ``count`` aggregates correct submissions + first-time solves on that 126 day; effectively "did anything productive happen". The series is 127 dense — every day in the requested window is present, zero-filled.""" 128 129 date: str = "" # YYYY-MM-DD, UTC 130 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.
215class ChallengeBuildKickoffNodeResult(CtfyModel): 216 """One node's response to a build kickoff (single or all). 217 218 ``queued`` is populated only on the ``build-all`` fan-out; for the 219 single-challenge variant the platform inspects ``status`` to learn 220 what the node accepted. 221 """ 222 223 node_id: str 224 ok: bool 225 status: str = "" 226 queued: list[str] = Field(default_factory=list) 227 skipped_built: list[str] = Field(default_factory=list) 228 skipped_in_progress: list[str] = Field(default_factory=list) 229 error: str = ""
232class ChallengeBuildKickoffResponse(CtfyModel): 233 """Admin ``POST /admin/challenges/{id}/build`` and ``…/build-all`` response.""" 234 235 challenge_id: str = "" # empty for build-all 236 nodes: list[ChallengeBuildKickoffNodeResult] = Field(default_factory=list)
Admin POST /admin/challenges/{id}/build and …/build-all response.
172class ChallengeBuildNodeState(CtfyModel): 173 """One worker node's view of a single challenge's build state. 174 175 ``status`` is one of ``unbuilt`` / ``building`` / ``built`` / 176 ``failed``. ``node_error`` is non-empty only when the platform 177 could not reach the node at all (heartbeat stale / 5xx) — in that 178 case ``status`` is forced to ``unbuilt`` for the aggregate. 179 """ 180 181 node_id: str 182 status: str = "unbuilt" 183 built_at: float = 0.0 184 error: str = "" 185 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.
209class ChallengeBuildStateResponse(CtfyModel): 210 """Admin ``GET /admin/challenges/build-state`` response.""" 211 212 rows: list[ChallengeBuildStateRow] = Field(default_factory=list)
Admin GET /admin/challenges/build-state response.
188class ChallengeBuildStateRow(CtfyModel): 189 """Per-challenge build state aggregated across every online node. 190 191 ``aggregated`` rolls up ``nodes`` using a worst-case rule so the 192 table's single status column behaves intuitively: 193 194 * any ``building`` → ``building`` (yellow) 195 * any ``failed`` → ``failed`` (red) 196 * any ``unbuilt`` → ``unbuilt`` (grey) 197 * else → ``built`` (green) 198 199 A node that couldn't be reached at all contributes ``unbuilt`` so 200 the aggregate stays conservative — the admin still sees a 201 "missing" pip and can drill into the modal to find out why. 202 """ 203 204 challenge_id: str 205 aggregated: str = "unbuilt" 206 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.
96class ChallengeFacetCount(CtfyModel): 97 """One bucket on the challenge catalog facet summary. 98 99 ``value`` is the difficulty / tag string; ``total`` is how many 100 challenges in scope carry it. ``solved`` is the *calling* user's 101 solved count in that bucket (across every team they have been on); 102 it is 0 for anonymous callers and for tag buckets (the UI only 103 renders a solved/total ratio for difficulty). 104 """ 105 106 value: str 107 total: int = 0 108 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).
111class ChallengeFacets(CtfyModel): 112 """Catalog aggregates for the Challenges page side panels + filter 113 pill counts — served by ``GET /challenges/facets`` so the page no 114 longer derives them from a bulk fetch of the whole catalog. 115 116 Scoped by ``competition_id`` (when given) exactly like the list 117 endpoint, so the pills/charts reflect the same subset the paged 118 list pages through. 119 """ 120 121 total: int = 0 122 difficulty: list[ChallengeFacetCount] = Field(default_factory=list) 123 # Per-category counts (only categories with ≥1 challenge in scope 124 # show up). Ordered by the canonical category sequence so the 125 # filter pills always render web → pwn → reverse → crypto → misc. 126 category: list[ChallengeFacetCount] = Field(default_factory=list) 127 tags: list[ChallengeFacetCount] = Field(default_factory=list) 128 # Per-status counts for the calling caller (``solved`` / ``unsolved`` 129 # / ``running``). Drives the count chip on each status filter pill 130 # so the page doesn't N+1 a status-filtered list per pill. Buckets 131 # are always present (in canonical order) for layout stability; on 132 # anonymous callers every count is 0 — never leaking another 133 # team's state. 134 status: 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.
304class ChallengeFlagStats(CtfyModel): 305 """Per-flag aggregate for one challenge (for the detail page).""" 306 307 flag_id: str 308 solves_count: int = 0
Per-flag aggregate for one challenge (for the detail page).
45class ChallengeInfo(CtfyModel): 46 id: str 47 name: str 48 category: ChallengeCategory = ChallengeCategory.WEB 49 difficulty: str = "" 50 description: str = "" 51 # Full-challenge solves: teams that captured every declared 52 # question (across all modes — dynamic + static + select). 53 solves_count: int = 0 54 # Times this challenge has been instantiated, ever — one per 55 # archived InstanceRecord (every terminal path writes one). A 56 # "how much has this been run" signal for the challenge card; 57 # platform-wide, not scoped to the viewing team or competition. 58 launch_count: int = 0 59 tags: list[str] = Field(default_factory=list) 60 # Every question declared on this challenge, in author-declared 61 # order. Single-question challenges have a single entry with 62 # id ``"flag"``. ``QuestionPublicInfo`` exposes prompt/mode/choices 63 # but never the answer. 64 questions: list[QuestionPublicInfo] = Field(default_factory=list) 65 # Per-question solve counts (how many teams captured each 66 # question). Keyed by question id. Useful for the challenge 67 # detail page's progress breakdown. Counts span every mode; 68 # the multi_select grader records one solve per fully-correct 69 # submission, not per chosen choice. 70 flag_solves: dict[str, int] = Field(default_factory=dict) 71 # Files shipped under the challenge's ``attachments/`` directory, 72 # downloadable via ``GET /challenges/{id}/attachments/{name}``. 73 # Empty for pure-network challenges that ship nothing. 74 attachments: list[AttachmentInfo] = Field(default_factory=list) 75 # True for pure question-answer challenges (``category: misc``, 76 # no ``docker-compose.yml`` on disk, every declared question 77 # carries a static ``answer:``). The frontend keys off this flag 78 # to switch the competition surface to a quiz UI and skip the 79 # launch-confirm flow. Source of truth lives in 80 # :attr:`ctfy.core.challenge.ChallengeSpec.is_qa_only`, ultimately 81 # derived from :attr:`BenchmarkContext.is_qa_only`. 82 is_qa_only: 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.
285class ChallengePullKickoffNodeResult(CtfyModel): 286 """One node's response to a pull kickoff (single or all).""" 287 288 node_id: str 289 ok: bool 290 status: str = "" 291 queued: list[str] = Field(default_factory=list) 292 skipped_pulled: list[str] = Field(default_factory=list) 293 skipped_in_progress: list[str] = Field(default_factory=list) 294 error: str = ""
One node's response to a pull kickoff (single or all).
297class ChallengePullKickoffResponse(CtfyModel): 298 """Admin ``POST /admin/challenges/{id}/pull`` and ``…/pull-all`` response.""" 299 300 challenge_id: str = "" # empty for pull-all 301 nodes: list[ChallengePullKickoffNodeResult] = Field(default_factory=list)
Admin POST /admin/challenges/{id}/pull and …/pull-all response.
246class ChallengePullNodeState(CtfyModel): 247 """One worker node's view of a single challenge's pull state. 248 249 ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` / 250 ``failed``. ``node_error`` is non-empty only when the platform could 251 not reach the node at all — ``status`` is then forced to ``unpulled`` 252 for the aggregate. 253 """ 254 255 node_id: str 256 status: str = "unpulled" 257 pulled_at: float = 0.0 258 error: str = "" 259 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.
279class ChallengePullStateResponse(CtfyModel): 280 """Admin ``GET /admin/challenges/pull-state`` response.""" 281 282 rows: list[ChallengePullStateRow] = Field(default_factory=list)
Admin GET /admin/challenges/pull-state response.
262class ChallengePullStateRow(CtfyModel): 263 """Per-challenge pull state aggregated across every online node. 264 265 ``aggregated`` rolls up ``nodes`` worst-case, mirroring the build 266 row: 267 268 * any ``pulling`` → ``pulling`` (yellow) 269 * any ``failed`` → ``failed`` (red) 270 * any ``unpulled`` → ``unpulled`` (grey) 271 * else → ``pulled`` (green) 272 """ 273 274 challenge_id: str 275 aggregated: str = "unpulled" 276 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)
137class ChallengeRescanNodeResult(CtfyModel): 138 """Outcome of fanning the rescan out to one worker node. 139 140 ``ok`` is False for an offline node (skipped, never contacted) or a 141 node that errored; ``total`` is the node's post-rescan challenge 142 count when reached, else ``None``. One unreachable node never fails 143 the whole operation — the platform rescan still stands. 144 """ 145 146 node_id: str 147 ok: bool 148 total: int | None = None 149 error: str | None = None
152class ChallengeRescanResult(CtfyModel): 153 """Admin ``POST /admin/challenges/rescan`` response. 154 155 ``total`` / ``added`` / ``removed`` describe the platform's own 156 catalog after re-scanning ``challenges_dir``; ``nodes`` carries the 157 per-worker fan-out outcome so a newly added challenge is confirmed 158 launchable, not just listable. 159 """ 160 161 total: int 162 added: list[str] = Field(default_factory=list) 163 removed: list[str] = Field(default_factory=list) 164 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.
325class ChallengeSolveAttempt(CtfyModel): 326 """One archived instance for a challenge — used to visualise per-team 327 multi-solve attempts on the challenge detail page.""" 328 329 instance_id: str 330 team_id: str = "" 331 team_name: str = "" 332 display_name: str = "" 333 challenge_id: str = "" 334 started_at: float = 0.0 335 stopped_at: float = 0.0 336 duration_s: float = 0.0 337 attempts: int = 0 338 solved: bool = False 339 solved_flags: list[str] = Field(default_factory=list) 340 stop_reason: str = ""
One archived instance for a challenge — used to visualise per-team multi-solve attempts on the challenge detail page.
357class ChallengeSolveAttemptsResponse(CtfyModel): 358 challenge_id: str 359 attempts: list[ChallengeSolveAttempt] = Field(default_factory=list) 360 per_team: list[ChallengeTeamSolveSummary] = Field(default_factory=list) 361 # Total archived instances (incl. unsolved) — lets the UI show 362 # "showing N solved of M total" when the response is filtered. 363 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.
311class ChallengeStats(CtfyModel): 312 challenge_id: str 313 name: str = "" 314 category: ChallengeCategory | None = None 315 difficulty: str = "" 316 # Teams that captured every declared flag on this challenge. 317 solves_count: int = 0 318 total_attempts: int = 0 319 success_rate: float = 0.0 320 # Per-flag breakdown. Single-flag challenges have a single entry with 321 # ``flag_id == "flag"`` and ``solves_count == solves_count`` above. 322 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.
343class ChallengeTeamSolveSummary(CtfyModel): 344 """Per-team aggregate for the challenge detail solve-attempts panel.""" 345 346 team_id: str 347 team_name: str = "" 348 display_name: str = "" 349 solve_count: int = 0 350 attempt_count: int = 0 351 best_duration_s: float = 0.0 352 total_duration_s: float = 0.0 353 first_solved_at: float = 0.0 354 last_solved_at: float = 0.0
Per-team aggregate for the challenge detail solve-attempts panel.
133class ClusterInfo(CtfyModel): 134 """Returned by ``GET /cluster-info``. 135 136 Provides the bits the ``ctfy server invite`` CLI and admin UI wizard 137 need to build a runnable join command. The *registration token* is 138 minted separately via ``POST /nodes/invites`` — it isn't on this 139 payload because cluster info is queried speculatively by the UI on 140 every admin dashboard load, and we never want to hand an invite 141 token to a page that didn't explicitly ask for one. 142 """ 143 144 platform_url: str 145 node_image: str
Returned by GET /cluster-info.
Provides the bits the ctfy 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.
185class CompetitionAdminInfo(CtfyModel): 186 """One per-competition admin grant, for the super-admin management 187 card on the competition detail page.""" 188 189 user_id: str 190 display_name: str = "" 191 email: str = "" 192 granted_by: str = "" 193 granted_at: datetime | None = None
One per-competition admin grant, for the super-admin management card on the competition detail page.
573class CompetitionChallengeBreakdown(CtfyModel): 574 challenges: list[CompetitionChallengeRow] = Field(default_factory=list) 575 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.
551class CompetitionChallengeRow(CtfyModel): 552 """Per-challenge competition aggregate: solve count, attempt 553 success rate, and first blood. Reconstructed from the solve log 554 with the same window / team filter as the scoreboard so the 555 numbers always agree with the leaderboard.""" 556 557 challenge_id: str 558 name: str = "" 559 category: ChallengeCategory | None = None 560 difficulty: str = "" 561 # Teams that captured every declared flag (matches scoreboard's 562 # ``solved`` tally). 563 solves_count: int = 0 564 # In-window submissions for this challenge across registered teams. 565 attempts: int = 0 566 success_rate: float = 0.0 567 # First team to solve this challenge in-window. Empty when unsolved. 568 first_blood_team_id: str = "" 569 first_blood_team_name: str = "" 570 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.
74class CompetitionCreate(CtfyModel): 75 """Request body for ``POST /admin/competitions``. 76 77 Time fields accept ISO 8601 strings; empty means "no bound" (live 78 immediately / never expires). ``challenge_ids`` is validated against 79 the spec registry inside the route handler — unknown ids return 422. 80 """ 81 82 title: str = Field(min_length=1, max_length=200) 83 description: str = Field(default="", max_length=20000) 84 starts_at: datetime | None = None 85 ends_at: datetime | None = None 86 # No fixed length cap: the real bound is ``_check_challenge_ids`` in 87 # ``admin_competitions`` (every id must be a known spec, so a valid list 88 # can never exceed the catalog). A magic number here is arbitrary and a 89 # recurring foot-gun as the corpus grows — bucket comps already exceed 1000. 90 challenge_ids: list[str] = Field(default_factory=list) 91 # New competitions start hidden ("draft") so admins can prepare them 92 # privately and publish when ready. 93 status: Literal["draft", "published", "archived"] = "draft" 94 # Participation access (orthogonal to ``status``). ``public`` is open 95 # to everyone; ``private_listed`` / ``private_hidden`` require an admin 96 # invite to register (the latter also hides the comp from non-invited 97 # users). Defaults to ``public`` so ad-hoc API callers stay open. 98 access: Literal["public", "private_listed", "private_hidden"] = "public" 99 100 _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.
168class CompetitionDetail(CompetitionInfo): 169 """Full detail payload — includes resolved challenge summaries and 170 the caller's registration state when authenticated. 171 """ 172 173 challenges: list[ChallengeInfo] = Field(default_factory=list) 174 registered_at: datetime | None = None
Full detail payload — includes resolved challenge summaries and the caller's registration state when authenticated.
117class CompetitionInfo(CtfyModel): 118 """Summary row used in the list view. 119 120 Per-viewer fields (``is_registered`` / ``my_*``) are populated only 121 when the request carries an authenticated user; for anonymous 122 requests they fall back to sentinels (empty strings, ``-1``) so 123 the response shape stays stable. Front-end treats ``my_rank < 0`` 124 as "no value" rather than "rank zero". 125 """ 126 127 id: str 128 title: str 129 description: str 130 starts_at: datetime | None = None 131 ends_at: datetime | None = None 132 created_at: datetime | None = None 133 updated_at: datetime | None = None 134 created_by: str 135 created_by_name: str 136 challenge_ids: list[str] = Field(default_factory=list) 137 # Visibility gate. Only ``published`` rows reach non-admins; admins 138 # and per-competition admins also receive ``draft`` / ``archived``. 139 status: Literal["draft", "published", "archived"] = "published" 140 # Participation access. ``public`` is open; ``private_listed`` stays 141 # discoverable but invite-only to register; ``private_hidden`` is also 142 # hidden from non-invited users. Default ``public`` for backward 143 # compat (legacy rows / anonymous responses). 144 access: Literal["public", "private_listed", "private_hidden"] = "public" 145 # Server-derived projection. ``"upcoming"`` / ``"running"`` / ``"past"`` 146 # — saves the frontend from re-implementing the same string-compare 147 # the backend already does. 148 phase: Literal["upcoming", "running", "past"] = "running" 149 # Total registered teams. Surfaced on cards so users can gauge 150 # popularity at a glance. 151 registered_count: int = 0 152 # Viewer-scoped fields — see class docstring. 153 is_registered: bool = False 154 # Whether the viewer may register for this comp. Always ``True`` for 155 # ``public`` comps; for private comps it's ``True`` only when the 156 # viewer is invited or can administer it. Lets the frontend render a 157 # "private — invitation required" locked card without a probe POST. 158 # Defaults ``True`` so public / anonymous-on-public responses are open. 159 can_participate: bool = True 160 my_team_id: str = "" 161 my_team_name: str = "" 162 my_role: Literal["captain", "member", ""] = "" 163 my_rank: int = -1 164 my_score: int = -1 165 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".
204class CompetitionInviteInfo(CtfyModel): 205 """One participation-allowlist entry, for the admin "Invitations" 206 card on a private competition. The body for POST is the shared 207 ``GrantCompetitionAdminRequest`` (resolve by id or email).""" 208 209 user_id: str 210 display_name: str = "" 211 email: str = "" 212 invited_by: str = "" 213 invited_at: datetime | None = None
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).
25class CompetitionMembershipInfo(CtfyModel): 26 """One row on ``MeResponse.competition_teams``. 27 28 Lets the frontend hydrate a per-comp team picker / "My 29 competitions" hub from a single ``/me`` call without N+1 30 fetches. Captain status surfaces in the UI for buttons gated 31 on ``role == "captain"`` (mint invite / kick / rename). 32 33 ``competition_title`` / ``competition_phase`` are denormalized 34 from the competition the membership points at so the switcher 35 and the dashboard can label rows by the *competition* (the 36 thing the user is choosing between) rather than the team name — 37 every user is auto-joined to an identically named personal team 38 per comp, so team name alone makes the rows indistinguishable. 39 """ 40 41 competition_id: str 42 competition_title: str 43 competition_phase: Literal["upcoming", "running", "past"] 44 team_id: str 45 team_name: str 46 role: Literal["captain", "member"] 47 # Rail-enrichment fields (denormalized so the Slack-style rail can 48 # render instance count / rank / a time-progress ring from one /me 49 # call, no per-tile fetch fan-out). 50 # 51 # Live instances for THIS team in THIS comp. Mirrors 52 # InstanceCountContext semantics (all non-terminal InstanceState 53 # rows for the team — terminal ones already left the live store as 54 # InstanceRecord); 0 when none. 55 running_instances: int = 0 56 # Competition window, copied straight off the CompetitionState the 57 # /me loop already fetches. "" start = live immediately, "" end = 58 # never ends (same convention as CompetitionState / phase()). 59 starts_at: datetime | None = None 60 ends_at: datetime | None = None 61 # Caller's current rank in this comp. -1 sentinel = "not computed" 62 # (upcoming phase, or the per-request rank budget was exhausted) — 63 # matches the existing my_rank<0 frontend convention. 64 my_rank: int = -1 65 # Registered-team count, the denominator the UI shows as "#3 / 40". 66 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.
177class CompetitionRegistrationInfo(CtfyModel): 178 """One row in the admin "who's registered" table.""" 179 180 team_id: str 181 team_name: str = "" 182 registered_at: datetime | None = None
One row in the admin "who's registered" table.
585class CompetitionScoreDistribution(CtfyModel): 586 """Aggregate companion to the (now server-paginated) competition 587 scoreboard: the score-distribution histogram + the true team count. 588 589 The standings table pages server-side, so the histogram / "N teams" 590 figure can't be re-derived from one page in the browser — this 591 bins every registered team's ``flags_solved`` with the same 592 adaptive scheme the frontend used to do client-side. 593 """ 594 595 total_teams: int = 0 596 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.
542class CompetitionScoreHistory(CtfyModel): 543 """Top-N teams' score/rank progression, reconstructed from the 544 competition's solve log (no snapshot dependency — exact for past 545 and live competitions alike).""" 546 547 series: list[ScoreHistorySeries] = Field(default_factory=list) 548 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).
103class CompetitionUpdate(CtfyModel): 104 """PATCH body — every field optional, ``None`` means "leave alone".""" 105 106 title: str | None = Field(default=None, min_length=1, max_length=200) 107 description: str | None = Field(default=None, max_length=20000) 108 starts_at: datetime | None = None 109 ends_at: datetime | None = None 110 challenge_ids: list[str] | None = Field(default=None) # no cap — see CompetitionCreate 111 status: Literal["draft", "published", "archived"] | None = None 112 access: Literal["public", "private_listed", "private_hidden"] | None = None 113 114 _norm_times = field_validator("starts_at", "ends_at", mode="before")(_blank_to_none)
PATCH body — every field optional, None means "leave alone".
152class CreateFineGrainedTokenRequest(CtfyModel): 153 """Body for ``POST /auth/tokens`` (mints a fine-grained token). 154 155 Back-compat: when ``competition_access`` / ``permissions`` are 156 omitted the server mints the broad legacy profile (every 157 competition, full participate access) so existing 158 ``mint_token(label)`` callers — SDK, CLI, MCP — keep getting a 159 token equivalent to the old agent token. 160 """ 161 162 label: str = Field(default="", max_length=64) 163 # Lifetime in days. ``None`` uses the server default (30); ``0`` means 164 # "never expires". The server caps this at ~5 years to reject absurd 165 # values. 166 expires_in_days: int | None = Field(default=None, ge=0, le=365 * 5) 167 # ``None`` → legacy-broad default. Otherwise: none | all | selected. 168 competition_access: str | None = None 169 competition_ids: list[str] = Field(default_factory=list) 170 # Category → "none" | "read" | "write". Clamped server-side to each 171 # category's max level. ``None`` → legacy-broad default. 172 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.
175class CreateFineGrainedTokenResponse(TokenInfo): 176 """Includes the plaintext ``token`` — returned once, never persisted.""" 177 178 token: str
Includes the plaintext token — returned once, never persisted.
101class CreateInviteRequest(CtfyModel): 102 """Body for ``POST /api/v1/nodes/invites``. 103 104 ``ttl_seconds`` controls how long the minted registration token stays 105 valid — it cannot be renewed, only re-created. 106 """ 107 108 ttl_seconds: int = Field(default=3600, ge=60, le=7 * 24 * 3600)
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.
111class CreateInviteResponse(CtfyModel): 112 """Plaintext registration token is returned **exactly once** here. 113 114 Subsequent ``GET /nodes/invites`` lookups only expose metadata; the 115 plaintext is never persisted — only ``token_hash`` is stored. 116 """ 117 118 id: str 119 registration_token: str 120 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.
112class DeleteMeRequest(CtfyModel): 113 """Body for ``DELETE /me``. ``confirm_display_name`` must match the 114 caller's user display name exactly (case-sensitive) — the typo 115 gate that stops a fat-fingered click from cascading the account's 116 data away. (Falls back to email when display_name is empty.) 117 """ 118 119 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.)
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.
83class DeviceCodeResponse(CtfyModel): 84 """RFC 8628-style response to ``POST /auth/device/code`` — the CLI 85 shows ``user_code`` + ``verification_uri`` and polls with 86 ``device_code``.""" 87 88 device_code: str 89 user_code: str 90 verification_uri: str 91 verification_uri_complete: str 92 expires_in: int 93 interval: int
RFC 8628-style response to POST /auth/device/code — the CLI
shows user_code + verification_uri and polls with
device_code.
113class DeviceInfoResponse(CtfyModel): 114 """What the approval page shows about a pending device request.""" 115 116 status: str 117 requested_at: datetime | None = None 118 requester_ip: str = "" 119 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.
100class DeviceTokenResponse(CtfyModel): 101 """Poll result for ``POST /auth/device/token``. ``status`` is one of 102 ``pending`` / ``approved`` / ``denied`` / ``expired``; ``token`` is 103 set only when ``approved``.""" 104 105 status: str 106 token: str = ""
146class DifficultyStat(CtfyModel): 147 difficulty: str = "" # easy | medium | hard | ... 148 solved: int = 0 149 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.
95class ErrorResponse(CtfyModel): 96 """Unified shape for every HTTP 4xx/5xx body produced by platform routes. 97 98 ``detail`` mirrors the pre-existing field (so SDK / CLI / frontend code 99 that reads ``data.detail`` keeps working); ``code`` adds a stable 100 machine-readable identifier derived from the raising exception class, 101 and ``timestamp`` is server-side UTC in ISO 8601 — useful when 102 correlating client-side and server-side logs. 103 104 ``detail`` is a union because three sources feed the envelope: 105 * a plain string from legacy ``HTTPException`` / ``raise_error`` calls; 106 * a structured dict (``{"message": "...", "competition_id": ...}`` 107 or the legacy ``{"error": "..."}``) when the route wants to attach 108 machine-readable context the frontend can branch on; 109 * a list of FastAPI ``RequestValidationError`` issues, wrapped under 110 ``{"message", "issues"}`` by ``_handle_validation_error``. 111 """ 112 113 code: str 114 detail: str | dict[str, Any] | list[dict[str, Any]] 115 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.
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.
196class GrantCompetitionAdminRequest(CtfyModel): 197 """Body for ``PUT /admin/competitions/{id}/admins`` — resolve the 198 target user by id or (case-insensitive) email.""" 199 200 user_id: str = "" 201 email: str = ""
Body for PUT /admin/competitions/{id}/admins — resolve the
target user by id or (case-insensitive) email.
18class HealthResponse(CtfyModel): 19 status: str = "ok" 20 hostname: str = "" 21 running_instances: int = 0 22 capacity: int = 0 23 # Server's installed ``ctfy`` version. Lets the SDK/CLI flag a 24 # client/server skew off the probe they already make — no extra 25 # round trip. Defaults to "" so older servers (and the model's own 26 # default construction) stay valid. 27 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.
127class InboxAnnouncement(CtfyModel): 128 """Announcement projected onto the Inbox surface. 129 130 Carries the same fields as :class:`AnnouncementInfo` plus a 131 per-user ``is_read`` flag derived from the 132 ``announcement_reads`` table. The frontend renders unread rows 133 prominently and lets the user mark them read via 134 ``POST /me/announcements/{id}/read``. 135 """ 136 137 id: str 138 title: str 139 body: str 140 severity: AnnouncementSeverity 141 starts_at: datetime | None = None 142 ends_at: datetime | None = None 143 created_at: datetime | None = None 144 updated_at: datetime | None = None 145 created_by: str 146 created_by_name: str 147 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.
92class InboxCaptainRequest(CtfyModel): 93 """Pending join request against any team the calling user 94 captains. Frontend renders an Approve / Reject pair pointing at 95 ``/competitions/{competition_id}/invites/{invite_id}/approve|reject``. 96 """ 97 98 id: str 99 competition_id: str 100 competition_title: str 101 team_id: str 102 team_name: str 103 requester_user_id: str 104 requester_display_name: str 105 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.
108class InboxCompetitionInvite(CtfyModel): 109 """A standing invitation to participate in a PRIVATE competition the 110 calling user has been granted but not yet joined. 111 112 Unlike the team invites above this isn't accept/decline — the 113 allowlist row is a standing permission, so the frontend renders a 114 "Register" CTA linking to ``/competitions/{competition_id}/team``. 115 Drops off the inbox once the user registers (or the comp ends / is 116 flipped back to public). 117 """ 118 119 competition_id: str 120 competition_title: str 121 competition_phase: Literal["upcoming", "running", "past"] 122 invited_by_user_id: str = "" 123 invited_by_display_name: str = "" 124 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).
59class InboxIncomingInvite(CtfyModel): 60 """Pending direct invite where the calling user is the named target. 61 62 The frontend renders an Accept / Decline pair pointing at 63 ``/competitions/{competition_id}/invites/{invite_id}/accept|decline``. 64 """ 65 66 id: str 67 competition_id: str 68 competition_title: str 69 team_id: str 70 team_name: str 71 captain_user_id: str 72 captain_display_name: str 73 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.
76class InboxOutgoingRequest(CtfyModel): 77 """Pending join request the calling user opened — surfaces so the 78 requester can see "did the captain see this yet?" without 79 refreshing the team page. 80 """ 81 82 id: str 83 competition_id: str 84 competition_title: str 85 team_id: str 86 team_name: str 87 captain_user_id: str 88 captain_display_name: str 89 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.
150class InboxResponse(CtfyModel): 151 """``GET /me/inbox`` payload — pending invites/requests + 152 actionable announcements grouped by the action the calling 153 user can take. 154 """ 155 156 incoming_invites: list[InboxIncomingInvite] = Field(default_factory=list) 157 outgoing_requests: list[InboxOutgoingRequest] = Field(default_factory=list) 158 captain_requests: list[InboxCaptainRequest] = Field(default_factory=list) 159 # Standing participation invites to private competitions the user 160 # hasn't joined yet (rendered with a "Register" CTA, not accept/decline). 161 competition_invites: list[InboxCompetitionInvite] = Field(default_factory=list) 162 # Site-wide announcements relevant right now (currently live + a 163 # short tail of recently-expired) with the user's read state. 164 announcements: list[InboxAnnouncement] = Field(default_factory=list) 165 # Convenience count so the sidebar can render an unread badge 166 # without re-iterating ``announcements``. 167 unread_announcement_count: int = 0
GET /me/inbox payload — pending invites/requests +
actionable announcements grouped by the action the calling
user can take.
61class InstanceInfo(CtfyModel): 62 id: str 63 challenge_id: str = "" 64 team_id: str = "" 65 # Competition the instance is scoped to. Stamped from the resolved 66 # per-comp team at start time; empty for legacy rows or unscoped 67 # admin instances. Frontend keys per-comp instance pages off this 68 # field so a user registered for two comps doesn't see comp A's 69 # instances in comp B's tab. 70 competition_id: str = "" 71 # Worker node currently hosting this instance. Empty only for 72 # records created before nodes were tracked (legacy). 73 node_id: str = "" 74 name: str = "" 75 category: ChallengeCategory | None = None 76 difficulty: str = "" 77 status: str = InstanceStatus.STARTING 78 started_at: float = 0.0 79 ttl: int = 0 80 expires_at: float = 0.0 81 services: list[ServiceEndpoint] = Field(default_factory=list) 82 description: str = "" 83 # Per-instance question view: same shape as ``ChallengeInfo.questions`` 84 # but additionally carries the agent-visible state. ``unlocked`` is 85 # ``False`` when the question's ``requires:`` predecessors haven't 86 # all been answered correctly yet; the UI hides the prompt for locked 87 # questions to avoid leaking route hints. ``answered_correctly`` is 88 # ``True`` once this team has captured this question on this instance. 89 questions: list[InstanceQuestionInfo] = Field(default_factory=list) 90 # Mirrored from ``ChallengeInfo.attachments`` so players who jump 91 # straight to the instance page (e.g. via a launch link) see the 92 # download list without re-fetching the catalog row. 93 attachments: list[AttachmentInfo] = Field(default_factory=list) 94 # How the platform exposes this instance to the player. Always 95 # surfaced (defaults to ``"simple"``) so the agent can branch on 96 # `if info.network_topology == "engagement": ...` without dealing 97 # with a missing field. 98 network_topology: Literal["simple", "engagement"] = "simple" 99 # Set only when ``network_topology == "engagement"``. Carries the 100 # tunnel endpoint host:port + the URL the client downloads the 101 # `.ovpn` body from. ``None`` in simple mode. 102 vpn_endpoint: VpnEndpoint | None = None 103 # Landing-page hints declared in the challenge's ``metadata.yaml`` 104 # (``entry_urls:`` field, validated by META013). Surfaced verbatim 105 # to the frontend so the VPN / services panel can render a 106 # "start here" list without the player having to read the 107 # description for the canonical first URL. 108 entry_urls: list[str] = 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.
27class InstanceQuestionInfo(CtfyModel): 28 """One question, projected for a specific running instance. 29 30 Mirrors :class:`QuestionPublicInfo` plus per-instance state. The 31 ``prompt`` is replaced with an empty string when ``unlocked`` is 32 ``False`` so dependent questions never leak their route hints 33 before the predecessor is solved. 34 """ 35 36 id: str 37 prompt: str 38 mode: str | None = None 39 choices: list[str] | None = None 40 requires: list[str] = Field(default_factory=list) 41 # ``True`` once every id in ``requires`` has been captured by the 42 # calling team on this instance. ``False`` keeps the prompt empty 43 # to avoid leaking the question text early. 44 unlocked: bool = True 45 # ``True`` once the calling team has submitted a correct answer 46 # for this question against this instance. Drives the UI checkmark. 47 answered_correctly: bool = False 48 # Wrong-attempt budget remaining for the calling team on this 49 # specific question. ``None`` when the question's mode is uncapped 50 # (dynamic free-form, or any mode the operator opted out of via 51 # ``question_attempt_caps``). The UI renders an ``X/N attempts`` 52 # badge from this on first page load and decrements it locally on 53 # each wrong submit (the submission response also carries it). 54 attempts_remaining: int | None = None 55 # The cap that ``attempts_remaining`` is being measured against — 56 # echoed so the UI can render ``X/N`` without recomputing N from 57 # the question's mode + current platform settings. 58 attempts_cap: int | None = None
One question, projected for a specific running instance.
Mirrors QuestionPublicInfo plus per-instance state. The
prompt is replaced with an empty string when unlocked is
False so dependent questions never leak their route hints
before the predecessor is solved.
153class InstanceRecordArtifacts(CtfyModel): 154 """Per-artifact presence flags + counts for an archived instance. 155 156 Returned alongside the full :class:`InstanceRecord` from 157 ``GET /admin/instance-records/{id}`` so the admin UI can show 158 ``Manifest / Events / Submissions / Container log`` tabs at a glance 159 without a round-trip per tab. 160 """ 161 162 has_manifest: bool = False 163 has_container_log: bool = False 164 events_count: int = 0 165 submissions_count: int = 0 166 traffic_count: int = 0 167 # Bytes on disk for the archived ``traffic.pcap``. 0 when no capture 168 # was persisted (sidecar disabled, archive disabled, or fetch 169 # failed). The admin UI uses this both to gate the download button 170 # and to render an "X MB" hint next to it. 171 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.
174class InstanceRecordDetail(CtfyModel): 175 record: InstanceRecordInfoDetail 176 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.
111class InstanceRecordInfo(CtfyModel): 112 """Archived-instance summary for the admin history list. 113 114 Mirrors :class:`ctfy.core.state.models.InstanceRecord` minus the heavy 115 fields (``spec``, ``surface``). Used by ``GET /admin/instance-records``. 116 """ 117 118 id: str 119 team_id: str = "" 120 challenge_id: str = "" 121 node_id: str = "" 122 name: str = "" 123 category: ChallengeCategory | None = None 124 difficulty: str = "" 125 status: str = "" 126 stop_reason: str = "" 127 error: str = "" 128 # Full node diagnostic (e.g. ``docker compose up`` stderr tail). 129 # Admin-only: this model backs ``/admin/instance-records*`` only; 130 # the player-facing ``InstanceInfo`` / ``InstanceStatusResponse`` 131 # deliberately omit it. 132 error_detail: str = "" 133 started_at: float = 0.0 134 stopped_at: float = 0.0 135 duration_s: float = 0.0 136 ttl: int = 0 137 solved: bool = False 138 attempts: int = 0 139 # Total HTTP requests captured by the mitmproxy sidecar during the 140 # instance's lifetime. 0 when the archive sink was disabled or no 141 # traffic was captured. 142 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.
145class InstanceRecordInfoDetail(InstanceRecordInfo): 146 """Full record — includes spec + surface, returned by detail endpoint.""" 147 148 spec: dict[str, Any] = Field(default_factory=dict) 149 surface: AttackSurface | None = None 150 artifact_dir: str = ""
Full record — includes spec + surface, returned by detail endpoint.
179class InstanceStatusResponse(CtfyModel): 180 id: str 181 status: str = InstanceStatus.STARTING 182 attack_surface: AttackSurface | None = None 183 error: str = "" 184 # CA volume for the per-instance mitmproxy. Operators trust this CA 185 # if they want to MITM HTTPS; HTTP traffic is captured transparently 186 # without it. 187 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.
198class LinkStartResponse(CtfyModel): 199 """Returned by ``POST /auth/identities/link/{provider}``. 200 201 The caller is already authenticated via XHR, so we don't 302 them; 202 instead we hand back the authorize URL for the frontend to navigate 203 the browser to. 204 """ 205 206 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.
34class LinkedIdentity(CtfyModel): 35 id: str 36 # "github" | "google" (OAuth) or "password" for a local credential. 37 provider: str 38 provider_email: str = "" 39 provider_display_name: str = "" 40 # Stable third-party account id (GitHub numeric id, Google `sub`); 41 # empty for the local password credential. 42 provider_user_id: str = "" 43 # GitHub `@handle`; empty for Google and password. 44 provider_login: str = "" 45 avatar_url: str = "" 46 created_at: datetime | None = None 47 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.
67class LoginRequest(CtfyModel): 68 email: EmailStr 69 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)
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.
51class MetaChallenges(CtfyModel): 52 """Source-control identity of the challenges repository, plus the 53 public count of available challenges. 54 55 ``commit_sha`` is None when the challenges directory isn't a git 56 working tree (e.g. ``CTFY_CHALLENGES_REPO=""`` or a bare checkout). 57 ``total`` is visible to everyone — the list itself is browsable. 58 """ 59 60 commit_sha: str | None = None 61 commit_url: str | None = None 62 repo_url: str = "" 63 total: 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.
37class MetaPlatform(CtfyModel): 38 """Source-control identity of the running ctfy build. 39 40 ``commit_sha`` is None when the build couldn't determine its own 41 revision (no ``CTFY_GIT_COMMIT``, no usable git binary). 42 ``commit_url`` is built from ``repo_url`` + sha so the frontend 43 doesn't have to know the URL convention. 44 """ 45 46 commit_sha: str | None = None 47 commit_url: str | None = None 48 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.
66class MetaResponse(CtfyModel): 67 version: str = "" 68 platform: MetaPlatform = Field(default_factory=MetaPlatform) 69 challenges: MetaChallenges = Field(default_factory=MetaChallenges) 70 started_at_ts: float = 0.0 71 server_time_ts: float = 0.0 72 # Cluster-wide counters — admin-only. ``None`` for non-admin 73 # callers so the frontend can distinguish "not authorised" from 74 # "happens to be zero" and hide the segment entirely instead of 75 # rendering a misleading "0 nodes". 76 teams_total: int | None = None 77 users_total: int | None = None 78 nodes_total: int | None = None 79 nodes_healthy: int | None = None 80 running_instances: int | None = None 81 solves_total: int | None = None 82 # Public surface bit for the super-admin docker-exec feature. The 83 # frontend uses this to decide whether to render the Shell entry 84 # in the admin instance list — false means the routes aren't 85 # mounted, so any UI link would 404. Never carries the 86 # token/recording details — those stay server-side. 87 admin_shell_enabled: 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.
474class MilestoneProgress(CtfyModel): 475 """Per-challenge milestone progress for the calling user. 476 477 Returned by ``GET /me/milestone-progress``. One row per challenge 478 with at least one captured question, regardless of whether the 479 challenge is fully solved. The Challenges list page renders a 480 progress bar from ``len(solved_question_ids) / total_questions`` 481 so a player can see "2/5 milestones" instead of an all-or-nothing 482 solved badge. 483 484 Like ``MySolveSummary`` this is user-scoped: ``solved_question_ids`` 485 aggregates every question id the user captured for the challenge 486 across every team they have ever been on. The optional 487 ``competition_id`` query param narrows the aggregation to solves 488 stamped against the user's team in that comp. 489 """ 490 491 challenge_id: str 492 solved_question_ids: list[str] = Field(default_factory=list) 493 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.
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.
458class MySolveSummary(CtfyModel): 459 """Per-challenge solve summary for the calling team. 460 461 Returned by ``GET /me/solves``. One row per challenge with at least 462 one captured flag. ``best_rank`` is the team's best (smallest) rank 463 across the flags they captured for the challenge — drives the 464 1血/2血/3血 badge on the challenge cards. Multi-flag challenges may 465 have different ranks per flag; reporting the best one gives players 466 credit for whichever piece they nailed first. 467 """ 468 469 challenge_id: str 470 best_rank: int 471 solved_at: datetime | None = None
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.
61class NodeHeartbeat(CtfyModel): 62 """Body for ``POST /nodes/heartbeat``. 63 64 Node reports its live counts + resource utilisation on every beat. 65 All metrics are 0–100; 0 is a safe default for the first beat where 66 psutil hasn't had a prior sample to diff against. 67 """ 68 69 id: str 70 running: int = 0 71 capacity: int = 50 72 cpu_percent: float = 0.0 73 memory_percent: float = 0.0 74 disk_percent: float = 0.0 75 # Unix seconds when the node sampled the metrics. The server stores 76 # ``received_at - sampled_at`` as one-way latency on the resulting 77 # health sample. Default 0 means "not measured" — older node builds 78 # that haven't been upgraded keep working. 79 sampled_at: float = 0.0
Body for POST /nodes/heartbeat.
Node reports its live counts + resource utilisation on every beat. All metrics 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
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
123class NodeInviteInfo(CtfyModel): 124 """Admin-facing summary (``GET /nodes/invites``). No token fields.""" 125 126 id: str 127 created_at: datetime | None = None 128 expires_at: datetime | None = None 129 status: str # "active" | "consumed" | "expired" 130 consumed_by_node_id: str = ""
Admin-facing summary (GET /nodes/invites). No token fields.
49class NodePatch(CtfyModel): 50 """Body for ``PATCH /admin/nodes/{node_id}``. 51 52 Fields are optional — absent fields keep their current value. 53 display_name is the common case; labels allow adding/replacing the 54 full dict (partial label edits need a second round-trip). 55 """ 56 57 display_name: str | None = None 58 labels: dict[str, 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).
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.
82class NodeRegisterResponse(NodeInfo): 83 """Registration response carries the node's bearer token (plaintext). 84 85 Returned exactly once, at registration. The same token is used in 86 both directions: 87 88 * **node→platform** (heartbeat, deregister) — node presents it as 89 the Bearer; platform looks the row up by comparing plaintexts. 90 * **platform→node** (start/stop/status) — platform presents the 91 same plaintext on every call; node verifies against its in-memory 92 copy. 93 94 Re-registration rotates the token. The node never persists it on 95 disk; "restart = re-register = fresh credential". 96 """ 97 98 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".
17class OAuthProviderInfo(CtfyModel): 18 name: str # "github" | "google" 19 enabled: bool 20 authorize_path: str # relative, e.g. "/api/v1/auth/login/github"
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.
23class PasswordAuthInfo(CtfyModel): 24 """Gating flag for the email+password sign-in form on the login page.""" 25 26 enabled: bool = False
Gating flag for the email+password sign-in form on the login page.
306class PlatformSettingInfo(CtfyModel): 307 """One row from ``GET /admin/platform-settings``. 308 309 ``value`` is the live resolved value (DB > env > default); the 310 other two fields show what env and the built-in default would 311 resolve to in isolation, so the UI can render a tooltip 312 explaining what a ``DELETE`` would land on. ``source`` is the 313 tier the current value came from. 314 """ 315 316 key: str = "" 317 type: str = "" 318 description: str = "" 319 value: Any = None 320 source: str = "" # "db" | "env" | "default" 321 env_value: Any = None 322 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.
325class PlatformSettingPatch(CtfyModel): 326 """Body of ``PATCH /admin/platform-settings/{key}``. 327 328 ``value`` is JSON-typed; the resolver's per-key validator decides 329 whether it's accepted. 330 """ 331 332 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.
96class ProfilePatchRequest(CtfyModel): 97 """Body for ``PATCH /me/profile``. Every field is optional so the 98 UI can send only the diff; the route only writes the fields that 99 were present in the JSON. ``None`` means "clear this field".""" 100 101 # ``Field(default=...)`` with a non-None sentinel is awkward in 102 # pydantic; we instead declare each field as Optional + default-None 103 # and rely on the request's raw dict (via ``model_fields_set``) to 104 # know which keys were actually present. 105 bio: str | None = Field(default=None, max_length=280) 106 country: str | None = None 107 website_url: str | None = Field(default=None, max_length=200) 108 timezone: str | None = Field(default=None, max_length=80) 109 social_links: dict[str, str] | None = None
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".
159class ProfileStats(CtfyModel): 160 """Aggregate per-team analytics surfaced on the public profile. 161 162 Every section is independently visibility-gated; non-owner viewers 163 see ``[]`` for a section the team has marked private. Owner and 164 admin always see the full payload.""" 165 166 calendar: list[CalendarBucket] = Field(default_factory=list) 167 by_tag: list[TagStat] = Field(default_factory=list) 168 by_difficulty: list[DifficultyStat] = Field(default_factory=list) 169 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.
29class ProvidersResponse(CtfyModel): 30 providers: list[OAuthProviderInfo] = Field(default_factory=list) 31 password_auth: PasswordAuthInfo = Field(default_factory=PasswordAuthInfo)
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.
432class QaChallengeView(CtfyModel): 433 """One QA challenge as seen by a team on the quiz surface. 434 435 Carries the full question / choices / description data alongside 436 per-team solve and attempt state, so the frontend can render the 437 quiz list with progress badges in a single round-trip. 438 """ 439 440 id: str 441 name: str 442 # Provenance bucket prefix — ``SECQA`` / ``MMLU-CS`` / ``CTI-MCQ`` 443 # / etc — derived from the challenge id (everything before the 444 # trailing numeric suffix). Lets the frontend group / filter 445 # without splitting the id again client-side. 446 bucket: str 447 description: str 448 difficulty: str = "" 449 tags: list[str] = Field(default_factory=list) 450 questions: list[QuestionPublicInfo] = Field(default_factory=list) 451 # Question ids the calling team has already captured. 452 solved_question_ids: list[str] = Field(default_factory=list) 453 # Question ids the team has submitted at least one wrong answer 454 # against but never solved. Drives the "Try again" amber badge. 455 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.
415class QaSubmissionCreate(CtfyModel): 416 """Request body for ``POST /qa/submissions`` — the instance-free 417 submission path for pure question-answer challenges. 418 419 No ``instance_id``: QA challenges have no Docker infra to launch, 420 so there's no per-team instance row to read context off. The 421 caller passes ``challenge_id`` + ``competition_id`` explicitly, 422 and the server resolves the submitter's team for the named 423 competition via the standard auth helper. 424 """ 425 426 challenge_id: str 427 competition_id: str 428 question_id: str = "answer" 429 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.
369class QuestionAttemptResetInfo(CtfyModel): 370 """Response from the admin reset endpoint. Echoes the new baseline 371 row so the UI can update its local state without re-fetching the 372 instance. 373 """ 374 375 team_id: str 376 challenge_id: str 377 question_id: str 378 reset_at: datetime 379 reset_by_user_id: str 380 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.
358class QuestionAttemptResetRequest(CtfyModel): 359 """Request body for ``POST /admin/teams/.../reset-attempts``. 360 361 ``reason`` is free-form audit metadata recorded onto the activity 362 log row; capped at 500 chars so it round-trips through the SSE 363 payload without bloating the wire frame. 364 """ 365 366 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.
19class QuestionPublicInfo(CtfyModel): 20 """One question, as exposed to agents and the web UI. 21 22 Carries the prompt + mode + ``choices:`` enum (when present) so the 23 UI can render a radio/checkbox/free-text input without an extra 24 round-trip to fetch the spec. ``answer`` is NEVER projected — the 25 groundtruth lives on the platform either as metadata.yaml or as 26 a per-instance mint. 27 """ 28 29 id: str 30 prompt: str 31 # Grading mode: ``dynamic`` | ``static`` | ``single_select`` | 32 # ``multi_select``. ``None`` only on malformed metadata (which the 33 # audit catches before deploy). 34 mode: str | None = None 35 # Closed-list options for ``single_select`` / ``multi_select``; 36 # ``None`` for free-form modes. 37 choices: list[str] | None = None 38 # Question ids that must be answered correctly first. The 39 # platform gates visibility / submission acceptance on this list 40 # (a question with non-empty ``requires:`` is hidden until every 41 # listed predecessor is solved). 42 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.
55class RegisterRequest(CtfyModel): 56 email: EmailStr 57 # Length minimum mirrors ``MIN_PASSWORD_LENGTH`` in 58 # ``ctfy.server.password``. ``validate_password_strength`` runs the 59 # full check (length + common-password reject) inside the route 60 # handler so the rule lives in one place; this floor is a cheap 61 # client-side hint that catches obviously short input before it 62 # reaches argon2. 63 password: str = Field(min_length=12, max_length=256) 64 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.
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.
447class ScheduledJobInfo(CtfyModel): 448 """One recurring job on ``GET /admin/scheduled-jobs``: a projection of 449 ``ScheduledJobState`` plus the code-side ``default_interval_s`` so the 450 UI can show "(default: 30s)" next to a tuned value.""" 451 452 name: str = "" 453 enabled: bool = True 454 interval_s: int = 0 455 default_interval_s: int = 0 456 last_run_at_ts: float = 0.0 457 last_status: str = "" 458 last_summary: str = "" 459 last_error: str = "" 460 last_duration_s: float = 0.0 461 run_count: int = 0 462 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.
465class ScheduledJobPatch(CtfyModel): 466 """Body of ``PATCH /admin/scheduled-jobs/{name}``. Either field may be 467 omitted; ``interval_s`` is clamped to ``[5, 86400]`` by the route.""" 468 469 enabled: bool | None = None 470 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.
181class ScopeCatalogEntry(CtfyModel): 182 """One assignable permission category, for the token-creation UI.""" 183 184 key: str 185 label: str 186 description: str 187 competition_scoped: bool 188 max_level: str # highest grantable level: "read" | "write"
One assignable permission category, for the token-creation UI.
578class ScoreBucket(CtfyModel): 579 """One bucket of the scoreboard score-distribution histogram.""" 580 581 label: str 582 count: int = 0
One bucket of the scoreboard score-distribution histogram.
524class ScoreHistoryPoint(CtfyModel): 525 """One sample on a team's score/rank-over-time curve. 526 527 Both metrics travel together so the frontend can toggle 528 Score ⇄ Rank without a refetch. 529 """ 530 531 ts: float = 0.0 # Unix seconds (solve time of the driving event) 532 score: int = 0 # Cumulative flags captured up to ``ts`` 533 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.
536class ScoreHistorySeries(CtfyModel): 537 team_id: str 538 team_name: str 539 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.
501class ScoreboardEntry(CtfyModel): 502 rank: int = 0 503 team_id: str 504 team_name: str 505 # Challenges where the team captured every declared flag. Preserved 506 # for parity with the legacy single-flag scoreboard. 507 solved: int = 0 508 # Total individual flags captured across all challenges. Primary 509 # scoreboard metric now that multi-flag challenges exist — rewards 510 # partial progress (e.g. foothold without root). 511 flags_solved: int = 0 512 attempts: int = 0 513 last_solve_at: datetime | None = None 514 # Most recent activity of any kind. Distinct from ``last_solve_at`` — 515 # a team may be actively attempting without a solve, and the UI 516 # wants to show that they're not idle. 517 last_active_at: datetime | None = None 518 # OAuth provider avatar URL for the team's earliest identity that 519 # supplies one. Empty for password-only teams; the frontend falls 520 # back to a generated initials avatar in that case. 521 avatar_url: 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.
122class SetPasswordRequest(CtfyModel): 123 # Required iff the caller already has a password identity — absent on 124 # the first-time "attach password to an OAuth account" flow. 125 current_password: str = Field(default="", max_length=256) 126 # See RegisterRequest.password for the length-minimum rationale. 127 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.
340class ShellRecordingInfo(CtfyModel): 341 """One row from ``GET /admin/shell-recordings``: a per-session 342 asciinema v2 cast file the platform wrote when a super-admin 343 opened a docker-exec shell. The frontend renders a playable 344 inline player; the raw bytes are pulled via the per-session 345 download route (also super-admin gated, also audited).""" 346 347 session_id: str = "" 348 path: str = "" 349 size_bytes: int = 0 350 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).
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.
195class StartRequest(CtfyModel): 196 challenge_id: str 197 proxy_output_dir: str | None = None 198 # Per-instance TTL override in seconds. ``None`` (the default) 199 # defers to the platform's ``default_instance_ttl_s`` setting — 200 # admin-tunable, currently 24h. Explicit values are clamped to 201 # ``max_instance_ttl_s`` so a misbehaving SDK / agent can't pin 202 # a node by requesting an arbitrarily large TTL. 203 ttl: int | None = None 204 # Which competition this instance is for. The platform looks up 205 # the caller's per-comp team via ``(user_id, competition_id)`` and 206 # stamps the instance with that team. Optional only when the 207 # caller is on exactly one team — multi-comp users always pass 208 # it explicitly. 209 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.
371class SubmissionCreate(CtfyModel): 372 # ``instance_id`` is the canonical handle since 0.2: the server 373 # reads challenge_id and competition_id off the instance row, so 374 # callers don't need to repeat them and N instances per 375 # (team, challenge) are unambiguous on submit. 376 instance_id: str 377 # Which question on the challenge the agent is answering. Defaults 378 # to ``"flag"`` so single-question challenges stay one-liner submits; 379 # multi-question challenges (see ``ChallengeInfo.questions``) must 380 # specify the id explicitly. 381 question_id: str = "flag" 382 # The agent's submission. String for ``dynamic`` / ``static`` / 383 # ``single_select`` questions; list of strings for ``multi_select`` 384 # (e.g. ``["idor", "ssrf"]``). The server picks the grader by 385 # consulting ``spec.questions[i].mode`` on the live instance. 386 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.
389class SubmissionResponse(CtfyModel): 390 id: str 391 correct: bool 392 solve_time_s: float = 0 # seconds from instance start to correct submission 393 # 1 = first blood, 2 = second, 3 = third, 4+ = regular solve. 394 # 0 when incorrect or when the team already had a prior correct solve. 395 # Ranks within the specific question — independent of the 396 # full-challenge solve rank. 397 solve_rank: int = 0 398 # The question id this submission targeted. Echoed back to the 399 # caller so simple submission UIs don't have to thread the id 400 # separately. Equal to the request's ``question_id``. 401 question_id: str | None = None 402 # True iff this team has now captured every question declared on 403 # the challenge. Single-question challenges: always equals ``correct``. 404 challenge_fully_solved: bool = False 405 # Remaining wrong-attempt budget for the question this submission 406 # targeted, AFTER this attempt is counted (the cap is on wrongs; 407 # correct submissions don't consume budget). ``None`` when the 408 # question's mode is uncapped (dynamic free-form, or any mode the 409 # operator opted out of via ``question_attempt_caps``). The UI 410 # uses this to decrement its remaining-attempts badge without 411 # re-fetching the instance — see ``ctfy.server.submission_policy``. 412 attempts_remaining: int | 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.
133class TagStat(CtfyModel): 134 """One axis on the per-tag strength radar. 135 136 ``solved`` is the team's solve count for challenges carrying this 137 tag; ``total`` is the platform-wide challenge count for the tag, 138 so the frontend can render "team / total" or normalize to a 0–1 139 ratio for the radar polygon.""" 140 141 tag: str = "" 142 solved: int = 0 143 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.
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.
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 = ""
170class TeamInviteInfo(CtfyModel): 171 """Public projection of a :class:`TeamInviteState`. 172 173 Surfaces on captain CRUD. ``active`` is server-derived from the 174 expires/use/revoked triple. ``kind`` discriminates code-style 175 invites from directed invites/requests; ``target_user_id`` is 176 the recipient for ``kind="direct"`` (set at mint time) and the 177 requester for ``kind="join_request"`` (set when a member opens 178 a request). 179 """ 180 181 id: str 182 code: str 183 team_id: str 184 created_by_user_id: str 185 created_at: datetime | None = None 186 expires_at: datetime | None = None 187 max_uses: int 188 use_count: int 189 revoked_at: datetime | None = None 190 active: bool = True 191 competition_id: str = "" 192 kind: Literal["code", "direct", "join_request"] = "code" 193 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).
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.
130class TokenInfo(CtfyModel): 131 id: str 132 kind: str # "user" | "fine_grained" (legacy dumps may still say "agent") 133 label: str = "" 134 created_at: datetime | None = None 135 last_used_at: datetime | None = None 136 # ISO-8601 UTC timestamp; empty means the token never expires. 137 expires_at: datetime | None = None 138 # Client metadata captured when the session was minted. Only populated 139 # for user-kind tokens (browser sessions). 140 ip_address: str = "" 141 user_agent: str = "" 142 # True for the session that made this request — lets the UI show "this 143 # session" and disable the Revoke button so a user can't accidentally 144 # log themselves out. 145 is_current: bool = False 146 # Fine-grained scope summary (empty/"none" for user-kind tokens). 147 competition_access: str = "none" # none | all | selected 148 competition_ids: list[str] = Field(default_factory=list) 149 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.
191class TokenScopesResponse(CtfyModel): 192 """``GET /auth/tokens/scopes`` — the catalog the UI renders.""" 193 194 categories: list[ScopeCatalogEntry] = Field(default_factory=list) 195 levels: list[str] = Field(default_factory=lambda: ["none", "read", "write"])
GET /auth/tokens/scopes — the catalog the UI renders.
152class TrendPoint(CtfyModel): 153 """One UTC-day bucket on the solve-trend line.""" 154 155 date: str = "" # YYYY-MM-DD, UTC 156 solves: int = 0
One UTC-day bucket on the solve-trend line.
85class UpdateUserRoleRequest(CtfyModel): 86 """Body for ``PATCH /admin/users/{user_id}/role``. 87 88 Only ``"admin"`` and ``"user"`` are accepted — granting 89 ``"super_admin"`` is reserved for the env allowlist 90 (``CTFY_SUPER_ADMIN_EMAILS``) so the trust anchor stays out-of-band. 91 """ 92 93 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.
599class UserScoreboardEntry(CtfyModel): 600 """One row on the user-ranked global scoreboard. 601 602 Aggregates a single user's solves across every team they've 603 ever been on (each ``SolveState`` carries both ``user_id`` and 604 ``team_id``, so per-user totals are a straight filter over the 605 global solve table). Excludes users with zero solves to keep 606 the public list compact. 607 """ 608 609 rank: int = 0 610 user_id: str 611 display_name: str 612 avatar_url: str = "" 613 country: str = "" 614 # Total flags solved across all teams the user has ever been on. 615 flags_solved: int = 0 616 # Challenges fully solved (every declared flag captured). 617 solved: int = 0 618 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.
172class UserSolveTrend(CtfyModel): 173 """The calling user's daily solve count for the last ``days`` UTC days, 174 aggregated across every team they have ever been on (matched by 175 ``user_id`` on each :class:`SolveState`). 176 177 Powers the Dashboard's "personal solve trend" chart, which is 178 user-scoped — independent of which competition the user is currently 179 registered for. Multi-flag challenges count once per flag day, the 180 same way :class:`ProfileStats.solve_trend` does it. 181 """ 182 183 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.
228class VerifyAnswerRequest(CtfyModel): 229 """Oracle-mode answer check — same grader as /submissions but writes 230 no submission record. Carries the same shape as :class:`SubmissionCreate` 231 minus the audit trail. 232 """ 233 234 # Which question on the challenge the agent is checking. Defaults 235 # to ``"flag"`` for single-question challenges. 236 question_id: str = "flag" 237 # The answer to verify (str for dynamic / static / single_select; 238 # list[str] for multi_select). 239 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.
242class VerifyAnswerResponse(CtfyModel): 243 correct: bool 244 # Echoes the request's ``question_id`` on a correct match; ``None`` 245 # on a wrong claim. Useful for batched verification calls where the 246 # client wants to confirm which check the response refers to. 247 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.