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 AdminTimeseries, 37 AdminTimeseriesBucket, 38 AdminTrafficInstanceRow, 39 AdminTrafficSummary, 40 AdminTrafficTeamRow, 41 AdminUnhealthyNode, 42 PlatformSettingInfo, 43 PlatformSettingPatch, 44 QuestionAttemptResetInfo, 45 QuestionAttemptResetRequest, 46 ShellRecordingInfo, 47) 48from ctfy.server.models.announcement import ( 49 AnnouncementCreate, 50 AnnouncementInfo, 51 AnnouncementUpdate, 52) 53from ctfy.server.models.auth import ( 54 AuthTokenResponse, 55 CreateFineGrainedTokenRequest, 56 CreateFineGrainedTokenResponse, 57 DeviceApproveRequest, 58 DeviceCodeResponse, 59 DeviceInfoResponse, 60 DeviceTokenRequest, 61 DeviceTokenResponse, 62 LinkedIdentity, 63 LinkStartResponse, 64 LoginRequest, 65 OAuthProviderInfo, 66 PasswordAuthInfo, 67 ProvidersResponse, 68 RegisterRequest, 69 ScopeCatalogEntry, 70 SetPasswordRequest, 71 TokenInfo, 72 TokenScopesResponse, 73) 74from ctfy.server.models.challenge import ( 75 AttachmentList, 76 ChallengeBuildKickoffNodeResult, 77 ChallengeBuildKickoffResponse, 78 ChallengeBuildNodeState, 79 ChallengeBuildStateResponse, 80 ChallengeBuildStateRow, 81 ChallengeFacetCount, 82 ChallengeFacets, 83 ChallengeFlagStats, 84 ChallengeInfo, 85 ChallengeRescanNodeResult, 86 ChallengeRescanResult, 87 ChallengeSolveAttempt, 88 ChallengeSolveAttemptsResponse, 89 ChallengeStats, 90 ChallengeTeamSolveSummary, 91 CompetitionChallengeBreakdown, 92 CompetitionChallengeRow, 93 CompetitionScoreDistribution, 94 CompetitionScoreHistory, 95 MilestoneProgress, 96 MySolveSummary, 97 QaChallengeView, 98 QaSubmissionCreate, 99 QuestionPublicInfo, 100 ScoreboardEntry, 101 ScoreBucket, 102 ScoreHistoryPoint, 103 ScoreHistorySeries, 104 SubmissionCreate, 105 SubmissionResponse, 106 UserScoreboardEntry, 107) 108from ctfy.server.models.competition import ( 109 CompetitionAdminInfo, 110 CompetitionCreate, 111 CompetitionDetail, 112 CompetitionInfo, 113 CompetitionMembershipInfo, 114 CompetitionRegistrationInfo, 115 CompetitionUpdate, 116 GrantCompetitionAdminRequest, 117) 118from ctfy.server.models.feedback import ( 119 AdminFeedbackRow, 120 FeedbackStats, 121 MyReactionsResponse, 122) 123from ctfy.server.models.instance import ( 124 InstanceInfo, 125 InstanceQuestionInfo, 126 InstanceRecordArtifacts, 127 InstanceRecordDetail, 128 InstanceRecordInfo, 129 InstanceRecordInfoDetail, 130 InstanceStatusResponse, 131 RenewResponse, 132 StartRequest, 133 StartResponse, 134 StopResponse, 135 VerifyAnswerRequest, 136 VerifyAnswerResponse, 137) 138from ctfy.server.models.meta import ( 139 ErrorResponse, 140 HealthResponse, 141 MetaChallenges, 142 MetaPlatform, 143 MetaResponse, 144) 145from ctfy.server.models.node import ( 146 ClusterInfo, 147 CreateInviteRequest, 148 CreateInviteResponse, 149 NodeHeartbeat, 150 NodeInfo, 151 NodeInviteInfo, 152 NodePatch, 153 NodeRegister, 154 NodeRegisterResponse, 155) 156from ctfy.server.models.pagination import BigLimitOffsetPage 157from ctfy.server.models.team import ( 158 InboxAnnouncement, 159 InboxCaptainRequest, 160 InboxIncomingInvite, 161 InboxOutgoingRequest, 162 InboxResponse, 163 TeamDetail, 164 TeamInfo, 165 TeamInviteInfo, 166 TeamMemberInfo, 167) 168from ctfy.server.models.user import ( 169 PROFILE_VISIBILITY_KEYS, 170 AdminUserInfo, 171 CalendarBucket, 172 DeleteMeRequest, 173 DifficultyStat, 174 MeResponse, 175 ProfilePatchRequest, 176 ProfileStats, 177 TagStat, 178 TrendPoint, 179 UpdateUserRoleRequest, 180 UserInfo, 181 UserSolveTrend, 182) 183 184__all__ = [ 185 "PROFILE_VISIBILITY_KEYS", 186 "AchievementCatalogEntry", 187 "AchievementProgress", 188 "AchievementSummary", 189 "Activity", 190 "ActivityTimeseries", 191 "ActivityTimeseriesBucket", 192 "AdminChallengeLastError", 193 "AdminChallengeLatencyBucket", 194 "AdminChallengeStatsRow", 195 "AdminChallengeTimeseries", 196 "AdminChallengeTimeseriesBucket", 197 "AdminFeedbackRow", 198 "AdminHealthFlags", 199 "AdminOverview", 200 "AdminOverviewCounts", 201 "AdminRecentError", 202 "AdminSilentChallenge", 203 "AdminSolveCell", 204 "AdminSolveMatrix", 205 "AdminSolveMatrixChallenge", 206 "AdminSolveMatrixTeam", 207 "AdminStuckInstance", 208 "AdminTimeseries", 209 "AdminTimeseriesBucket", 210 "AdminTrafficInstanceRow", 211 "AdminTrafficSummary", 212 "AdminTrafficTeamRow", 213 "AdminUnhealthyNode", 214 "AdminUserInfo", 215 "AnnouncementCreate", 216 "AnnouncementInfo", 217 "AnnouncementUpdate", 218 "AttachmentList", 219 "AuthTokenResponse", 220 "BigLimitOffsetPage", 221 "CalendarBucket", 222 "ChallengeBuildKickoffNodeResult", 223 "ChallengeBuildKickoffResponse", 224 "ChallengeBuildNodeState", 225 "ChallengeBuildStateResponse", 226 "ChallengeBuildStateRow", 227 "ChallengeFacetCount", 228 "ChallengeFacets", 229 "ChallengeFlagStats", 230 "ChallengeInfo", 231 "ChallengeRescanNodeResult", 232 "ChallengeRescanResult", 233 "ChallengeSolveAttempt", 234 "ChallengeSolveAttemptsResponse", 235 "ChallengeStats", 236 "ChallengeTeamSolveSummary", 237 "ClusterInfo", 238 "CompetitionAdminInfo", 239 "CompetitionChallengeBreakdown", 240 "CompetitionChallengeRow", 241 "CompetitionCreate", 242 "CompetitionDetail", 243 "CompetitionInfo", 244 "CompetitionMembershipInfo", 245 "CompetitionRegistrationInfo", 246 "CompetitionScoreDistribution", 247 "CompetitionScoreHistory", 248 "CompetitionUpdate", 249 "CreateFineGrainedTokenRequest", 250 "CreateFineGrainedTokenResponse", 251 "CreateInviteRequest", 252 "CreateInviteResponse", 253 "DeleteMeRequest", 254 "DeviceApproveRequest", 255 "DeviceCodeResponse", 256 "DeviceInfoResponse", 257 "DeviceTokenRequest", 258 "DeviceTokenResponse", 259 "DifficultyStat", 260 "EasterEggClaim", 261 "ErrorResponse", 262 "FeedbackStats", 263 "GrantCompetitionAdminRequest", 264 "HealthResponse", 265 "InboxAnnouncement", 266 "InboxCaptainRequest", 267 "InboxIncomingInvite", 268 "InboxOutgoingRequest", 269 "InboxResponse", 270 "InstanceInfo", 271 "InstanceQuestionInfo", 272 "InstanceRecordArtifacts", 273 "InstanceRecordDetail", 274 "InstanceRecordInfo", 275 "InstanceRecordInfoDetail", 276 "InstanceStatusResponse", 277 "LinkStartResponse", 278 "LinkedIdentity", 279 "LoginRequest", 280 "MeResponse", 281 "MetaChallenges", 282 "MetaPlatform", 283 "MetaResponse", 284 "MilestoneProgress", 285 "MyAchievementsResponse", 286 "MyReactionsResponse", 287 "MySolveSummary", 288 "NodeHeartbeat", 289 "NodeInfo", 290 "NodeInviteInfo", 291 "NodePatch", 292 "NodeRegister", 293 "NodeRegisterResponse", 294 "OAuthProviderInfo", 295 "PasswordAuthInfo", 296 "PlatformSettingInfo", 297 "PlatformSettingPatch", 298 "ProfilePatchRequest", 299 "ProfileStats", 300 "ProvidersResponse", 301 "QaChallengeView", 302 "QaSubmissionCreate", 303 "QuestionAttemptResetInfo", 304 "QuestionAttemptResetRequest", 305 "QuestionPublicInfo", 306 "RecentUnlock", 307 "RegisterRequest", 308 "RenewResponse", 309 "ScopeCatalogEntry", 310 "ScoreBucket", 311 "ScoreHistoryPoint", 312 "ScoreHistorySeries", 313 "ScoreboardEntry", 314 "SetPasswordRequest", 315 "ShellRecordingInfo", 316 "StarGazerVerifyResponse", 317 "StartRequest", 318 "StartResponse", 319 "StopResponse", 320 "SubmissionCreate", 321 "SubmissionResponse", 322 "TagStat", 323 "TeamAchievement", 324 "TeamDetail", 325 "TeamInfo", 326 "TeamInviteInfo", 327 "TeamMemberInfo", 328 "TokenInfo", 329 "TokenScopesResponse", 330 "TrendPoint", 331 "UpdateUserRoleRequest", 332 "UserInfo", 333 "UserScoreboardEntry", 334 "UserSolveTrend", 335 "VerifyAnswerRequest", 336 "VerifyAnswerResponse", 337]
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 detail: ActivityDetail = Field(default_factory=ActivityDetail)
A single activity event in the platform log.
51class ActivityTimeseries(CtfyModel): 52 """Bucketed activity counts for the per-team activity histogram. 53 54 Same ``ts`` semantics as ``AdminTimeseries`` — oldest-first, right 55 edge anchored to "now". ``events`` is the sorted union of event 56 types that appear in any bucket, so the frontend has a stable list 57 of stack segments to render even when individual buckets are empty. 58 """ 59 60 window_s: int = 0 61 bucket_s: int = 0 62 events: list[str] = Field(default_factory=list) 63 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.
36class ActivityTimeseriesBucket(CtfyModel): 37 """One bucket of the team's activity histogram. 38 39 ``counts`` is keyed by event type (``flag_correct``, ``instance_started``, 40 …) so the frontend can stack each event as its own bar segment without 41 a follow-up shape-change to add new event types. 42 43 See :class:`AdminTimeseriesBucket` for the ``partial`` semantics. 44 """ 45 46 ts: float = 0.0 # right edge, Unix seconds 47 counts: dict[str, int] = Field(default_factory=dict) 48 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.
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
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.
119class CalendarBucket(CtfyModel): 120 """One UTC-day cell on the contribution calendar. 121 122 ``count`` aggregates correct submissions + first-time solves on that 123 day; effectively "did anything productive happen". The series is 124 dense — every day in the requested window is present, zero-filled.""" 125 126 date: str = "" # YYYY-MM-DD, UTC 127 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.
239class ChallengeFlagStats(CtfyModel): 240 """Per-flag aggregate for one challenge (for the detail page).""" 241 242 flag_id: str 243 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.
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.
260class ChallengeSolveAttempt(CtfyModel): 261 """One archived instance for a challenge — used to visualise per-team 262 multi-solve attempts on the challenge detail page.""" 263 264 instance_id: str 265 team_id: str = "" 266 team_name: str = "" 267 display_name: str = "" 268 challenge_id: str = "" 269 started_at: float = 0.0 270 stopped_at: float = 0.0 271 duration_s: float = 0.0 272 attempts: int = 0 273 solved: bool = False 274 solved_flags: list[str] = Field(default_factory=list) 275 stop_reason: str = ""
One archived instance for a challenge — used to visualise per-team multi-solve attempts on the challenge detail page.
292class ChallengeSolveAttemptsResponse(CtfyModel): 293 challenge_id: str 294 attempts: list[ChallengeSolveAttempt] = Field(default_factory=list) 295 per_team: list[ChallengeTeamSolveSummary] = Field(default_factory=list) 296 # Total archived instances (incl. unsolved) — lets the UI show 297 # "showing N solved of M total" when the response is filtered. 298 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.
246class ChallengeStats(CtfyModel): 247 challenge_id: str 248 name: str = "" 249 category: ChallengeCategory | None = None 250 difficulty: str = "" 251 # Teams that captured every declared flag on this challenge. 252 solves_count: int = 0 253 total_attempts: int = 0 254 success_rate: float = 0.0 255 # Per-flag breakdown. Single-flag challenges have a single entry with 256 # ``flag_id == "flag"`` and ``solves_count == solves_count`` above. 257 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.
278class ChallengeTeamSolveSummary(CtfyModel): 279 """Per-team aggregate for the challenge detail solve-attempts panel.""" 280 281 team_id: str 282 team_name: str = "" 283 display_name: str = "" 284 solve_count: int = 0 285 attempt_count: int = 0 286 best_duration_s: float = 0.0 287 total_duration_s: float = 0.0 288 first_solved_at: float = 0.0 289 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.
164class CompetitionAdminInfo(CtfyModel): 165 """One per-competition admin grant, for the super-admin management 166 card on the competition detail page.""" 167 168 user_id: str 169 display_name: str = "" 170 email: str = "" 171 granted_by: str = "" 172 granted_at: datetime | None = None
One per-competition admin grant, for the super-admin management card on the competition detail page.
508class CompetitionChallengeBreakdown(CtfyModel): 509 challenges: list[CompetitionChallengeRow] = Field(default_factory=list) 510 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.
486class CompetitionChallengeRow(CtfyModel): 487 """Per-challenge competition aggregate: solve count, attempt 488 success rate, and first blood. Reconstructed from the solve log 489 with the same window / team filter as the scoreboard so the 490 numbers always agree with the leaderboard.""" 491 492 challenge_id: str 493 name: str = "" 494 category: ChallengeCategory | None = None 495 difficulty: str = "" 496 # Teams that captured every declared flag (matches scoreboard's 497 # ``solved`` tally). 498 solves_count: int = 0 499 # In-window submissions for this challenge across registered teams. 500 attempts: int = 0 501 success_rate: float = 0.0 502 # First team to solve this challenge in-window. Empty when unsolved. 503 first_blood_team_id: str = "" 504 first_blood_team_name: str = "" 505 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 challenge_ids: list[str] = Field(default_factory=list, max_length=200) 87 # New competitions start hidden ("draft") so admins can prepare them 88 # privately and publish when ready. 89 status: Literal["draft", "published", "archived"] = "draft" 90 91 _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.
147class CompetitionDetail(CompetitionInfo): 148 """Full detail payload — includes resolved challenge summaries and 149 the caller's registration state when authenticated. 150 """ 151 152 challenges: list[ChallengeInfo] = Field(default_factory=list) 153 registered_at: datetime | None = None
Full detail payload — includes resolved challenge summaries and the caller's registration state when authenticated.
107class CompetitionInfo(CtfyModel): 108 """Summary row used in the list view. 109 110 Per-viewer fields (``is_registered`` / ``my_*``) are populated only 111 when the request carries an authenticated user; for anonymous 112 requests they fall back to sentinels (empty strings, ``-1``) so 113 the response shape stays stable. Front-end treats ``my_rank < 0`` 114 as "no value" rather than "rank zero". 115 """ 116 117 id: str 118 title: str 119 description: str 120 starts_at: datetime | None = None 121 ends_at: datetime | None = None 122 created_at: datetime | None = None 123 updated_at: datetime | None = None 124 created_by: str 125 created_by_name: str 126 challenge_ids: list[str] = Field(default_factory=list) 127 # Visibility gate. Only ``published`` rows reach non-admins; admins 128 # and per-competition admins also receive ``draft`` / ``archived``. 129 status: Literal["draft", "published", "archived"] = "published" 130 # Server-derived projection. ``"upcoming"`` / ``"running"`` / ``"past"`` 131 # — saves the frontend from re-implementing the same string-compare 132 # the backend already does. 133 phase: Literal["upcoming", "running", "past"] = "running" 134 # Total registered teams. Surfaced on cards so users can gauge 135 # popularity at a glance. 136 registered_count: int = 0 137 # Viewer-scoped fields — see class docstring. 138 is_registered: bool = False 139 my_team_id: str = "" 140 my_team_name: str = "" 141 my_role: Literal["captain", "member", ""] = "" 142 my_rank: int = -1 143 my_score: int = -1 144 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".
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.
156class CompetitionRegistrationInfo(CtfyModel): 157 """One row in the admin "who's registered" table.""" 158 159 team_id: str 160 team_name: str = "" 161 registered_at: datetime | None = None
One row in the admin "who's registered" table.
520class CompetitionScoreDistribution(CtfyModel): 521 """Aggregate companion to the (now server-paginated) competition 522 scoreboard: the score-distribution histogram + the true team count. 523 524 The standings table pages server-side, so the histogram / "N teams" 525 figure can't be re-derived from one page in the browser — this 526 bins every registered team's ``flags_solved`` with the same 527 adaptive scheme the frontend used to do client-side. 528 """ 529 530 total_teams: int = 0 531 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.
477class CompetitionScoreHistory(CtfyModel): 478 """Top-N teams' score/rank progression, reconstructed from the 479 competition's solve log (no snapshot dependency — exact for past 480 and live competitions alike).""" 481 482 series: list[ScoreHistorySeries] = Field(default_factory=list) 483 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).
94class CompetitionUpdate(CtfyModel): 95 """PATCH body — every field optional, ``None`` means "leave alone".""" 96 97 title: str | None = Field(default=None, min_length=1, max_length=200) 98 description: str | None = Field(default=None, max_length=20000) 99 starts_at: datetime | None = None 100 ends_at: datetime | None = None 101 challenge_ids: list[str] | None = Field(default=None, max_length=200) 102 status: Literal["draft", "published", "archived"] | None = None 103 104 _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.
109class DeleteMeRequest(CtfyModel): 110 """Body for ``DELETE /me``. ``confirm_display_name`` must match the 111 caller's user display name exactly (case-sensitive) — the typo 112 gate that stops a fat-fingered click from cascading the account's 113 data away. (Falls back to email when display_name is empty.) 114 """ 115 116 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 = ""
143class DifficultyStat(CtfyModel): 144 difficulty: str = "" # easy | medium | hard | ... 145 solved: int = 0 146 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.
175class GrantCompetitionAdminRequest(CtfyModel): 176 """Body for ``PUT /admin/competitions/{id}/admins`` — resolve the 177 target user by id or (case-insensitive) email.""" 178 179 user_id: str = "" 180 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.
108class InboxAnnouncement(CtfyModel): 109 """Announcement projected onto the Inbox surface. 110 111 Carries the same fields as :class:`AnnouncementInfo` plus a 112 per-user ``is_read`` flag derived from the 113 ``announcement_reads`` table. The frontend renders unread rows 114 prominently and lets the user mark them read via 115 ``POST /me/announcements/{id}/read``. 116 """ 117 118 id: str 119 title: str 120 body: str 121 severity: AnnouncementSeverity 122 starts_at: datetime | None = None 123 ends_at: datetime | None = None 124 created_at: datetime | None = None 125 updated_at: datetime | None = None 126 created_by: str 127 created_by_name: str 128 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 invite_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.
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 invite_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.
131class InboxResponse(CtfyModel): 132 """``GET /me/inbox`` payload — pending invites/requests + 133 actionable announcements grouped by the action the calling 134 user can take. 135 """ 136 137 incoming_invites: list[InboxIncomingInvite] = Field(default_factory=list) 138 outgoing_requests: list[InboxOutgoingRequest] = Field(default_factory=list) 139 captain_requests: list[InboxCaptainRequest] = Field(default_factory=list) 140 # Site-wide announcements relevant right now (currently live + a 141 # short tail of recently-expired) with the user's read state. 142 announcements: list[InboxAnnouncement] = Field(default_factory=list) 143 # Convenience count so the sidebar can render an unread badge 144 # without re-iterating ``announcements``. 145 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 identity_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.
409class MilestoneProgress(CtfyModel): 410 """Per-challenge milestone progress for the calling user. 411 412 Returned by ``GET /me/milestone-progress``. One row per challenge 413 with at least one captured question, regardless of whether the 414 challenge is fully solved. The Challenges list page renders a 415 progress bar from ``len(solved_question_ids) / total_questions`` 416 so a player can see "2/5 milestones" instead of an all-or-nothing 417 solved badge. 418 419 Like ``MySolveSummary`` this is user-scoped: ``solved_question_ids`` 420 aggregates every question id the user captured for the challenge 421 across every team they have ever been on. The optional 422 ``competition_id`` query param narrows the aggregation to solves 423 stamped against the user's team in that comp. 424 """ 425 426 challenge_id: str 427 solved_question_ids: list[str] = Field(default_factory=list) 428 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.
393class MySolveSummary(CtfyModel): 394 """Per-challenge solve summary for the calling team. 395 396 Returned by ``GET /me/solves``. One row per challenge with at least 397 one captured flag. ``best_rank`` is the team's best (smallest) rank 398 across the flags they captured for the challenge — drives the 399 1血/2血/3血 badge on the challenge cards. Multi-flag challenges may 400 have different ranks per flag; reporting the best one gives players 401 credit for whichever piece they nailed first. 402 """ 403 404 challenge_id: str 405 best_rank: int 406 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 node_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 node_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.
93class ProfilePatchRequest(CtfyModel): 94 """Body for ``PATCH /me/profile``. Every field is optional so the 95 UI can send only the diff; the route only writes the fields that 96 were present in the JSON. ``None`` means "clear this field".""" 97 98 # ``Field(default=...)`` with a non-None sentinel is awkward in 99 # pydantic; we instead declare each field as Optional + default-None 100 # and rely on the request's raw dict (via ``model_fields_set``) to 101 # know which keys were actually present. 102 bio: str | None = Field(default=None, max_length=280) 103 country: str | None = None 104 website_url: str | None = Field(default=None, max_length=200) 105 timezone: str | None = Field(default=None, max_length=80) 106 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".
156class ProfileStats(CtfyModel): 157 """Aggregate per-team analytics surfaced on the public profile. 158 159 Every section is independently visibility-gated; non-owner viewers 160 see ``[]`` for a section the team has marked private. Owner and 161 admin always see the full payload.""" 162 163 calendar: list[CalendarBucket] = Field(default_factory=list) 164 by_tag: list[TagStat] = Field(default_factory=list) 165 by_difficulty: list[DifficultyStat] = Field(default_factory=list) 166 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.
367class QaChallengeView(CtfyModel): 368 """One QA challenge as seen by a team on the quiz surface. 369 370 Carries the full question / choices / description data alongside 371 per-team solve and attempt state, so the frontend can render the 372 quiz list with progress badges in a single round-trip. 373 """ 374 375 id: str 376 name: str 377 # Provenance bucket prefix — ``SECQA`` / ``MMLU-CS`` / ``CTI-MCQ`` 378 # / etc — derived from the challenge id (everything before the 379 # trailing numeric suffix). Lets the frontend group / filter 380 # without splitting the id again client-side. 381 bucket: str 382 description: str 383 difficulty: str = "" 384 tags: list[str] = Field(default_factory=list) 385 questions: list[QuestionPublicInfo] = Field(default_factory=list) 386 # Question ids the calling team has already captured. 387 solved_question_ids: list[str] = Field(default_factory=list) 388 # Question ids the team has submitted at least one wrong answer 389 # against but never solved. Drives the "Try again" amber badge. 390 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.
350class QaSubmissionCreate(CtfyModel): 351 """Request body for ``POST /qa/submissions`` — the instance-free 352 submission path for pure question-answer challenges. 353 354 No ``instance_id``: QA challenges have no Docker infra to launch, 355 so there's no per-team instance row to read context off. The 356 caller passes ``challenge_id`` + ``competition_id`` explicitly, 357 and the server resolves the submitter's team for the named 358 competition via the standard auth helper. 359 """ 360 361 challenge_id: str 362 competition_id: str 363 question_id: str = "answer" 364 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.
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.
513class ScoreBucket(CtfyModel): 514 """One bucket of the scoreboard score-distribution histogram.""" 515 516 label: str 517 count: int = 0
One bucket of the scoreboard score-distribution histogram.
459class ScoreHistoryPoint(CtfyModel): 460 """One sample on a team's score/rank-over-time curve. 461 462 Both metrics travel together so the frontend can toggle 463 Score ⇄ Rank without a refetch. 464 """ 465 466 ts: float = 0.0 # Unix seconds (solve time of the driving event) 467 score: int = 0 # Cumulative flags captured up to ``ts`` 468 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.
471class ScoreHistorySeries(CtfyModel): 472 team_id: str 473 team_name: str 474 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.
436class ScoreboardEntry(CtfyModel): 437 rank: int = 0 438 team_id: str 439 team_name: str 440 # Challenges where the team captured every declared flag. Preserved 441 # for parity with the legacy single-flag scoreboard. 442 solved: int = 0 443 # Total individual flags captured across all challenges. Primary 444 # scoreboard metric now that multi-flag challenges exist — rewards 445 # partial progress (e.g. foothold without root). 446 flags_solved: int = 0 447 attempts: int = 0 448 last_solve_at: datetime | None = None 449 # Most recent activity of any kind. Distinct from ``last_solve_at`` — 450 # a team may be actively attempting without a solve, and the UI 451 # wants to show that they're not idle. 452 last_active_at: datetime | None = None 453 # OAuth provider avatar URL for the team's earliest identity that 454 # supplies one. Empty for password-only teams; the frontend falls 455 # back to a generated initials avatar in that case. 456 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.
306class SubmissionCreate(CtfyModel): 307 # ``instance_id`` is the canonical handle since 0.2: the server 308 # reads challenge_id and competition_id off the instance row, so 309 # callers don't need to repeat them and N instances per 310 # (team, challenge) are unambiguous on submit. 311 instance_id: str 312 # Which question on the challenge the agent is answering. Defaults 313 # to ``"flag"`` so single-question challenges stay one-liner submits; 314 # multi-question challenges (see ``ChallengeInfo.questions``) must 315 # specify the id explicitly. 316 question_id: str = "flag" 317 # The agent's submission. String for ``dynamic`` / ``static`` / 318 # ``single_select`` questions; list of strings for ``multi_select`` 319 # (e.g. ``["idor", "ssrf"]``). The server picks the grader by 320 # consulting ``spec.questions[i].mode`` on the live instance. 321 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.
324class SubmissionResponse(CtfyModel): 325 id: str 326 correct: bool 327 solve_time_s: float = 0 # seconds from instance start to correct submission 328 # 1 = first blood, 2 = second, 3 = third, 4+ = regular solve. 329 # 0 when incorrect or when the team already had a prior correct solve. 330 # Ranks within the specific question — independent of the 331 # full-challenge solve rank. 332 solve_rank: int = 0 333 # The question id this submission targeted. Echoed back to the 334 # caller so simple submission UIs don't have to thread the id 335 # separately. Equal to the request's ``question_id``. 336 question_id: str | None = None 337 # True iff this team has now captured every question declared on 338 # the challenge. Single-question challenges: always equals ``correct``. 339 challenge_fully_solved: bool = False 340 # Remaining wrong-attempt budget for the question this submission 341 # targeted, AFTER this attempt is counted (the cap is on wrongs; 342 # correct submissions don't consume budget). ``None`` when the 343 # question's mode is uncapped (dynamic free-form, or any mode the 344 # operator opted out of via ``question_attempt_caps``). The UI 345 # uses this to decrement its remaining-attempts badge without 346 # re-fetching the instance — see ``ctfy.server.submission_policy``. 347 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.
130class TagStat(CtfyModel): 131 """One axis on the per-tag strength radar. 132 133 ``solved`` is the team's solve count for challenges carrying this 134 tag; ``total`` is the platform-wide challenge count for the tag, 135 so the frontend can render "team / total" or normalize to a 0–1 136 ratio for the radar polygon.""" 137 138 tag: str = "" 139 solved: int = 0 140 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 = ""
148class TeamInviteInfo(CtfyModel): 149 """Public projection of a :class:`TeamInviteState`. 150 151 Surfaces on captain CRUD. ``active`` is server-derived from the 152 expires/use/revoked triple. ``kind`` discriminates code-style 153 invites from directed invites/requests; ``target_user_id`` is 154 the recipient for ``kind="direct"`` (set at mint time) and the 155 requester for ``kind="join_request"`` (set when a member opens 156 a request). 157 """ 158 159 id: str 160 code: str 161 team_id: str 162 created_by_user_id: str 163 created_at: datetime | None = None 164 expires_at: datetime | None = None 165 max_uses: int 166 use_count: int 167 revoked_at: datetime | None = None 168 active: bool = True 169 competition_id: str = "" 170 kind: Literal["code", "direct", "join_request"] = "code" 171 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 token_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.
149class TrendPoint(CtfyModel): 150 """One UTC-day bucket on the solve-trend line.""" 151 152 date: str = "" # YYYY-MM-DD, UTC 153 solves: int = 0
One UTC-day bucket on the solve-trend line.
82class UpdateUserRoleRequest(CtfyModel): 83 """Body for ``PATCH /admin/users/{user_id}/role``. 84 85 Only ``"admin"`` and ``"user"`` are accepted — granting 86 ``"super_admin"`` is reserved for the env allowlist 87 (``CTFY_SUPER_ADMIN_EMAILS``) so the trust anchor stays out-of-band. 88 """ 89 90 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.
534class UserScoreboardEntry(CtfyModel): 535 """One row on the user-ranked global scoreboard. 536 537 Aggregates a single user's solves across every team they've 538 ever been on (each ``SolveState`` carries both ``user_id`` and 539 ``team_id``, so per-user totals are a straight filter over the 540 global solve table). Excludes users with zero solves to keep 541 the public list compact. 542 """ 543 544 rank: int = 0 545 user_id: str 546 display_name: str 547 avatar_url: str = "" 548 country: str = "" 549 # Total flags solved across all teams the user has ever been on. 550 flags_solved: int = 0 551 # Challenges fully solved (every declared flag captured). 552 solved: int = 0 553 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.
169class UserSolveTrend(CtfyModel): 170 """The calling user's daily solve count for the last ``days`` UTC days, 171 aggregated across every team they have ever been on (matched by 172 ``user_id`` on each :class:`SolveState`). 173 174 Powers the Dashboard's "personal solve trend" chart, which is 175 user-scoped — independent of which competition the user is currently 176 registered for. Multi-flag challenges count once per flag day, the 177 same way :class:`ProfileStats.solve_trend` does it. 178 """ 179 180 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.