ctfy.server.models

Public wire-model package — re-exports for back-compat.

  1"""Public wire-model package — re-exports for back-compat."""
  2
  3from __future__ import annotations
  4
  5from ctfy.server.models.achievement import (
  6    AchievementCatalogEntry,
  7    AchievementProgress,
  8    AchievementSummary,
  9    EasterEggClaim,
 10    MyAchievementsResponse,
 11    RecentUnlock,
 12    StarGazerVerifyResponse,
 13    TeamAchievement,
 14)
 15from ctfy.server.models.activity import (
 16    Activity,
 17    ActivityTimeseries,
 18    ActivityTimeseriesBucket,
 19)
 20from ctfy.server.models.admin import (
 21    AdminChallengeLastError,
 22    AdminChallengeLatencyBucket,
 23    AdminChallengeStatsRow,
 24    AdminChallengeTimeseries,
 25    AdminChallengeTimeseriesBucket,
 26    AdminHealthFlags,
 27    AdminOverview,
 28    AdminOverviewCounts,
 29    AdminRecentError,
 30    AdminSilentChallenge,
 31    AdminSolveCell,
 32    AdminSolveMatrix,
 33    AdminSolveMatrixChallenge,
 34    AdminSolveMatrixTeam,
 35    AdminStuckInstance,
 36    AdminTaskInfo,
 37    AdminTaskListResponse,
 38    AdminTaskLogLine,
 39    AdminTaskLogsResponse,
 40    AdminTaskSubmitRequest,
 41    AdminTimeseries,
 42    AdminTimeseriesBucket,
 43    AdminTrafficInstanceRow,
 44    AdminTrafficSummary,
 45    AdminTrafficTeamRow,
 46    AdminUnhealthyNode,
 47    DeploymentConfig,
 48    LlmBudgetPage,
 49    LlmBudgetResetInfo,
 50    LlmBudgetResetRequest,
 51    LlmBudgetRow,
 52    PlatformSettingInfo,
 53    PlatformSettingPatch,
 54    QuestionAttemptResetInfo,
 55    QuestionAttemptResetRequest,
 56    ScheduledJobInfo,
 57    ScheduledJobPatch,
 58    ShellRecordingInfo,
 59)
 60from ctfy.server.models.announcement import (
 61    AnnouncementCreate,
 62    AnnouncementInfo,
 63    AnnouncementUpdate,
 64)
 65from ctfy.server.models.auth import (
 66    AuthTokenResponse,
 67    CreateFineGrainedTokenRequest,
 68    CreateFineGrainedTokenResponse,
 69    DeviceApproveRequest,
 70    DeviceCodeResponse,
 71    DeviceInfoResponse,
 72    DeviceTokenRequest,
 73    DeviceTokenResponse,
 74    EmailCodeAuthInfo,
 75    LinkedIdentity,
 76    LinkStartResponse,
 77    LoginRequest,
 78    OAuthProviderInfo,
 79    PasswordAuthInfo,
 80    ProvidersResponse,
 81    RegisterRequest,
 82    ScopeCatalogEntry,
 83    SetPasswordRequest,
 84    TokenInfo,
 85    TokenScopesResponse,
 86)
 87from ctfy.server.models.awd import (
 88    MAX_FLAGS_PER_SUBMISSION,
 89    AwdBoardEntry,
 90    AwdBoardResponse,
 91    AwdFlagResult,
 92    AwdFlagSubmitRequest,
 93    AwdFlagSubmitResponse,
 94    AwdMatchConfig,
 95    AwdMatchInfo,
 96    AwdMatchStatus,
 97    AwdProvisionPlanInfo,
 98)
 99from ctfy.server.models.challenge import (
100    AdminPatchSubmissionDetail,
101    AdminPatchSubmissionInfo,
102    AdminPatchSubmissionPage,
103    AttachmentList,
104    ChallengeBuildKickoffNodeResult,
105    ChallengeBuildKickoffResponse,
106    ChallengeBuildNodeState,
107    ChallengeBuildStateResponse,
108    ChallengeBuildStateRow,
109    ChallengeFacetCount,
110    ChallengeFacets,
111    ChallengeFlagStats,
112    ChallengeInfo,
113    ChallengePullKickoffNodeResult,
114    ChallengePullKickoffResponse,
115    ChallengePullNodeState,
116    ChallengePullStateResponse,
117    ChallengePullStateRow,
118    ChallengeRescanNodeResult,
119    ChallengeRescanResult,
120    ChallengeSolveAttempt,
121    ChallengeSolveAttemptsResponse,
122    ChallengeStats,
123    ChallengeTeamSolveSummary,
124    CompetitionChallengeBreakdown,
125    CompetitionChallengeRow,
126    CompetitionScoreDistribution,
127    CompetitionScoreHistory,
128    DefenceInfo,
129    DefenceSource,
130    MilestoneProgress,
131    MySolveSummary,
132    PatchSubmissionCreate,
133    PatchSubmissionInfo,
134    QaChallengeView,
135    QaSubmissionCreate,
136    QuestionPublicInfo,
137    ScoreboardEntry,
138    ScoreBucket,
139    ScoreHistoryPoint,
140    ScoreHistorySeries,
141    SubmissionCreate,
142    SubmissionResponse,
143    UserScoreboardEntry,
144)
145from ctfy.server.models.competition import (
146    BulkInviteRequest,
147    BulkInviteResponse,
148    BulkInviteResultRow,
149    CompetitionAdminInfo,
150    CompetitionCreate,
151    CompetitionDetail,
152    CompetitionInfo,
153    CompetitionInviteInfo,
154    CompetitionMembershipInfo,
155    CompetitionRegistrationInfo,
156    CompetitionSessionInfo,
157    CompetitionUpdate,
158    GrantCompetitionAdminRequest,
159    VirtualGhostBoard,
160    VirtualGhostRow,
161)
162from ctfy.server.models.eval import (
163    EvalLeaderboard,
164    EvalTrendPoint,
165    HarnessModelCell,
166    HarnessStanding,
167    LeaderboardCell,
168    ModelStanding,
169    VendorStandardSet,
170    VendorStanding,
171)
172from ctfy.server.models.feedback import (
173    AdminFeedbackRow,
174    FeedbackStats,
175    MyReactionsResponse,
176)
177from ctfy.server.models.instance import (
178    AnswerTraceHit,
179    AnswerTraceRequest,
180    AnswerTraceResponse,
181    CheckAnswerRequest,
182    CheckAnswerResponse,
183    InstanceInfo,
184    InstanceQuestionInfo,
185    InstanceRecordArtifacts,
186    InstanceRecordDetail,
187    InstanceRecordInfo,
188    InstanceRecordInfoDetail,
189    InstanceStatusResponse,
190    PlayerShellCreate,
191    PlayerShellTicket,
192    RenewResponse,
193    SshCredential,
194    SshExchangeRequest,
195    StartRequest,
196    StartResponse,
197    StopResponse,
198    VerifyAnswerRequest,
199    VerifyAnswerResponse,
200)
201from ctfy.server.models.meta import (
202    ErrorResponse,
203    HealthResponse,
204    MetaChallenges,
205    MetaPlatform,
206    MetaResponse,
207)
208from ctfy.server.models.node import (
209    ClusterInfo,
210    CreateInviteRequest,
211    CreateInviteResponse,
212    NodeHeartbeat,
213    NodeInfo,
214    NodeInviteInfo,
215    NodePatch,
216    NodeRegister,
217    NodeRegisterResponse,
218)
219from ctfy.server.models.notification import (
220    EmailSuppressionCreate,
221    EmailSuppressionInfo,
222    EmailSuppressionListResponse,
223    EmailTemplateInfo,
224    EmailTemplateListResponse,
225    NotificationCategoryInfo,
226    NotificationChannelCell,
227    NotificationPreferencesResponse,
228    NotificationPreferenceUpdate,
229    TestEmailRequest,
230    TestEmailResult,
231)
232from ctfy.server.models.pagination import BigLimitOffsetPage
233from ctfy.server.models.registration import (
234    MyRegistrationResponse,
235    RegistrationFieldSpec,
236    RegistrationFormSpec,
237    RegistrationRosterRow,
238    RegistrationSubmitRequest,
239    RegistrationSummary,
240    RegistrationTeamRow,
241    ReviewDecisionRequest,
242    TeamLogoUploadResponse,
243)
244from ctfy.server.models.series import (
245    CompetitionSeriesConfig,
246    CompetitionSeriesInfo,
247    SeriesOccurrencePreview,
248    SeriesPreview,
249)
250from ctfy.server.models.team import (
251    AdminTeamRow,
252    CompetitionRoster,
253    CompetitionRosterTeam,
254    InboxAnnouncement,
255    InboxCaptainRequest,
256    InboxCompetitionInvite,
257    InboxIncomingInvite,
258    InboxOutgoingRequest,
259    InboxResponse,
260    TeamCaptainRequest,
261    TeamDetail,
262    TeamDisqualifyRequest,
263    TeamInfo,
264    TeamInviteInfo,
265    TeamMemberInfo,
266    TeamStandingInfo,
267)
268from ctfy.server.models.user import (
269    PROFILE_VISIBILITY_KEYS,
270    AdminUserInfo,
271    CalendarBucket,
272    DeleteMeRequest,
273    DifficultyStat,
274    MeResponse,
275    ProfilePatchRequest,
276    ProfileStats,
277    TagStat,
278    TrendPoint,
279    UpdateUserRoleRequest,
280    UserInfo,
281    UserSolveTrend,
282)
283
284__all__ = [
285    "MAX_FLAGS_PER_SUBMISSION",
286    "PROFILE_VISIBILITY_KEYS",
287    "AchievementCatalogEntry",
288    "AchievementProgress",
289    "AchievementSummary",
290    "Activity",
291    "ActivityTimeseries",
292    "ActivityTimeseriesBucket",
293    "AdminChallengeLastError",
294    "AdminChallengeLatencyBucket",
295    "AdminChallengeStatsRow",
296    "AdminChallengeTimeseries",
297    "AdminChallengeTimeseriesBucket",
298    "AdminFeedbackRow",
299    "AdminHealthFlags",
300    "AdminOverview",
301    "AdminOverviewCounts",
302    "AdminPatchSubmissionDetail",
303    "AdminPatchSubmissionInfo",
304    "AdminPatchSubmissionPage",
305    "AdminRecentError",
306    "AdminSilentChallenge",
307    "AdminSolveCell",
308    "AdminSolveMatrix",
309    "AdminSolveMatrixChallenge",
310    "AdminSolveMatrixTeam",
311    "AdminStuckInstance",
312    "AdminTaskInfo",
313    "AdminTaskListResponse",
314    "AdminTaskLogLine",
315    "AdminTaskLogsResponse",
316    "AdminTaskSubmitRequest",
317    "AdminTeamRow",
318    "AdminTimeseries",
319    "AdminTimeseriesBucket",
320    "AdminTrafficInstanceRow",
321    "AdminTrafficSummary",
322    "AdminTrafficTeamRow",
323    "AdminUnhealthyNode",
324    "AdminUserInfo",
325    "AnnouncementCreate",
326    "AnnouncementInfo",
327    "AnnouncementUpdate",
328    "AnswerTraceHit",
329    "AnswerTraceRequest",
330    "AnswerTraceResponse",
331    "AttachmentList",
332    "AuthTokenResponse",
333    "AwdBoardEntry",
334    "AwdBoardResponse",
335    "AwdFlagResult",
336    "AwdFlagSubmitRequest",
337    "AwdFlagSubmitResponse",
338    "AwdMatchConfig",
339    "AwdMatchInfo",
340    "AwdMatchStatus",
341    "AwdProvisionPlanInfo",
342    "BigLimitOffsetPage",
343    "BulkInviteRequest",
344    "BulkInviteResponse",
345    "BulkInviteResultRow",
346    "CalendarBucket",
347    "ChallengeBuildKickoffNodeResult",
348    "ChallengeBuildKickoffResponse",
349    "ChallengeBuildNodeState",
350    "ChallengeBuildStateResponse",
351    "ChallengeBuildStateRow",
352    "ChallengeFacetCount",
353    "ChallengeFacets",
354    "ChallengeFlagStats",
355    "ChallengeInfo",
356    "ChallengePullKickoffNodeResult",
357    "ChallengePullKickoffResponse",
358    "ChallengePullNodeState",
359    "ChallengePullStateResponse",
360    "ChallengePullStateRow",
361    "ChallengeRescanNodeResult",
362    "ChallengeRescanResult",
363    "ChallengeSolveAttempt",
364    "ChallengeSolveAttemptsResponse",
365    "ChallengeStats",
366    "ChallengeTeamSolveSummary",
367    "CheckAnswerRequest",
368    "CheckAnswerResponse",
369    "ClusterInfo",
370    "CompetitionAdminInfo",
371    "CompetitionChallengeBreakdown",
372    "CompetitionChallengeRow",
373    "CompetitionCreate",
374    "CompetitionDetail",
375    "CompetitionInfo",
376    "CompetitionInviteInfo",
377    "CompetitionMembershipInfo",
378    "CompetitionRegistrationInfo",
379    "CompetitionRoster",
380    "CompetitionRosterTeam",
381    "CompetitionScoreDistribution",
382    "CompetitionScoreHistory",
383    "CompetitionSeriesConfig",
384    "CompetitionSeriesInfo",
385    "CompetitionSessionInfo",
386    "CompetitionUpdate",
387    "CreateFineGrainedTokenRequest",
388    "CreateFineGrainedTokenResponse",
389    "CreateInviteRequest",
390    "CreateInviteResponse",
391    "DefenceInfo",
392    "DefenceSource",
393    "DeleteMeRequest",
394    "DeploymentConfig",
395    "DeviceApproveRequest",
396    "DeviceCodeResponse",
397    "DeviceInfoResponse",
398    "DeviceTokenRequest",
399    "DeviceTokenResponse",
400    "DifficultyStat",
401    "EasterEggClaim",
402    "EmailCodeAuthInfo",
403    "EmailSuppressionCreate",
404    "EmailSuppressionInfo",
405    "EmailSuppressionListResponse",
406    "EmailTemplateInfo",
407    "EmailTemplateListResponse",
408    "ErrorResponse",
409    "EvalLeaderboard",
410    "EvalTrendPoint",
411    "FeedbackStats",
412    "GrantCompetitionAdminRequest",
413    "HarnessModelCell",
414    "HarnessStanding",
415    "HealthResponse",
416    "InboxAnnouncement",
417    "InboxCaptainRequest",
418    "InboxCompetitionInvite",
419    "InboxIncomingInvite",
420    "InboxOutgoingRequest",
421    "InboxResponse",
422    "InstanceInfo",
423    "InstanceQuestionInfo",
424    "InstanceRecordArtifacts",
425    "InstanceRecordDetail",
426    "InstanceRecordInfo",
427    "InstanceRecordInfoDetail",
428    "InstanceStatusResponse",
429    "LeaderboardCell",
430    "LinkStartResponse",
431    "LinkedIdentity",
432    "LlmBudgetPage",
433    "LlmBudgetResetInfo",
434    "LlmBudgetResetRequest",
435    "LlmBudgetRow",
436    "LoginRequest",
437    "MeResponse",
438    "MetaChallenges",
439    "MetaPlatform",
440    "MetaResponse",
441    "MilestoneProgress",
442    "ModelStanding",
443    "MyAchievementsResponse",
444    "MyReactionsResponse",
445    "MyRegistrationResponse",
446    "MySolveSummary",
447    "NodeHeartbeat",
448    "NodeInfo",
449    "NodeInviteInfo",
450    "NodePatch",
451    "NodeRegister",
452    "NodeRegisterResponse",
453    "NotificationCategoryInfo",
454    "NotificationChannelCell",
455    "NotificationPreferenceUpdate",
456    "NotificationPreferencesResponse",
457    "OAuthProviderInfo",
458    "PasswordAuthInfo",
459    "PatchSubmissionCreate",
460    "PatchSubmissionInfo",
461    "PlatformSettingInfo",
462    "PlatformSettingPatch",
463    "PlayerShellCreate",
464    "PlayerShellTicket",
465    "ProfilePatchRequest",
466    "ProfileStats",
467    "ProvidersResponse",
468    "QaChallengeView",
469    "QaSubmissionCreate",
470    "QuestionAttemptResetInfo",
471    "QuestionAttemptResetRequest",
472    "QuestionPublicInfo",
473    "RecentUnlock",
474    "RegisterRequest",
475    "RegistrationFieldSpec",
476    "RegistrationFormSpec",
477    "RegistrationRosterRow",
478    "RegistrationSubmitRequest",
479    "RegistrationSummary",
480    "RegistrationTeamRow",
481    "RenewResponse",
482    "ReviewDecisionRequest",
483    "ScheduledJobInfo",
484    "ScheduledJobPatch",
485    "ScopeCatalogEntry",
486    "ScoreBucket",
487    "ScoreHistoryPoint",
488    "ScoreHistorySeries",
489    "ScoreboardEntry",
490    "SeriesOccurrencePreview",
491    "SeriesPreview",
492    "SetPasswordRequest",
493    "ShellRecordingInfo",
494    "SshCredential",
495    "SshExchangeRequest",
496    "StarGazerVerifyResponse",
497    "StartRequest",
498    "StartResponse",
499    "StopResponse",
500    "SubmissionCreate",
501    "SubmissionResponse",
502    "TagStat",
503    "TeamAchievement",
504    "TeamCaptainRequest",
505    "TeamDetail",
506    "TeamDisqualifyRequest",
507    "TeamInfo",
508    "TeamInviteInfo",
509    "TeamLogoUploadResponse",
510    "TeamMemberInfo",
511    "TeamStandingInfo",
512    "TestEmailRequest",
513    "TestEmailResult",
514    "TokenInfo",
515    "TokenScopesResponse",
516    "TrendPoint",
517    "UpdateUserRoleRequest",
518    "UserInfo",
519    "UserScoreboardEntry",
520    "UserSolveTrend",
521    "VendorStandardSet",
522    "VendorStanding",
523    "VerifyAnswerRequest",
524    "VerifyAnswerResponse",
525    "VirtualGhostBoard",
526    "VirtualGhostRow",
527]
MAX_FLAGS_PER_SUBMISSION = 1000
PROFILE_VISIBILITY_KEYS = frozenset({'website_url', 'activity_calendar', 'country', 'attempts_count', 'timezone', 'difficulty_stats', 'solves_count', 'tag_stats', 'solve_trend', 'achievements', 'last_active_at', 'email', 'rank_history', 'bio', 'social_links'})
class AchievementCatalogEntry(ctfy.core.models.CtfyModel):
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.

id: str = PydanticUndefined
name: str = PydanticUndefined
description: str = PydanticUndefined
icon: str = PydanticUndefined
tier: str = PydanticUndefined
secret: bool = False
progress: AchievementProgress | None = None
points: int = 0
earned_by_count: int = 0
rarity: str = 'unearned'
class AchievementProgress(ctfy.core.models.CtfyModel):
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.

current: int = PydanticUndefined
target: int = PydanticUndefined
class AchievementSummary(ctfy.core.models.CtfyModel):
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.

unlocked_count: int = 0
total_count: int = 0
score: int = 0
total_score: int = 0
class Activity(ctfy.core.models.CtfyModel):
18class Activity(CtfyModel):
19    """A single activity event in the platform log."""
20
21    id: str = ""
22    timestamp: datetime | None = None
23    event: str  # See PlatformEvent enum for valid values
24    team_id: str = ""
25    team_name: str = ""
26    challenge_id: str = ""
27    # Who triggered this event — surfaced on the public feed so admins
28    # can filter by actor and auditors can trace back. Values mirror
29    # the ActivityState fields in core.state.models.
30    actor_id: str = ""
31    actor_name: str = ""
32    actor_type: str = ""
33    # Denormalised actor avatar (GitHub/Google), surfaced on the feed so
34    # each event can render the actor's real face. Empty ⇒ the frontend
35    # renders a generated initials chip instead.
36    actor_avatar_url: str = ""
37    detail: ActivityDetail = Field(default_factory=ActivityDetail)

A single activity event in the platform log.

id: str = ''
timestamp: datetime.datetime | None = None
event: str = PydanticUndefined
team_id: str = ''
team_name: str = ''
challenge_id: str = ''
actor_id: str = ''
actor_name: str = ''
actor_type: str = ''
actor_avatar_url: str = ''
detail: ctfy.core.activity.ActivityDetail = PydanticUndefined
class ActivityTimeseries(ctfy.core.models.CtfyModel):
55class ActivityTimeseries(CtfyModel):
56    """Bucketed activity counts for the per-team activity histogram.
57
58    Same ``ts`` semantics as ``AdminTimeseries`` — oldest-first, right
59    edge anchored to "now". ``events`` is the sorted union of event
60    types that appear in any bucket, so the frontend has a stable list
61    of stack segments to render even when individual buckets are empty.
62    """
63
64    window_s: int = 0
65    bucket_s: int = 0
66    events: list[str] = Field(default_factory=list)
67    buckets: list[ActivityTimeseriesBucket] = Field(default_factory=list)

Bucketed activity counts for the per-team activity histogram.

Same ts semantics as AdminTimeseries — oldest-first, right edge anchored to "now". events is the sorted union of event types that appear in any bucket, so the frontend has a stable list of stack segments to render even when individual buckets are empty.

window_s: int = 0
bucket_s: int = 0
events: list[str] = PydanticUndefined
buckets: list[ActivityTimeseriesBucket] = PydanticUndefined
class ActivityTimeseriesBucket(ctfy.core.models.CtfyModel):
40class ActivityTimeseriesBucket(CtfyModel):
41    """One bucket of the team's activity histogram.
42
43    ``counts`` is keyed by event type (``flag_correct``, ``instance_started``,
44    …) so the frontend can stack each event as its own bar segment without
45    a follow-up shape-change to add new event types.
46
47    See :class:`AdminTimeseriesBucket` for the ``partial`` semantics.
48    """
49
50    ts: float = 0.0  # right edge, Unix seconds
51    counts: dict[str, int] = Field(default_factory=dict)
52    partial: bool = False

One bucket of the team's activity histogram.

counts is keyed by event type (flag_correct, instance_started, …) so the frontend can stack each event as its own bar segment without a follow-up shape-change to add new event types.

See AdminTimeseriesBucket for the partial semantics.

ts: float = 0.0
counts: dict[str, int] = PydanticUndefined
partial: bool = False
class AdminChallengeLastError(ctfy.core.models.CtfyModel):
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.

ts: float = 0.0
message: str = ''
instance_id: str = ''
class AdminChallengeLatencyBucket(ctfy.core.models.CtfyModel):
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.

label: str = PydanticUndefined
count: int = 0
class AdminChallengeStatsRow(ctfy.core.models.CtfyModel):
 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.

challenge_id: str = PydanticUndefined
name: str = ''
difficulty: str = ''
category: str = ''
tags: list[str] = PydanticUndefined
question_count: int = 0
launches: int = 0
errors: int = 0
error_rate: float = 0.0
avg_launch_s: float = 0.0
p50_launch_s: float = 0.0
p95_launch_s: float = 0.0
solve_rate: float = 0.0
running_count: int = 0
last_error: AdminChallengeLastError | None = None
recent_launch_durations: list[float] = PydanticUndefined
class AdminChallengeTimeseries(ctfy.core.models.CtfyModel):
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.

challenge_id: str = ''
window_s: int = 0
bucket_s: int = 0
buckets: list[AdminChallengeTimeseriesBucket] = PydanticUndefined
class AdminChallengeTimeseriesBucket(ctfy.core.models.CtfyModel):
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.

ts: float = 0.0
launches: int = 0
errors: int = 0
error_rate: float = 0.0
p50_launch_s: float = 0.0
p95_launch_s: float = 0.0
partial: bool = False
class AdminFeedbackRow(ctfy.core.models.CtfyModel):
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.

user_id: str = PydanticUndefined
user_display_name: str = ''
user_email: str = ''
challenge_id: str = PydanticUndefined
team_id: str = PydanticUndefined
competition_id: str = PydanticUndefined
reaction: Literal['addictive', 'mindblown', 'learned', 'goat_setter', 'overthought', 'guessy', 'brutal', 'buggy', 'infra_broken'] = PydanticUndefined
created_at: datetime.datetime | None = None
updated_at: datetime.datetime | None = None
class AdminHealthFlags(ctfy.core.models.CtfyModel):
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.

stuck_starting: list[AdminStuckInstance] = PydanticUndefined
recent_errors: list[AdminRecentError] = PydanticUndefined
unhealthy_nodes: list[AdminUnhealthyNode] = PydanticUndefined
silent_challenges: list[AdminSilentChallenge] = PydanticUndefined
class AdminOverview(ctfy.core.models.CtfyModel):
255class AdminOverview(CtfyModel):
256    """Single aggregate payload for the admin landing page."""
257
258    nodes: AdminOverviewCounts = Field(default_factory=AdminOverviewCounts)
259    config: DeploymentConfig = Field(default_factory=DeploymentConfig)
260    capacity: int = 0
261    running_instances: int = 0
262    teams_total: int = 0
263    solves_total: int = 0
264    solves_today: int = 0
265    # User-engagement counters — the "are people actually on the platform right
266    # now?" signal the infra/challenge/team metrics above don't answer.
267    # ``users_active_24h`` is distinct humans who triggered a write action
268    # (launch / submit / solve / join / answer) in the last 24h, not raw logins
269    # (pure browsing leaves no activity row). ``users_new_24h`` is registrations
270    # in the same window. All default to 0 so the field set stays additive.
271    users_total: int = 0
272    users_active_24h: int = 0
273    users_new_24h: int = 0

Single aggregate payload for the admin landing page.

nodes: AdminOverviewCounts = PydanticUndefined
config: DeploymentConfig = PydanticUndefined
capacity: int = 0
running_instances: int = 0
teams_total: int = 0
solves_total: int = 0
solves_today: int = 0
users_total: int = 0
users_active_24h: int = 0
users_new_24h: int = 0
class AdminOverviewCounts(ctfy.core.models.CtfyModel):
64class AdminOverviewCounts(CtfyModel):
65    total: int = 0
66    healthy: 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.

total: int = 0
healthy: int = 0
class AdminPatchSubmissionDetail(ctfy.server.models.AdminPatchSubmissionInfo):
852class AdminPatchSubmissionDetail(AdminPatchSubmissionInfo):
853    """One submission with the code it carried.
854
855    This is what keeping the bytes was *for*: a team disputes a verdict,
856    or a checker bug is found on day two, and someone has to read what
857    was actually submitted. Without a reader the stored blobs bought
858    nothing.
859    """
860
861    #: ``{path: base64(bytes)}``. Base64 rather than text because a
862    #: patch target may legitimately be binary, and decoding it into a
863    #: string would corrupt it into something that merely looks like a
864    #: bad submission.
865    files_b64: dict[str, str] = Field(default_factory=dict)
866    #: ``{path: sha256}`` — an equality match across teams, which is
867    #: what is left once uniqueness has stopped flag sharing but nothing
868    #: stops *patch* sharing.
869    digests: dict[str, str] = Field(default_factory=dict)

One submission with the code it carried.

This is what keeping the bytes was for: a team disputes a verdict, or a checker bug is found on day two, and someone has to read what was actually submitted. Without a reader the stored blobs bought nothing.

files_b64: dict[str, str] = PydanticUndefined
digests: dict[str, str] = PydanticUndefined
class AdminPatchSubmissionInfo(ctfy.server.models.PatchSubmissionInfo):
831class AdminPatchSubmissionInfo(PatchSubmissionInfo):
832    """One row of the organiser's defence queue.
833
834    The player's view plus the four things only an organiser asks:
835    *whose* it is, and — when a verdict is disputed — which worker
836    decided it after how many tries.
837
838    Still **without the code**, and here that is a stronger requirement
839    than on the parent: this is a competition-wide listing, so shipping
840    bodies would mean reading every stored patch of the event to render
841    a table of statuses. The bytes live on
842    :class:`AdminPatchSubmissionDetail`, one row at a time.
843    """
844
845    team_id: str = ""
846    user_id: str = ""
847    #: Which worker judged it, for correlating with that node's logs.
848    node_id: str = ""
849    attempts: int = 0

One row of the organiser's defence queue.

The player's view plus the four things only an organiser asks: whose it is, and — when a verdict is disputed — which worker decided it after how many tries.

Still without the code, and here that is a stronger requirement than on the parent: this is a competition-wide listing, so shipping bodies would mean reading every stored patch of the event to render a table of statuses. The bytes live on AdminPatchSubmissionDetail, one row at a time.

team_id: str = ''
user_id: str = ''
node_id: str = ''
attempts: int = 0
class AdminPatchSubmissionPage(ctfy.core.models.CtfyModel):
872class AdminPatchSubmissionPage(CtfyModel):
873    """A page of the defence queue, with the total behind it.
874
875    ``total`` is its own query rather than ``len(items)`` so a pager can
876    say "1-20 of 340" — and, with a ``status`` filter, it is also the
877    answer to "is the queue draining".
878    """
879
880    items: list[AdminPatchSubmissionInfo] = Field(default_factory=list)
881    total: int = 0

A page of the defence queue, with the total behind it.

total is its own query rather than len(items) so a pager can say "1-20 of 340" — and, with a status filter, it is also the answer to "is the queue draining".

items: list[AdminPatchSubmissionInfo] = PydanticUndefined
total: int = 0
class AdminRecentError(ctfy.core.models.CtfyModel):
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".

instance_id: str = PydanticUndefined
challenge_id: str = ''
team_id: str = ''
node_id: str = ''
error: str = ''
stopped_at: float = 0.0
class AdminSilentChallenge(ctfy.core.models.CtfyModel):
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.

challenge_id: str = PydanticUndefined
name: str = ''
launches: int = 0
submissions: int = 0
class AdminSolveCell(ctfy.core.models.CtfyModel):
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".

team_id: str = PydanticUndefined
challenge_id: str = PydanticUndefined
solved: bool = False
solved_at: datetime.datetime | None = None
solve_time_s: float = 0.0
attempts: int = 0
first_blood: bool = False
class AdminSolveMatrix(ctfy.core.models.CtfyModel):
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.

teams: list[AdminSolveMatrixTeam] = PydanticUndefined
challenges: list[AdminSolveMatrixChallenge] = PydanticUndefined
cells: list[AdminSolveCell] = PydanticUndefined
class AdminSolveMatrixChallenge(ctfy.core.models.CtfyModel):
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.

challenge_id: str = PydanticUndefined
name: str = ''
category: str = ''
difficulty: str = ''
solves_count: int = 0
class AdminSolveMatrixTeam(ctfy.core.models.CtfyModel):
36class AdminSolveMatrixTeam(CtfyModel):
37    team_id: str
38    name: str = ""
39    solved: 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.

team_id: str = PydanticUndefined
name: str = ''
solved: int = 0
class AdminStuckInstance(ctfy.core.models.CtfyModel):
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.

instance_id: str = PydanticUndefined
challenge_id: str = ''
team_id: str = ''
node_id: str = ''
requested_at: float = 0.0
stuck_for_s: float = 0.0
class AdminTaskInfo(ctfy.core.models.CtfyModel):
490class AdminTaskInfo(CtfyModel):
491    """One background task on ``GET /admin/tasks`` / detail. A projection
492    of ``TaskState`` onto the wire (these routes are admin-only, so the
493    full ``error_detail`` traceback tail is included)."""
494
495    id: str = ""
496    kind: str = ""
497    status: str = ""
498    params: dict[str, Any] = Field(default_factory=dict)
499    result: dict[str, Any] = Field(default_factory=dict)
500    progress: float = 0.0
501    progress_message: str = ""
502    error: str = ""
503    error_detail: str = ""
504    cancel_requested: bool = False
505    created_by_user_id: str = ""
506    created_by_name: str = ""
507    created_at_ts: float = 0.0
508    started_at_ts: float = 0.0
509    finished_at_ts: float = 0.0

One background task on GET /admin/tasks / detail. A projection of TaskState onto the wire (these routes are admin-only, so the full error_detail traceback tail is included).

id: str = ''
kind: str = ''
status: str = ''
params: dict[str, typing.Any] = PydanticUndefined
result: dict[str, typing.Any] = PydanticUndefined
progress: float = 0.0
progress_message: str = ''
error: str = ''
error_detail: str = ''
cancel_requested: bool = False
created_by_user_id: str = ''
created_by_name: str = ''
created_at_ts: float = 0.0
started_at_ts: float = 0.0
finished_at_ts: float = 0.0
class AdminTaskListResponse(ctfy.core.models.CtfyModel):
512class AdminTaskListResponse(CtfyModel):
513    """Paginated task list."""
514
515    items: list[AdminTaskInfo] = Field(default_factory=list)
516    total: int = 0
517    offset: int = 0
518    limit: int = 50

Paginated task list.

items: list[AdminTaskInfo] = PydanticUndefined
total: int = 0
offset: int = 0
limit: int = 50
class AdminTaskLogLine(ctfy.core.models.CtfyModel):
521class AdminTaskLogLine(CtfyModel):
522    """One verbose log line for a task (admin-only)."""
523
524    seq: int = 0
525    ts: float = 0.0
526    level: str = "info"
527    message: str = ""

One verbose log line for a task (admin-only).

seq: int = 0
ts: float = 0.0
level: str = 'info'
message: str = ''
class AdminTaskLogsResponse(ctfy.core.models.CtfyModel):
530class AdminTaskLogsResponse(CtfyModel):
531    """A cursor page of task logs. ``next_after`` is the ``seq`` to pass as
532    ``?after=`` on the next poll (the last item's seq, or the request's
533    ``after`` when the page is empty — so an empty poll never rewinds the
534    cursor)."""
535
536    items: list[AdminTaskLogLine] = Field(default_factory=list)
537    next_after: int = 0

A cursor page of task logs. next_after is the seq to pass as ?after= on the next poll (the last item's seq, or the request's after when the page is empty — so an empty poll never rewinds the cursor).

items: list[AdminTaskLogLine] = PydanticUndefined
next_after: int = 0
class AdminTaskSubmitRequest(ctfy.core.models.CtfyModel):
540class AdminTaskSubmitRequest(CtfyModel):
541    """Body of ``POST /admin/tasks``. ``kind`` is validated against the
542    runner's handler registry; ``params`` is the kind-specific input
543    (e.g. ``{"competition_id": ...}`` or ``{"job": "ttl_sweep"}``)."""
544
545    kind: str = ""
546    params: dict[str, Any] = Field(default_factory=dict)

Body of POST /admin/tasks. kind is validated against the runner's handler registry; params is the kind-specific input (e.g. {"competition_id": ...} or {"job": "ttl_sweep"}).

kind: str = ''
params: dict[str, typing.Any] = PydanticUndefined
class AdminTeamRow(ctfy.core.models.CtfyModel):
281class AdminTeamRow(CtfyModel):
282    """One row of the organiser's team table.
283
284    Deliberately **not** ``TeamInfo`` plus a field. ``TeamInfo`` is the
285    public projection, and standing is not public: a disqualification
286    reaches the team and the organisers, and announcing it more widely is
287    the organiser's call — the same reasoning that keeps
288    ``team_disqualified`` off the public SSE feed. Putting
289    ``disqualified`` on the public model would publish the verdict on
290    every team page, from a route nobody had to think about.
291
292    Also **not** ``RegistrationTeamRow``: that one carries every member's
293    sealed personal details and lands an audit row for the bulk read.
294    Managing a roster does not need anybody's phone number, and paying
295    that price to render a table would make the audit trail meaningless
296    by filling it with routine work.
297    """
298
299    id: str
300    name: str
301    description: str = ""
302    competition_id: str = ""
303    captain_user_id: str = ""
304    captain_display_name: str = ""
305    member_count: int = 0
306    created_at: datetime | None = None
307    review_status: str = ""
308    disqualified: bool = False
309    disqualified_at: datetime | None = None
310    disqualification_reason: str = ""
311    members: list[TeamMemberInfo] = Field(default_factory=list)

One row of the organiser's team table.

Deliberately not TeamInfo plus a field. TeamInfo is the public projection, and standing is not public: a disqualification reaches the team and the organisers, and announcing it more widely is the organiser's call — the same reasoning that keeps team_disqualified off the public SSE feed. Putting disqualified on the public model would publish the verdict on every team page, from a route nobody had to think about.

Also not RegistrationTeamRow: that one carries every member's sealed personal details and lands an audit row for the bulk read. Managing a roster does not need anybody's phone number, and paying that price to render a table would make the audit trail meaningless by filling it with routine work.

id: str = PydanticUndefined
name: str = PydanticUndefined
description: str = ''
competition_id: str = ''
captain_user_id: str = ''
captain_display_name: str = ''
member_count: int = 0
created_at: datetime.datetime | None = None
review_status: str = ''
disqualified: bool = False
disqualified_at: datetime.datetime | None = None
disqualification_reason: str = ''
members: list[TeamMemberInfo] = PydanticUndefined
class AdminTimeseries(ctfy.core.models.CtfyModel):
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.

metric: str = ''
window_s: int = 0
bucket_s: int = 0
buckets: list[AdminTimeseriesBucket] = PydanticUndefined
class AdminTimeseriesBucket(ctfy.core.models.CtfyModel):
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.

ts: float = 0.0
value: int = 0
partial: bool = False
class AdminTrafficInstanceRow(ctfy.core.models.CtfyModel):
291class AdminTrafficInstanceRow(CtfyModel):
292    """One instance × team × challenge row for the admin traffic dashboard."""
293
294    instance_id: str
295    team_id: str
296    team_name: str = ""
297    challenge_id: str = ""
298    name: str = ""
299    status: str = ""
300    request_count: int = 0
301    started_at: float = 0.0
302    stopped_at: float = 0.0
303    # ``True`` if the instance is still running (request_count is approximate
304    # — refreshed only when the dashboard re-fetches).
305    live: bool = False

One instance × team × challenge row for the admin traffic dashboard.

instance_id: str = PydanticUndefined
team_id: str = PydanticUndefined
team_name: str = ''
challenge_id: str = ''
name: str = ''
status: str = ''
request_count: int = 0
started_at: float = 0.0
stopped_at: float = 0.0
live: bool = False
class AdminTrafficSummary(ctfy.core.models.CtfyModel):
308class AdminTrafficSummary(CtfyModel):
309    """Top-level response for ``GET /admin/traffic/summary``."""
310
311    teams: list[AdminTrafficTeamRow] = Field(default_factory=list)
312    instances: list[AdminTrafficInstanceRow] = Field(default_factory=list)
313    total_requests: int = 0

Top-level response for GET /admin/traffic/summary.

teams: list[AdminTrafficTeamRow] = PydanticUndefined
instances: list[AdminTrafficInstanceRow] = PydanticUndefined
total_requests: int = 0
class AdminTrafficTeamRow(ctfy.core.models.CtfyModel):
276class AdminTrafficTeamRow(CtfyModel):
277    """Per-team aggregate row for the admin traffic dashboard.
278
279    Sums HTTP request counts across every archived instance the team has
280    ever launched, plus the live count for any instances the team is
281    still running.
282    """
283
284    team_id: str
285    team_name: str = ""
286    instance_count: int = 0
287    request_count: int = 0
288    last_activity_at: float = 0.0

Per-team aggregate row for the admin traffic dashboard.

Sums HTTP request counts across every archived instance the team has ever launched, plus the live count for any instances the team is still running.

team_id: str = PydanticUndefined
team_name: str = ''
instance_count: int = 0
request_count: int = 0
last_activity_at: float = 0.0
class AdminUnhealthyNode(ctfy.core.models.CtfyModel):
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.

node_id: str = PydanticUndefined
display_name: str = ''
url: str = ''
last_heartbeat_ts: float = 0.0
downtime_s: float = 0.0
class AdminUserInfo(ctfy.server.models.UserInfo):
77class AdminUserInfo(UserInfo):
78    """Admin-only view of a user. Carries the privilege tier and the
79    most recent role-change audit fields."""
80
81    email: str = ""
82    role: Literal["user", "admin", "super_admin"] = "user"
83    promoted_by: str | None = None
84    promoted_at: datetime | None = None
85    # OAuth providers the user has bound (e.g. ``["github", "google"]``),
86    # so the admin user list can surface which SSO accounts are linked.
87    providers: list[str] = Field(default_factory=list)

Admin-only view of a user. Carries the privilege tier and the most recent role-change audit fields.

email: str = ''
role: Literal['user', 'admin', 'super_admin'] = 'user'
promoted_by: str | None = None
promoted_at: datetime.datetime | None = None
providers: list[str] = PydanticUndefined
class AnnouncementCreate(ctfy.core.models.CtfyModel):
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.

title: str = PydanticUndefined
body: str = ''
severity: Literal['info', 'warning', 'critical'] = 'info'
starts_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
class AnnouncementInfo(ctfy.core.models.CtfyModel):
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.

id: str = PydanticUndefined
title: str = PydanticUndefined
body: str = PydanticUndefined
severity: Literal['info', 'warning', 'critical'] = PydanticUndefined
starts_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
created_at: datetime.datetime | None = None
updated_at: datetime.datetime | None = None
created_by: str = PydanticUndefined
created_by_name: str = PydanticUndefined
class AnnouncementUpdate(ctfy.core.models.CtfyModel):
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".

title: str | None = None
body: str | None = None
severity: Optional[Literal['info', 'warning', 'critical']] = None
starts_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
class AnswerTraceHit(ctfy.core.models.CtfyModel):
205class AnswerTraceHit(CtfyModel):
206    """One instance that was minted the traced answer.
207
208    ``live`` distinguishes a still-running instance (matched against the
209    in-memory minted map) from an archived one (matched against the
210    persisted digest). Both are authoritative; the flag only says
211    whether the instance can still be inspected or stopped.
212    """
213
214    instance_id: str = ""
215    question_id: str = ""
216    team_id: str = ""
217    team_name: str = ""
218    challenge_id: str = ""
219    competition_id: str = ""
220    live: bool = False
221    started_at: float = 0.0
222    stopped_at: float = 0.0

One instance that was minted the traced answer.

live distinguishes a still-running instance (matched against the in-memory minted map) from an archived one (matched against the persisted digest). Both are authoritative; the flag only says whether the instance can still be inspected or stopped.

instance_id: str = ''
question_id: str = ''
team_id: str = ''
team_name: str = ''
challenge_id: str = ''
competition_id: str = ''
live: bool = False
started_at: float = 0.0
stopped_at: float = 0.0
class AnswerTraceRequest(ctfy.core.models.CtfyModel):
191class AnswerTraceRequest(CtfyModel):
192    """Body for ``POST /admin/answer-trace``.
193
194    The answer travels in the body rather than a query parameter
195    because a live flag in a URL lands in every access log, proxy
196    cache, and browser history between the operator and the app.
197
198    Accepted in either the ``FLAG{...}`` or bare form — the server
199    normalises both to one digest, matching how submissions grade.
200    """
201
202    answer: str = Field(min_length=1, max_length=512)

Body for POST /admin/answer-trace.

The answer travels in the body rather than a query parameter because a live flag in a URL lands in every access log, proxy cache, and browser history between the operator and the app.

Accepted in either the FLAG{...} or bare form — the server normalises both to one digest, matching how submissions grade.

answer: str = PydanticUndefined
class AnswerTraceResponse(ctfy.core.models.CtfyModel):
225class AnswerTraceResponse(CtfyModel):
226    """Result of ``POST /admin/answer-trace``.
227
228    ``hits`` is normally empty or length 1. More than one means two
229    instances genuinely drew the same value — astronomically unlikely
230    for a 128-bit token, but reported rather than silently collapsed
231    because attributing a leak to the wrong team is worse than
232    reporting ambiguity.
233
234    ``traceable_records`` is the number of archived records that carry
235    a digest at all. Instances archived before answer fingerprinting
236    shipped have none, so an empty ``hits`` on a low count means "this
237    predates tracing", not "this flag was never minted here" — the
238    distinction matters when an organiser is deciding whether to act.
239    """
240
241    answer_hash: str = ""
242    hits: list[AnswerTraceHit] = Field(default_factory=list)
243    searched_live: int = 0
244    searched_records: int = 0
245    traceable_records: int = 0

Result of POST /admin/answer-trace.

hits is normally empty or length 1. More than one means two instances genuinely drew the same value — astronomically unlikely for a 128-bit token, but reported rather than silently collapsed because attributing a leak to the wrong team is worse than reporting ambiguity.

traceable_records is the number of archived records that carry a digest at all. Instances archived before answer fingerprinting shipped have none, so an empty hits on a low count means "this predates tracing", not "this flag was never minted here" — the distinction matters when an organiser is deciding whether to act.

answer_hash: str = ''
hits: list[AnswerTraceHit] = PydanticUndefined
searched_live: int = 0
searched_records: int = 0
traceable_records: int = 0
class AttachmentList(ctfy.core.models.CtfyModel):
147class AttachmentList(CtfyModel):
148    """Response payload for ``GET /challenges/{id}/attachments``.
149
150    Same data as ``ChallengeInfo.attachments`` but addressable directly
151    so pre-launch UI / agent tooling can fetch it without paging through
152    the catalog. Order matches the on-disk sort.
153    """
154
155    files: list[AttachmentInfo] = Field(default_factory=list)

Response payload for GET /challenges/{id}/attachments.

Same data as ChallengeInfo.attachments but addressable directly so pre-launch UI / agent tooling can fetch it without paging through the catalog. Order matches the on-disk sort.

files: list[ctfy.core.target.AttachmentInfo] = PydanticUndefined
class AuthTokenResponse(ctfy.core.models.CtfyModel):
91class AuthTokenResponse(CtfyModel):
92    """Returned by ``POST /auth/register`` and ``POST /auth/login``.
93
94    The plaintext user token is returned directly (not via fragment) since
95    these endpoints are called by first-party XHR, not redirects.
96    """
97
98    token: str
99    redirect_to: str = "/"

Returned by POST /auth/register and POST /auth/login.

The plaintext user token is returned directly (not via fragment) since these endpoints are called by first-party XHR, not redirects.

token: str = PydanticUndefined
redirect_to: str = '/'
class AwdBoardEntry(ctfy.core.models.CtfyModel):
 84class AwdBoardEntry(CtfyModel):
 85    """One team's standing after one settled round.
 86
 87    The three components are their own columns rather than a single
 88    total (§5.5): a team needs to know whether it is losing on attack or
 89    bleeding on defence, and one number cannot say.
 90    """
 91
 92    rank: int = 0
 93    team_id: str = ""
 94    #: ⚠️ The one field `AwdBoardRow` cannot supply, and the reason this
 95    #: wire model exists at all — a board of uuids is not a board.
 96    team_name: str = ""
 97    total: float = 0.0
 98    attack: float = 0.0
 99    defence: float = 0.0
100    sla: float = 0.0
101    #: service id → SLA status, this round. ⚠️ A service the checker
102    #: never reached is **absent**, not `up`: a missing verdict means
103    #: "not probed", and rendering it as healthy would let a round the
104    #: checker never got to look like a clean sheet.
105    services: dict[str, str] = Field(default_factory=dict)

One team's standing after one settled round.

The three components are their own columns rather than a single total (§5.5): a team needs to know whether it is losing on attack or bleeding on defence, and one number cannot say.

rank: int = 0
team_id: str = ''
team_name: str = ''
total: float = 0.0
attack: float = 0.0
defence: float = 0.0
sla: float = 0.0
services: dict[str, str] = PydanticUndefined
class AwdBoardResponse(ctfy.core.models.CtfyModel):
108class AwdBoardResponse(CtfyModel):
109    """One round's standings.
110
111    ``tick`` is the round actually rendered, which is the **last settled**
112    one unless the caller named another — never the round in progress,
113    which by construction has no scores yet.
114    """
115
116    tick: int = -1
117    rows: list[AwdBoardEntry] = Field(default_factory=list)

One round's standings.

tick is the round actually rendered, which is the last settled one unless the caller named another — never the round in progress, which by construction has no scores yet.

tick: int = -1
rows: list[AwdBoardEntry] = PydanticUndefined
class AwdFlagResult(ctfy.core.models.CtfyModel):
47class AwdFlagResult(CtfyModel):
48    """What happened to one submitted value.
49
50    ``verdict`` is a :class:`ctfy.core.awd.AttackVerdict` value, plus the
51    route-level ``already_captured`` — which is not a judging outcome
52    (``judge_attack`` is pure and cannot know what is on record) but is
53    the answer a client needs to stop resending a flag it banked.
54
55    ⚠️ **Refusals carry no victim.** ``victim_team_id`` feeds a score
56    debit, so naming a team on a rejected submission would take a point
57    off them because somebody else pasted the wrong string.
58    """
59
60    verdict: str = ""
61    victim_team_id: str = ""
62    service_id: str = ""
63    #: The round the *flag* belongs to, which for a grace-window capture
64    #: is not the current one. ``-1`` when nothing was resolved.
65    tick: int = -1

What happened to one submitted value.

verdict is a ctfy.core.awd.AttackVerdict value, plus the route-level already_captured — which is not a judging outcome (judge_attack is pure and cannot know what is on record) but is the answer a client needs to stop resending a flag it banked.

⚠️ Refusals carry no victim. victim_team_id feeds a score debit, so naming a team on a rejected submission would take a point off them because somebody else pasted the wrong string.

verdict: str = ''
victim_team_id: str = ''
service_id: str = ''
tick: int = -1
class AwdFlagSubmitRequest(ctfy.core.models.CtfyModel):
36class AwdFlagSubmitRequest(CtfyModel):
37    """One team's captures, submitted together.
38
39    ⚠️ **In the body, never a query string** — same reasoning as
40    ``POST /admin/answer-trace``: a flag in a URL is a flag in every
41    access log between the player and us.
42    """
43
44    flags: list[str] = Field(min_length=1, max_length=MAX_FLAGS_PER_SUBMISSION)

One team's captures, submitted together.

⚠️ In the body, never a query string — same reasoning as POST /admin/answer-trace: a flag in a URL is a flag in every access log between the player and us.

flags: list[str] = PydanticUndefined
class AwdFlagSubmitResponse(ctfy.core.models.CtfyModel):
68class AwdFlagSubmitResponse(CtfyModel):
69    """One result per submitted value, in the order they were sent.
70
71    Positional rather than keyed by flag: a client may legitimately
72    submit a value it cannot match back (a typo, a truncated read), and
73    a short or reordered array silently misattributes every verdict
74    after the first gap.
75    """
76
77    #: The round the batch was judged in — ``-1`` when the match is not
78    #: in one. It answers "which round am I in" once for the whole
79    #: request, so the per-flag results stay uniform even then.
80    tick: int = -1
81    results: list[AwdFlagResult] = Field(default_factory=list)

One result per submitted value, in the order they were sent.

Positional rather than keyed by flag: a client may legitimately submit a value it cannot match back (a typo, a truncated read), and a short or reordered array silently misattributes every verdict after the first gap.

tick: int = -1
results: list[AwdFlagResult] = PydanticUndefined
class AwdMatchConfig(ctfy.core.models.CtfyModel):
120class AwdMatchConfig(CtfyModel):
121    """What an organiser may set on a match.
122
123    ⚠️ **`last_tick_done` is deliberately absent, and that absence is a
124    control rather than an omission.** It is the tick loop's bookmark —
125    "has this round been settled" — and a body carrying it lets one save
126    rewind the loop, which then re-probes SLA and re-settles rounds the
127    board has already published *and cached as final* (the board's cache
128    keys on the resolved round precisely because a settled round never
129    changes again). The route preserves the stored value; nothing on the
130    wire can move it.
131
132    ``created_at`` is absent for the smaller version of the same reason:
133    it is a fact about the row, not a setting.
134
135    ⚠️ **`extra="forbid"`, so sending one is a 422 rather than a silent
136    drop.** The route preserves the stored bookmark either way, so this
137    buys nothing mechanically — it buys the client being *told*. A
138    caller that believes it can rewind the loop and gets a 200 goes on
139    believing it, and the next thing it writes may not be against a
140    model that ignores the field.
141    """
142
143    model_config = ConfigDict(extra="forbid")
144
145    #: The gamebox every team runs. Allowed empty — a match under
146    #: construction is a real state, and `AwdMatchState` says so — but
147    #: `plan_provision` refuses without one, which is where an organiser
148    #: hears about it while they can still act.
149    challenge_id: str = ""
150    #: When round 0 opens. ``None`` means "not scheduled yet", which is
151    #: what makes the whole configuration editable.
152    starts_at: AwareDatetime | None = None
153    #: ⚠️ Both floors are 1, not 0. `is_awd_match` reads a zero on
154    #: either as "not an AWD match", so storing one creates a row that
155    #: every other surface then denies exists — a match an organiser can
156    #: see in the form and nowhere else.
157    tick_seconds: int = Field(ge=1)
158    tick_count: int = Field(ge=1)
159    #: §16.10 — two independent caps, never a mode enum. Both stay
160    #: editable mid-match: `tick_budget_for` is read per round and is
161    #: purely forward-looking, and relaxing the allowance is exactly
162    #: what an organiser does after a bad round eats the field's.
163    per_tick_budget: int | None = Field(default=None, ge=0)
164    total_budget: int | None = Field(default=None, ge=0)

What an organiser may set on a match.

⚠️ last_tick_done is deliberately absent, and that absence is a control rather than an omission. It is the tick loop's bookmark — "has this round been settled" — and a body carrying it lets one save rewind the loop, which then re-probes SLA and re-settles rounds the board has already published and cached as final (the board's cache keys on the resolved round precisely because a settled round never changes again). The route preserves the stored value; nothing on the wire can move it.

created_at is absent for the smaller version of the same reason: it is a fact about the row, not a setting.

⚠️ extra="forbid", so sending one is a 422 rather than a silent drop. The route preserves the stored bookmark either way, so this buys nothing mechanically — it buys the client being told. A caller that believes it can rewind the loop and gets a 200 goes on believing it, and the next thing it writes may not be against a model that ignores the field.

challenge_id: str = ''
starts_at: pydantic.types.AwareDatetime | None = None
tick_seconds: int = PydanticUndefined
tick_count: int = PydanticUndefined
per_tick_budget: int | None = None
total_budget: int | None = None
class AwdMatchInfo(ctfy.server.models.AwdMatchConfig):
167class AwdMatchInfo(AwdMatchConfig):
168    """A match as an organiser reads it: the settings plus the clock.
169
170    The three added fields are all **read-only by construction** —
171    ``current_tick`` is derived from the wall clock on every read (see
172    `ctfy.core.awd.current_tick`) and the other two are the loop's own
173    record. Extending the request model rather than declaring a second
174    one keeps the shared half from drifting between what a form submits
175    and what it renders back.
176    """
177
178    competition_id: str = ""
179    #: The round in progress, or ``None`` before the first and after the
180    #: last. Never stored, never settable.
181    current_tick: int | None = None
182    #: The last round settled. ``-1`` means none has been, which is
183    #: distinct from round 0 having completed.
184    last_tick_done: int = -1
185    #: ⚠️ Not ``current_tick is not None`` — that goes back to ``None``
186    #: when the match ends, and a finished match is still started for
187    #: every purpose the organiser's form cares about.
188    started: bool = False
189    created_at: AwareDatetime | None = None
190    #: ⚠️ Whether the ``awd_tick`` scheduled job is switched on. It ships
191    #: **disabled** — deliberately, since it starts containers on worker
192    #: nodes and a match still being configured must not begin turning on
193    #: its own — so the default state of a freshly created match is
194    #: "everything looks configured and no round will ever run".
195    #:
196    #: It is on the *match* rather than left to the client reading
197    #: ``GET /admin/scheduled-jobs``, because that route is
198    #: ``require_admin`` while this page is ``require_competition_admin``:
199    #: a per-competition organiser — exactly the person running the
200    #: event — would get a 403 and see no warning at all.
201    tick_job_enabled: bool = False

A match as an organiser reads it: the settings plus the clock.

The three added fields are all read-only by constructioncurrent_tick is derived from the wall clock on every read (see ctfy.core.awd.current_tick) and the other two are the loop's own record. Extending the request model rather than declaring a second one keeps the shared half from drifting between what a form submits and what it renders back.

competition_id: str = ''
current_tick: int | None = None
last_tick_done: int = -1
started: bool = False
created_at: pydantic.types.AwareDatetime | None = None
tick_job_enabled: bool = False
class AwdMatchStatus(ctfy.core.models.CtfyModel):
226class AwdMatchStatus(CtfyModel):
227    """The match's clock and rules, as a player reads them.
228
229    Public, like the board: the cadence *is* the format's rules, and a
230    team that cannot see which round it is in cannot play. Deliberately
231    a different model from `AwdMatchInfo` — that one is the organiser's
232    form and carries nothing a player needs, while this one carries
233    ``tick_ends_at``, which the form does not.
234
235    ⚠️ **`tick_ends_at` is on the wire rather than derived client-side.**
236    A countdown computed in the browser from ``starts_at`` +
237    ``tick_seconds`` is a second implementation of the round arithmetic
238    `ctfy.core.awd` exists to keep in one place — and the one that would
239    disagree is the one every player is watching.
240    """
241
242    competition_id: str = ""
243    challenge_id: str = ""
244    starts_at: AwareDatetime | None = None
245    tick_seconds: int = 0
246    tick_count: int = 0
247    #: The round in progress, or ``None`` before the first and after the
248    #: last. Never stored; recomputed on every read.
249    current_tick: int | None = None
250    #: When the round in progress ends. ``None`` when none is.
251    tick_ends_at: AwareDatetime | None = None
252    #: The last round settled — the one the board is showing. ``-1``
253    #: means none has been, which is not the same as round 0.
254    last_tick_done: int = -1
255    per_tick_budget: int | None = None
256    total_budget: int | None = None

The match's clock and rules, as a player reads them.

Public, like the board: the cadence is the format's rules, and a team that cannot see which round it is in cannot play. Deliberately a different model from AwdMatchInfo — that one is the organiser's form and carries nothing a player needs, while this one carries tick_ends_at, which the form does not.

⚠️ tick_ends_at is on the wire rather than derived client-side. A countdown computed in the browser from starts_at + tick_seconds is a second implementation of the round arithmetic ctfy.core.awd exists to keep in one place — and the one that would disagree is the one every player is watching.

competition_id: str = ''
challenge_id: str = ''
starts_at: pydantic.types.AwareDatetime | None = None
tick_seconds: int = 0
tick_count: int = 0
current_tick: int | None = None
tick_ends_at: pydantic.types.AwareDatetime | None = None
last_tick_done: int = -1
per_tick_budget: int | None = None
total_budget: int | None = None
class AwdProvisionPlanInfo(ctfy.core.models.CtfyModel):
204class AwdProvisionPlanInfo(CtfyModel):
205    """What opening the arena would do, before it does it.
206
207    A read, deliberately separate from the launch: §4.5 fixes the box
208    supply at kickoff and admits no late teams, so an organiser wants to
209    see "12 teams, 10 need a box" *before* committing — and the refusals
210    (no gamebox, no capacity, no healthy node) are exactly the ones that
211    must land where they can still be acted on.
212    """
213
214    #: Teams still needing a box, in a stable order — a fleet launch is
215    #: chunked and retried, and an unstable order makes "which teams did
216    #: the last run reach" unanswerable.
217    to_start: list[str] = Field(default_factory=list)
218    #: Teams already holding one. Reported rather than dropped so a
219    #: resumed run can say what it skipped.
220    already: list[str] = Field(default_factory=list)
221    #: Every team in the match — the capacity figure §5.1 wants at build
222    #: time, not at kickoff.
223    total: int = 0

What opening the arena would do, before it does it.

A read, deliberately separate from the launch: §4.5 fixes the box supply at kickoff and admits no late teams, so an organiser wants to see "12 teams, 10 need a box" before committing — and the refusals (no gamebox, no capacity, no healthy node) are exactly the ones that must land where they can still be acted on.

to_start: list[str] = PydanticUndefined
already: list[str] = PydanticUndefined
total: int = 0
BigLimitOffsetPage = <class 'ctfy.server.models.pagination.LimitOffsetPageCustomized'>
class BulkInviteRequest(ctfy.core.models.CtfyModel):
468class BulkInviteRequest(CtfyModel):
469    """Body for ``POST /admin/competitions/{id}/invites/bulk``.
470
471    Organisers arrive with a qualifying list — a column pasted out of a
472    spreadsheet, newline- or comma-separated, usually with stray spaces
473    and the odd duplicate. Splitting it server-side keeps every client
474    (web, CLI, SDK) from having to agree on the same rules.
475    """
476
477    # One blob rather than a parsed list: a textarea is what the organiser
478    # actually has, and asking the client to split it invites three
479    # slightly different splitters.
480    emails: str = Field(default="", max_length=200_000)

Body for POST /admin/competitions/{id}/invites/bulk.

Organisers arrive with a qualifying list — a column pasted out of a spreadsheet, newline- or comma-separated, usually with stray spaces and the odd duplicate. Splitting it server-side keeps every client (web, CLI, SDK) from having to agree on the same rules.

emails: str = ''
class BulkInviteResponse(ctfy.core.models.CtfyModel):
496class BulkInviteResponse(CtfyModel):
497    """Per-address outcomes plus the tallies the toast reports.
498
499    Every address is echoed back rather than only the failures: an
500    organiser pasting 300 lines needs to see that 300 were understood,
501    and a silent drop is indistinguishable from success.
502    """
503
504    results: list[BulkInviteResultRow] = Field(default_factory=list)
505    invited: int = 0
506    pending: int = 0
507    already: int = 0
508    invalid: int = 0

Per-address outcomes plus the tallies the toast reports.

Every address is echoed back rather than only the failures: an organiser pasting 300 lines needs to see that 300 were understood, and a silent drop is indistinguishable from success.

results: list[BulkInviteResultRow] = PydanticUndefined
invited: int = 0
pending: int = 0
already: int = 0
invalid: int = 0
class BulkInviteResultRow(ctfy.core.models.CtfyModel):
483class BulkInviteResultRow(CtfyModel):
484    """What happened to one address. ``status`` is one of:
485
486    ``invited`` — a new allowlist row (the user existed);
487    ``pending`` — recorded against the address, waiting for a sign-up;
488    ``already`` — invited before, so nothing changed;
489    ``invalid`` — not an email address, skipped.
490    """
491
492    email: str
493    status: str

What happened to one address. status is one of:

invited — a new allowlist row (the user existed); pending — recorded against the address, waiting for a sign-up; already — invited before, so nothing changed; invalid — not an email address, skipped.

email: str = PydanticUndefined
status: str = PydanticUndefined
class CalendarBucket(ctfy.core.models.CtfyModel):
146class CalendarBucket(CtfyModel):
147    """One UTC-day cell on the contribution calendar.
148
149    ``count`` aggregates correct submissions + first-time solves on that
150    day; effectively "did anything productive happen". The series is
151    dense — every day in the requested window is present, zero-filled."""
152
153    date: str = ""  # YYYY-MM-DD, UTC
154    count: int = 0

One UTC-day cell on the contribution calendar.

count aggregates correct submissions + first-time solves on that day; effectively "did anything productive happen". The series is dense — every day in the requested window is present, zero-filled.

date: str = ''
count: int = 0
class ChallengeBuildKickoffNodeResult(ctfy.core.models.CtfyModel):
285class ChallengeBuildKickoffNodeResult(CtfyModel):
286    """One node's response to a build kickoff (single or all).
287
288    ``queued`` is populated only on the ``build-all`` fan-out; for the
289    single-challenge variant the platform inspects ``status`` to learn
290    what the node accepted.
291    """
292
293    node_id: str
294    ok: bool
295    status: str = ""
296    queued: list[str] = Field(default_factory=list)
297    skipped_built: list[str] = Field(default_factory=list)
298    skipped_in_progress: list[str] = Field(default_factory=list)
299    error: str = ""

One node's response to a build kickoff (single or all).

queued is populated only on the build-all fan-out; for the single-challenge variant the platform inspects status to learn what the node accepted.

node_id: str = PydanticUndefined
ok: bool = PydanticUndefined
status: str = ''
queued: list[str] = PydanticUndefined
skipped_built: list[str] = PydanticUndefined
skipped_in_progress: list[str] = PydanticUndefined
error: str = ''
class ChallengeBuildKickoffResponse(ctfy.core.models.CtfyModel):
302class ChallengeBuildKickoffResponse(CtfyModel):
303    """Admin ``POST /admin/challenges/{id}/build`` and ``…/build-all`` response."""
304
305    challenge_id: str = ""  # empty for build-all
306    nodes: list[ChallengeBuildKickoffNodeResult] = Field(default_factory=list)

Admin POST /admin/challenges/{id}/build and …/build-all response.

challenge_id: str = ''
nodes: list[ChallengeBuildKickoffNodeResult] = PydanticUndefined
class ChallengeBuildNodeState(ctfy.core.models.CtfyModel):
242class ChallengeBuildNodeState(CtfyModel):
243    """One worker node's view of a single challenge's build state.
244
245    ``status`` is one of ``unbuilt`` / ``building`` / ``built`` /
246    ``failed``. ``node_error`` is non-empty only when the platform
247    could not reach the node at all (heartbeat stale / 5xx) — in that
248    case ``status`` is forced to ``unbuilt`` for the aggregate.
249    """
250
251    node_id: str
252    status: str = "unbuilt"
253    built_at: float = 0.0
254    error: str = ""
255    node_error: str = ""

One worker node's view of a single challenge's build state.

status is one of unbuilt / building / built / failed. node_error is non-empty only when the platform could not reach the node at all (heartbeat stale / 5xx) — in that case status is forced to unbuilt for the aggregate.

node_id: str = PydanticUndefined
status: str = 'unbuilt'
built_at: float = 0.0
error: str = ''
node_error: str = ''
class ChallengeBuildStateResponse(ctfy.core.models.CtfyModel):
279class ChallengeBuildStateResponse(CtfyModel):
280    """Admin ``GET /admin/challenges/build-state`` response."""
281
282    rows: list[ChallengeBuildStateRow] = Field(default_factory=list)

Admin GET /admin/challenges/build-state response.

rows: list[ChallengeBuildStateRow] = PydanticUndefined
class ChallengeBuildStateRow(ctfy.core.models.CtfyModel):
258class ChallengeBuildStateRow(CtfyModel):
259    """Per-challenge build state aggregated across every online node.
260
261    ``aggregated`` rolls up ``nodes`` using a worst-case rule so the
262    table's single status column behaves intuitively:
263
264    * any ``building`` → ``building`` (yellow)
265    * any ``failed``  → ``failed`` (red)
266    * any ``unbuilt`` → ``unbuilt`` (grey)
267    * else            → ``built`` (green)
268
269    A node that couldn't be reached at all contributes ``unbuilt`` so
270    the aggregate stays conservative — the admin still sees a
271    "missing" pip and can drill into the modal to find out why.
272    """
273
274    challenge_id: str
275    aggregated: str = "unbuilt"
276    nodes: list[ChallengeBuildNodeState] = Field(default_factory=list)

Per-challenge build state aggregated across every online node.

aggregated rolls up nodes using a worst-case rule so the table's single status column behaves intuitively:

  • any buildingbuilding (yellow)
  • any failedfailed (red)
  • any unbuiltunbuilt (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.

challenge_id: str = PydanticUndefined
aggregated: str = 'unbuilt'
nodes: list[ChallengeBuildNodeState] = PydanticUndefined
class ChallengeFacetCount(ctfy.core.models.CtfyModel):
158class ChallengeFacetCount(CtfyModel):
159    """One bucket on the challenge catalog facet summary.
160
161    ``value`` is the difficulty / tag string; ``total`` is how many
162    challenges in scope carry it. ``solved`` is the *calling* user's
163    solved count in that bucket (across every team they have been on);
164    it is 0 for anonymous callers and for tag buckets (the UI only
165    renders a solved/total ratio for difficulty).
166    """
167
168    value: str
169    total: int = 0
170    solved: int = 0

One bucket on the challenge catalog facet summary.

value is the difficulty / tag string; total is how many challenges in scope carry it. solved is the calling user's solved count in that bucket (across every team they have been on); it is 0 for anonymous callers and for tag buckets (the UI only renders a solved/total ratio for difficulty).

value: str = PydanticUndefined
total: int = 0
solved: int = 0
class ChallengeFacets(ctfy.core.models.CtfyModel):
173class ChallengeFacets(CtfyModel):
174    """Catalog aggregates for the Challenges page side panels + filter
175    pill counts — served by ``GET /challenges/facets`` so the page no
176    longer derives them from a bulk fetch of the whole catalog.
177
178    Scoped by ``competition_id`` (when given) exactly like the list
179    endpoint, so the pills/charts reflect the same subset the paged
180    list pages through.
181    """
182
183    total: int = 0
184    difficulty: list[ChallengeFacetCount] = Field(default_factory=list)
185    # Per-category counts (only categories with ≥1 challenge in scope
186    # show up). Ordered by the canonical category sequence so the
187    # filter pills always render web → pwn → reverse → crypto → misc.
188    category: list[ChallengeFacetCount] = Field(default_factory=list)
189    tags: list[ChallengeFacetCount] = Field(default_factory=list)
190    # Per-status counts for the calling caller (``solved`` / ``unsolved``
191    # / ``running``). Drives the count chip on each status filter pill
192    # so the page doesn't N+1 a status-filtered list per pill. Buckets
193    # are always present (in canonical order) for layout stability; on
194    # anonymous callers every count is 0 — never leaking another
195    # team's state.
196    status: list[ChallengeFacetCount] = Field(default_factory=list)
197    # Per-source-bucket counts — the prefix of a challenge id
198    # (``CVE-2021-1234`` → ``CVE``), i.e. the benchmark suite an item
199    # was ingested from. Ordered by count descending then id, so a
200    # caller rendering a top-N breakdown gets a stable, meaningful
201    # slice order without re-sorting. ``solved`` is always 0 here:
202    # this facet answers "what is the catalog made of", not "how am I
203    # doing", and the landing page that consumes it is anonymous.
204    buckets: list[ChallengeFacetCount] = Field(default_factory=list)

Catalog aggregates for the Challenges page side panels + filter pill counts — served by GET /challenges/facets so the page no longer derives them from a bulk fetch of the whole catalog.

Scoped by competition_id (when given) exactly like the list endpoint, so the pills/charts reflect the same subset the paged list pages through.

total: int = 0
difficulty: list[ChallengeFacetCount] = PydanticUndefined
category: list[ChallengeFacetCount] = PydanticUndefined
tags: list[ChallengeFacetCount] = PydanticUndefined
status: list[ChallengeFacetCount] = PydanticUndefined
buckets: list[ChallengeFacetCount] = PydanticUndefined
class ChallengeFlagStats(ctfy.core.models.CtfyModel):
374class ChallengeFlagStats(CtfyModel):
375    """Per-flag aggregate for one challenge (for the detail page)."""
376
377    flag_id: str
378    solves_count: int = 0

Per-flag aggregate for one challenge (for the detail page).

flag_id: str = PydanticUndefined
solves_count: int = 0
class ChallengeInfo(ctfy.core.models.CtfyModel):
101class ChallengeInfo(CtfyModel):
102    id: str
103    name: str
104    category: ChallengeCategory = ChallengeCategory.WEB
105    difficulty: str = ""
106    description: str = ""
107    # Full-challenge solves: teams that captured every declared
108    # question (across all modes — dynamic + static + select).
109    solves_count: int = 0
110    # Times this challenge has been instantiated, ever — one per
111    # archived InstanceRecord (every terminal path writes one). A
112    # "how much has this been run" signal for the challenge card;
113    # platform-wide, not scoped to the viewing team or competition.
114    launch_count: int = 0
115    tags: list[str] = Field(default_factory=list)
116    # Every question declared on this challenge, in author-declared
117    # order. Single-question challenges have a single entry with
118    # id ``"flag"``. ``QuestionPublicInfo`` exposes prompt/mode/choices
119    # but never the answer.
120    questions: list[QuestionPublicInfo] = Field(default_factory=list)
121    # Per-question solve counts (how many teams captured each
122    # question). Keyed by question id. Useful for the challenge
123    # detail page's progress breakdown. Counts span every mode;
124    # the multi_select grader records one solve per fully-correct
125    # submission, not per chosen choice.
126    flag_solves: dict[str, int] = Field(default_factory=dict)
127    # Files shipped under the challenge's ``attachments/`` directory,
128    # downloadable via ``GET /challenges/{id}/attachments/{name}``.
129    # Empty for pure-network challenges that ship nothing.
130    attachments: list[AttachmentInfo] = Field(default_factory=list)
131    # True for pure question-answer challenges (``category: misc``,
132    # no ``docker-compose.yml`` on disk, every declared question
133    # carries a static ``answer:``). The frontend keys off this flag
134    # to switch the competition surface to a quiz UI and skip the
135    # launch-confirm flow. Source of truth lives in
136    # :attr:`ctfy.core.challenge.ChallengeSpec.is_qa_only`, ultimately
137    # derived from :attr:`BenchmarkContext.is_qa_only`.
138    is_qa_only: bool = False
139    # Set for AWD+ challenges; ``None`` for every other kind. Same role
140    # as ``is_qa_only`` one field up — the flag a client keys off to
141    # switch surface — but an object rather than a bool because a
142    # defence surface needs to know *what* it may send, not only that it
143    # may send something.
144    defence: DefenceInfo | None = None

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

id: str = PydanticUndefined
name: str = PydanticUndefined
category: ctfy.challenge.benchmark.ChallengeCategory = <ChallengeCategory.WEB: 'web'>
difficulty: str = ''
description: str = ''
solves_count: int = 0
launch_count: int = 0
tags: list[str] = PydanticUndefined
questions: list[QuestionPublicInfo] = PydanticUndefined
flag_solves: dict[str, int] = PydanticUndefined
attachments: list[ctfy.core.target.AttachmentInfo] = PydanticUndefined
is_qa_only: bool = False
defence: DefenceInfo | None = None
class ChallengePullKickoffNodeResult(ctfy.core.models.CtfyModel):
355class ChallengePullKickoffNodeResult(CtfyModel):
356    """One node's response to a pull kickoff (single or all)."""
357
358    node_id: str
359    ok: bool
360    status: str = ""
361    queued: list[str] = Field(default_factory=list)
362    skipped_pulled: list[str] = Field(default_factory=list)
363    skipped_in_progress: list[str] = Field(default_factory=list)
364    error: str = ""

One node's response to a pull kickoff (single or all).

node_id: str = PydanticUndefined
ok: bool = PydanticUndefined
status: str = ''
queued: list[str] = PydanticUndefined
skipped_pulled: list[str] = PydanticUndefined
skipped_in_progress: list[str] = PydanticUndefined
error: str = ''
class ChallengePullKickoffResponse(ctfy.core.models.CtfyModel):
367class ChallengePullKickoffResponse(CtfyModel):
368    """Admin ``POST /admin/challenges/{id}/pull`` and ``…/pull-all`` response."""
369
370    challenge_id: str = ""  # empty for pull-all
371    nodes: list[ChallengePullKickoffNodeResult] = Field(default_factory=list)

Admin POST /admin/challenges/{id}/pull and …/pull-all response.

challenge_id: str = ''
nodes: list[ChallengePullKickoffNodeResult] = PydanticUndefined
class ChallengePullNodeState(ctfy.core.models.CtfyModel):
316class ChallengePullNodeState(CtfyModel):
317    """One worker node's view of a single challenge's pull state.
318
319    ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` /
320    ``failed``. ``node_error`` is non-empty only when the platform could
321    not reach the node at all — ``status`` is then forced to ``unpulled``
322    for the aggregate.
323    """
324
325    node_id: str
326    status: str = "unpulled"
327    pulled_at: float = 0.0
328    error: str = ""
329    node_error: str = ""

One worker node's view of a single challenge's pull state.

status is one of unpulled / pulling / pulled / failed. node_error is non-empty only when the platform could not reach the node at all — status is then forced to unpulled for the aggregate.

node_id: str = PydanticUndefined
status: str = 'unpulled'
pulled_at: float = 0.0
error: str = ''
node_error: str = ''
class ChallengePullStateResponse(ctfy.core.models.CtfyModel):
349class ChallengePullStateResponse(CtfyModel):
350    """Admin ``GET /admin/challenges/pull-state`` response."""
351
352    rows: list[ChallengePullStateRow] = Field(default_factory=list)

Admin GET /admin/challenges/pull-state response.

rows: list[ChallengePullStateRow] = PydanticUndefined
class ChallengePullStateRow(ctfy.core.models.CtfyModel):
332class ChallengePullStateRow(CtfyModel):
333    """Per-challenge pull state aggregated across every online node.
334
335    ``aggregated`` rolls up ``nodes`` worst-case, mirroring the build
336    row:
337
338    * any ``pulling`` → ``pulling`` (yellow)
339    * any ``failed``  → ``failed`` (red)
340    * any ``unpulled`` → ``unpulled`` (grey)
341    * else            → ``pulled`` (green)
342    """
343
344    challenge_id: str
345    aggregated: str = "unpulled"
346    nodes: list[ChallengePullNodeState] = Field(default_factory=list)

Per-challenge pull state aggregated across every online node.

aggregated rolls up nodes worst-case, mirroring the build row:

  • any pullingpulling (yellow)
  • any failedfailed (red)
  • any unpulledunpulled (grey)
  • else → pulled (green)
challenge_id: str = PydanticUndefined
aggregated: str = 'unpulled'
nodes: list[ChallengePullNodeState] = PydanticUndefined
class ChallengeRescanNodeResult(ctfy.core.models.CtfyModel):
207class ChallengeRescanNodeResult(CtfyModel):
208    """Outcome of fanning the rescan out to one worker node.
209
210    ``ok`` is False for an offline node (skipped, never contacted) or a
211    node that errored; ``total`` is the node's post-rescan challenge
212    count when reached, else ``None``. One unreachable node never fails
213    the whole operation — the platform rescan still stands.
214    """
215
216    node_id: str
217    ok: bool
218    total: int | None = None
219    error: str | None = None

Outcome of fanning the rescan out to one worker node.

ok is False for an offline node (skipped, never contacted) or a node that errored; total is the node's post-rescan challenge count when reached, else None. One unreachable node never fails the whole operation — the platform rescan still stands.

node_id: str = PydanticUndefined
ok: bool = PydanticUndefined
total: int | None = None
error: str | None = None
class ChallengeRescanResult(ctfy.core.models.CtfyModel):
222class ChallengeRescanResult(CtfyModel):
223    """Admin ``POST /admin/challenges/rescan`` response.
224
225    ``total`` / ``added`` / ``removed`` describe the platform's own
226    catalog after re-scanning ``challenges_dir``; ``nodes`` carries the
227    per-worker fan-out outcome so a newly added challenge is confirmed
228    launchable, not just listable.
229    """
230
231    total: int
232    added: list[str] = Field(default_factory=list)
233    removed: list[str] = Field(default_factory=list)
234    nodes: list[ChallengeRescanNodeResult] = Field(default_factory=list)

Admin POST /admin/challenges/rescan response.

total / added / removed describe the platform's own catalog after re-scanning challenges_dir; nodes carries the per-worker fan-out outcome so a newly added challenge is confirmed launchable, not just listable.

total: int = PydanticUndefined
added: list[str] = PydanticUndefined
removed: list[str] = PydanticUndefined
nodes: list[ChallengeRescanNodeResult] = PydanticUndefined
class ChallengeSolveAttempt(ctfy.core.models.CtfyModel):
395class ChallengeSolveAttempt(CtfyModel):
396    """One archived instance for a challenge — used to visualise per-team
397    multi-solve attempts on the challenge detail page."""
398
399    instance_id: str
400    team_id: str = ""
401    team_name: str = ""
402    display_name: str = ""
403    challenge_id: str = ""
404    started_at: float = 0.0
405    stopped_at: float = 0.0
406    duration_s: float = 0.0
407    attempts: int = 0
408    solved: bool = False
409    solved_flags: list[str] = Field(default_factory=list)
410    stop_reason: str = ""

One archived instance for a challenge — used to visualise per-team multi-solve attempts on the challenge detail page.

instance_id: str = PydanticUndefined
team_id: str = ''
team_name: str = ''
display_name: str = ''
challenge_id: str = ''
started_at: float = 0.0
stopped_at: float = 0.0
duration_s: float = 0.0
attempts: int = 0
solved: bool = False
solved_flags: list[str] = PydanticUndefined
stop_reason: str = ''
class ChallengeSolveAttemptsResponse(ctfy.core.models.CtfyModel):
427class ChallengeSolveAttemptsResponse(CtfyModel):
428    challenge_id: str
429    attempts: list[ChallengeSolveAttempt] = Field(default_factory=list)
430    per_team: list[ChallengeTeamSolveSummary] = Field(default_factory=list)
431    # Total archived instances (incl. unsolved) — lets the UI show
432    # "showing N solved of M total" when the response is filtered.
433    total_attempts: int = 0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

challenge_id: str = PydanticUndefined
attempts: list[ChallengeSolveAttempt] = PydanticUndefined
per_team: list[ChallengeTeamSolveSummary] = PydanticUndefined
total_attempts: int = 0
class ChallengeStats(ctfy.core.models.CtfyModel):
381class ChallengeStats(CtfyModel):
382    challenge_id: str
383    name: str = ""
384    category: ChallengeCategory | None = None
385    difficulty: str = ""
386    # Teams that captured every declared flag on this challenge.
387    solves_count: int = 0
388    total_attempts: int = 0
389    success_rate: float = 0.0
390    # Per-flag breakdown. Single-flag challenges have a single entry with
391    # ``flag_id == "flag"`` and ``solves_count == solves_count`` above.
392    flag_stats: list[ChallengeFlagStats] = Field(default_factory=list)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

challenge_id: str = PydanticUndefined
name: str = ''
category: ctfy.challenge.benchmark.ChallengeCategory | None = None
difficulty: str = ''
solves_count: int = 0
total_attempts: int = 0
success_rate: float = 0.0
flag_stats: list[ChallengeFlagStats] = PydanticUndefined
class ChallengeTeamSolveSummary(ctfy.core.models.CtfyModel):
413class ChallengeTeamSolveSummary(CtfyModel):
414    """Per-team aggregate for the challenge detail solve-attempts panel."""
415
416    team_id: str
417    team_name: str = ""
418    display_name: str = ""
419    solve_count: int = 0
420    attempt_count: int = 0
421    best_duration_s: float = 0.0
422    total_duration_s: float = 0.0
423    first_solved_at: float = 0.0
424    last_solved_at: float = 0.0

Per-team aggregate for the challenge detail solve-attempts panel.

team_id: str = PydanticUndefined
team_name: str = ''
display_name: str = ''
solve_count: int = 0
attempt_count: int = 0
best_duration_s: float = 0.0
total_duration_s: float = 0.0
first_solved_at: float = 0.0
last_solved_at: float = 0.0
class CheckAnswerRequest(ctfy.core.models.CtfyModel):
319class CheckAnswerRequest(CtfyModel):
320    """Run the checker for one *checker-mode* question on an instance.
321
322    Proof-of-exploitation: the platform execs the question's
323    author-supplied checker inside the trusted judge sidecar; the player
324    submits no answer string — the sidecar emits the minted token only
325    when the exploit's effect is verified.
326    """
327
328    # Which checker question to verify. Checker questions usually carry a
329    # descriptive id (``"deface"`` / ``"persist"`` / …); defaults to
330    # ``"flag"`` for the single-question shape.
331    question_id: str = "flag"

Run the checker for one checker-mode question on an instance.

Proof-of-exploitation: the platform execs the question's author-supplied checker inside the trusted judge sidecar; the player submits no answer string — the sidecar emits the minted token only when the exploit's effect is verified.

question_id: str = 'flag'
class CheckAnswerResponse(ctfy.core.models.CtfyModel):
334class CheckAnswerResponse(CtfyModel):
335    # True when the checker confirmed the exploit (its stdout matched the
336    # instance's minted token for this question) and a solve was recorded.
337    passed: bool
338    # Echoes the question id on a pass; ``None`` otherwise.
339    question_id: str | None = None
340    # Checker process exit code — diagnostic only; the token match is the
341    # real signal. ``None`` when the checker couldn't be run at all.
342    exit_code: int | None = None
343    # True when the checker exceeded its wall-clock budget.
344    timed_out: bool = False
345    # True when the team had already solved this question (short-circuit,
346    # no node call made).
347    already_solved: bool = False
348    # First-blood rank on a fresh solve (1/2/3 = 1st/2nd/3rd blood), 0
349    # otherwise. Mirrors ``SubmissionResponse.solve_rank``.
350    solve_rank: int = 0
351    # True when this check completed the challenge (every question solved).
352    challenge_fully_solved: bool = False
353    # Server clock (epoch seconds) when the check ran, for the UI's
354    # "last checked" line.
355    last_checked_at: float = 0.0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

passed: bool = PydanticUndefined
question_id: str | None = None
exit_code: int | None = None
timed_out: bool = False
already_solved: bool = False
solve_rank: int = 0
challenge_fully_solved: bool = False
last_checked_at: float = 0.0
class ClusterInfo(ctfy.core.models.CtfyModel):
152class ClusterInfo(CtfyModel):
153    """Returned by ``GET /cluster-info``.
154
155    Provides the bits the ``ctfy-admin server invite`` CLI and admin UI wizard
156    need to build a runnable join command. The *registration token* is
157    minted separately via ``POST /nodes/invites`` — it isn't on this
158    payload because cluster info is queried speculatively by the UI on
159    every admin dashboard load, and we never want to hand an invite
160    token to a page that didn't explicitly ask for one.
161    """
162
163    platform_url: str
164    node_image: str

Returned by GET /cluster-info.

Provides the bits the ctfy-admin server invite CLI and admin UI wizard need to build a runnable join command. The registration token is minted separately via POST /nodes/invites — it isn't on this payload because cluster info is queried speculatively by the UI on every admin dashboard load, and we never want to hand an invite token to a page that didn't explicitly ask for one.

platform_url: str = PydanticUndefined
node_image: str = PydanticUndefined
class CompetitionAdminInfo(ctfy.core.models.CtfyModel):
425class CompetitionAdminInfo(CtfyModel):
426    """One per-competition admin grant, for the super-admin management
427    card on the competition detail page."""
428
429    user_id: str
430    display_name: str = ""
431    email: str = ""
432    role: CompetitionGrantRole = "admin"
433    granted_by: str = ""
434    granted_at: datetime | None = None

One per-competition admin grant, for the super-admin management card on the competition detail page.

user_id: str = PydanticUndefined
display_name: str = ''
email: str = ''
role: Literal['admin', 'reviewer'] = 'admin'
granted_by: str = ''
granted_at: datetime.datetime | None = None
class CompetitionChallengeBreakdown(ctfy.core.models.CtfyModel):
709class CompetitionChallengeBreakdown(CtfyModel):
710    challenges: list[CompetitionChallengeRow] = Field(default_factory=list)
711    generated_at: float = 0.0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

challenges: list[CompetitionChallengeRow] = PydanticUndefined
generated_at: float = 0.0
class CompetitionChallengeRow(ctfy.core.models.CtfyModel):
687class CompetitionChallengeRow(CtfyModel):
688    """Per-challenge competition aggregate: solve count, attempt
689    success rate, and first blood. Reconstructed from the solve log
690    with the same window / team filter as the scoreboard so the
691    numbers always agree with the leaderboard."""
692
693    challenge_id: str
694    name: str = ""
695    category: ChallengeCategory | None = None
696    difficulty: str = ""
697    # Teams that captured every declared flag (matches scoreboard's
698    # ``solved`` tally).
699    solves_count: int = 0
700    # In-window submissions for this challenge across registered teams.
701    attempts: int = 0
702    success_rate: float = 0.0
703    # First team to solve this challenge in-window. Empty when unsolved.
704    first_blood_team_id: str = ""
705    first_blood_team_name: str = ""
706    first_blood_at: datetime | None = None

Per-challenge competition aggregate: solve count, attempt success rate, and first blood. Reconstructed from the solve log with the same window / team filter as the scoreboard so the numbers always agree with the leaderboard.

challenge_id: str = PydanticUndefined
name: str = ''
category: ctfy.challenge.benchmark.ChallengeCategory | None = None
difficulty: str = ''
solves_count: int = 0
attempts: int = 0
success_rate: float = 0.0
first_blood_team_id: str = ''
first_blood_team_name: str = ''
first_blood_at: datetime.datetime | None = None
class CompetitionCreate(ctfy.core.models.CtfyModel):
111class CompetitionCreate(CtfyModel):
112    """Request body for ``POST /admin/competitions``.
113
114    Time fields accept ISO 8601 strings; empty means "no bound" (live
115    immediately / never expires). ``challenge_ids`` is validated against
116    the spec registry inside the route handler — unknown ids return 422.
117    """
118
119    title: str = Field(min_length=1, max_length=200)
120    description: str = Field(default="", max_length=20000)
121    starts_at: datetime | None = None
122    ends_at: datetime | None = None
123    # No fixed length cap: the real bound is ``_check_challenge_ids`` in
124    # ``admin_competitions`` (every id must be a known spec, so a valid list
125    # can never exceed the catalog). A magic number here is arbitrary and a
126    # recurring foot-gun as the corpus grows — bucket comps already exceed 1000.
127    challenge_ids: list[str] = Field(default_factory=list)
128    # New competitions start hidden ("draft") so admins can prepare them
129    # privately and publish when ready.
130    status: Literal["draft", "published", "archived"] = "draft"
131    # Participation access (orthogonal to ``status``). ``public`` is open
132    # to everyone; ``private_listed`` / ``private_hidden`` require an admin
133    # invite to register (the latter also hides the comp from non-invited
134    # users). Defaults to ``public`` so ad-hoc API callers stay open.
135    access: Literal["public", "private_listed", "private_hidden"] = "public"
136    # Marks this competition as an operator-curated eval **standard set** —
137    # only such a competition may back an ``official`` EvalCampaign
138    # (docs/model-eval-vendor-tenancy.md §4.4). ``corpus_sha`` optionally pins
139    # the benchmark-corpus snapshot it was certified against. Off by default.
140    eval_standard: bool = False
141    corpus_sha: str = ""
142    # Which rule ranks this competition's board, and its tuning. Empty
143    # selects the platform default (``flag_count``), so an API caller
144    # that says nothing gets exactly the historic behaviour. Set it here
145    # rather than later where you can: the rule freezes once the
146    # competition goes live (see ``scoring_locked``).
147    scoring_rule: str = ""
148    scoring_params: dict[str, Any] = Field(default_factory=dict)
149    # Playable slices inside the window. Empty (the default) means the
150    # whole ``starts_at``..``ends_at`` window is playable, which is how
151    # every competition has always behaved. Supplying sessions also
152    # *sets* the window: ``CompetitionState`` derives ``starts_at`` /
153    # ``ends_at`` from the envelope, so an organiser sending sessions
154    # need not compute the outer dates and cannot contradict them.
155    sessions: list[CompetitionSessionInfo] = Field(default_factory=list)
156    # --- registration ---------------------------------------------------
157    # The window sign-ups are open, distinct from the play window above:
158    # a real event closes entries well before the first challenge unlocks
159    # so the organiser can vet entrants and print certificates.
160    registration_starts_at: datetime | None = None
161    registration_ends_at: datetime | None = None
162    # Per-competition roster cap. 0 defers to the platform-wide setting,
163    # so two events on one deployment can carry different rules.
164    max_team_size: int = Field(default=0, ge=0)
165    # When true a newly registered team lands ``pending`` and cannot play
166    # until an organiser approves it.
167    registration_review: bool = False
168    # field name -> "hidden" | "optional" | "required". Keys are validated
169    # against REGISTRATION_FIELDS. Anything absent is treated as hidden,
170    # so an unconfigured competition collects nothing extra.
171    registration_fields: dict[str, RegistrationFieldPolicy] = Field(default_factory=dict)
172    # Days after ``ends_at`` before the retention sweep blanks the three
173    # sealed columns (real name, phone, postal address). ``0`` keeps them
174    # indefinitely, which is what every competition predating this reads
175    # as — the purge is irreversible, so turning it on is a decision
176    # somebody makes about one event, never a default.
177    pii_retention_days: int = Field(default=0, ge=0)
178
179    @field_validator("registration_fields")
180    @classmethod
181    def _known_registration_fields(
182        cls, v: dict[str, RegistrationFieldPolicy]
183    ) -> dict[str, RegistrationFieldPolicy]:
184        return _check_registration_fields(v)
185
186    _norm_times = field_validator("starts_at", "ends_at", mode="before")(_blank_to_none)

Request body for POST /admin/competitions.

Time fields accept ISO 8601 strings; empty means "no bound" (live immediately / never expires). challenge_ids is validated against the spec registry inside the route handler — unknown ids return 422.

title: str = PydanticUndefined
description: str = ''
starts_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
challenge_ids: list[str] = PydanticUndefined
status: Literal['draft', 'published', 'archived'] = 'draft'
access: Literal['public', 'private_listed', 'private_hidden'] = 'public'
eval_standard: bool = False
corpus_sha: str = ''
scoring_rule: str = ''
scoring_params: dict[str, typing.Any] = PydanticUndefined
sessions: list[CompetitionSessionInfo] = PydanticUndefined
registration_starts_at: datetime.datetime | None = None
registration_ends_at: datetime.datetime | None = None
max_team_size: int = 0
registration_review: bool = False
registration_fields: dict[str, typing.Literal['hidden', 'optional', 'required']] = PydanticUndefined
pii_retention_days: int = 0
class CompetitionDetail(ctfy.server.models.CompetitionInfo):
386class CompetitionDetail(CompetitionInfo):
387    """Full detail payload — includes a *preview* of the resolved
388    challenge summaries and the caller's registration state when
389    authenticated.
390
391    ⚠️ ``challenges`` is bounded by
392    :data:`~ctfy.server.routes.competitions.COMPETITION_CHALLENGE_PREVIEW`
393    and is **not** the competition's challenge set. It used to be, and a
394    bucket competition put hundreds of full ``ChallengeInfo`` rows —
395    each carrying questions, attachments and a defence block — through
396    a payload whose only reader renders six cards.
397
398    Anything that means *all of them* must page
399    ``GET /competitions/{id}/challenges`` (SDK:
400    ``client.competition(id).challenges(offset, limit)``), and anything
401    that means *how many* must read ``challenge_count``. Both existed as
402    ``len(challenges)`` before the bound and would have kept working
403    while reporting the preview size — which is why the two aggregates
404    below are served rather than left to the caller to derive.
405    """
406
407    challenges: list[ChallengeInfo] = Field(default_factory=list)
408    # The true totals, over the whole resolved set rather than the
409    # preview. Derived here because a client cannot recover them from a
410    # bounded list, and two clients deriving them differently is two
411    # clients disagreeing about how big the competition is.
412    challenge_count: int = 0
413    difficulty_counts: dict[str, int] = Field(default_factory=dict)
414    registered_at: datetime | None = None

Full detail payload — includes a preview of the resolved challenge summaries and the caller's registration state when authenticated.

⚠️ challenges is bounded by ~ctfy.server.routes.competitions.COMPETITION_CHALLENGE_PREVIEW and is not the competition's challenge set. It used to be, and a bucket competition put hundreds of full ChallengeInfo rows — each carrying questions, attachments and a defence block — through a payload whose only reader renders six cards.

Anything that means all of them must page GET /competitions/{id}/challenges (SDK: client.competition(id).challenges(offset, limit)), and anything that means how many must read challenge_count. Both existed as len(challenges) before the bound and would have kept working while reporting the preview size — which is why the two aggregates below are served rather than left to the caller to derive.

challenges: list[ChallengeInfo] = PydanticUndefined
challenge_count: int = 0
difficulty_counts: dict[str, int] = PydanticUndefined
registered_at: datetime.datetime | None = None
class CompetitionInfo(ctfy.core.models.CtfyModel):
240class CompetitionInfo(CtfyModel):
241    """Summary row used in the list view.
242
243    Per-viewer fields (``is_registered`` / ``my_*``) are populated only
244    when the request carries an authenticated user; for anonymous
245    requests they fall back to sentinels (empty strings, ``-1``) so
246    the response shape stays stable. Front-end treats ``my_rank < 0``
247    as "no value" rather than "rank zero".
248    """
249
250    id: str
251    title: str
252    description: str
253    starts_at: datetime | None = None
254    ends_at: datetime | None = None
255    created_at: datetime | None = None
256    updated_at: datetime | None = None
257    created_by: str
258    created_by_name: str
259    challenge_ids: list[str] = Field(default_factory=list)
260    # Visibility gate. Only ``published`` rows reach non-admins; admins
261    # and per-competition admins also receive ``draft`` / ``archived``.
262    status: Literal["draft", "published", "archived"] = "published"
263    # Participation access. ``public`` is open; ``private_listed`` stays
264    # discoverable but invite-only to register; ``private_hidden`` is also
265    # hidden from non-invited users. Default ``public`` for backward
266    # compat (legacy rows / anonymous responses).
267    access: Literal["public", "private_listed", "private_hidden"] = "public"
268    #: The series this contest is an occurrence of, empty for a one-off.
269    #: Carried so a client can reach the **ladder** — before this the id
270    #: was on no wire model, so the whole rating surface was unreachable
271    #: from the browser however public its route was. Not a disclosure:
272    #: it names a series whose ladder the reader may already read, and
273    #: nothing about the series' pool or cadence travels with it.
274    series_id: str = ""
275    # Eval standard-set marker + pinned corpus snapshot (admin-only meaning;
276    # see CompetitionState). Surfaced so the admin form can show/edit it.
277    eval_standard: bool = False
278    corpus_sha: str = ""
279    # Registration window + form config. The window is genuinely public
280    # information — an entrant needs to know when sign-ups close, and it
281    # differs from the play window — and the rest lets the admin edit form
282    # prefill without a second fetch. Nothing here is sensitive: the same
283    # field policy is already served to any signed-in user by
284    # ``GET /competitions/{id}/registration``.
285    registration_starts_at: datetime | None = None
286    registration_ends_at: datetime | None = None
287    max_team_size: int = 0
288    registration_review: bool = False
289    registration_fields: dict[str, RegistrationFieldPolicy] = Field(default_factory=dict)
290    # Public deliberately: how long entrants' personal data is kept is
291    # part of the notice they are owed, not an internal setting.
292    pii_retention_days: int = 0
293    # Which rule ranks this board, and its tuning. Empty name means the
294    # platform default (``flag_count``). Public because the players are
295    # entitled to know how they are being scored.
296    scoring_rule: str = ""
297    scoring_params: dict[str, Any] = Field(default_factory=dict)
298    # Server-derived: whether the rule is now frozen. The admin form
299    # disables the control off this rather than re-deriving the
300    # draft/phase rule client-side and disagreeing with the server.
301    scoring_locked: bool = False
302    # Server-derived projection. ``"upcoming"`` / ``"running"`` / ``"past"``
303    # — saves the frontend from re-implementing the same string-compare
304    # the backend already does.
305    phase: Literal["upcoming", "running", "past"] = "running"
306    # Declared play sessions, empty for a single-window competition.
307    # Public: an entrant has to know the event runs 08:00–16:00 daily,
308    # and the schedule is the first thing a competition page shows.
309    sessions: list[CompetitionSessionInfo] = Field(default_factory=list)
310    # Server-derived: whether play is open *right now*. Deliberately not
311    # folded into ``phase`` — a competition between its 16:00 and the
312    # next 08:00 is still running (board live, registration open, scoring
313    # window unmoved), and re-classifying it would change what every
314    # existing ``phase`` consumer says about a live event.
315    #
316    # ``True`` for every competition with no sessions, which is the whole
317    # existing corpus: they are gated by nothing, so play is never shut.
318    play_open: bool = True
319    # When play reopens. ``None`` means there is no next session — either
320    # none are declared, or the last one has ended. The closed-for-now
321    # card counts down to this, so it is the one field it needs.
322    next_session_starts_at: datetime | None = None
323    # When the current session closes. ``None`` outside a session, and
324    # also for a competition that declares none — there is no session to
325    # name, and reporting the window's end here would put a "session ends
326    # at" countdown on an event that never had sessions.
327    current_session_ends_at: datetime | None = None
328    # Whether this competition runs a classic AWD tick loop. Server-derived
329    # from the ``awd_matches`` side table through ``core.awd.is_awd_match``,
330    # never re-derived per client: it decides which board a player is sent
331    # to (``/awd`` vs ``/scoreboard``) and whether a gamebox page exists at
332    # all, so two clients answering it differently is two clients
333    # disagreeing about how the event is played.
334    #
335    # A bool rather than a ``format`` enum on purpose. ``docs/awd-plan.md``
336    # §14.1 refuses a ``GameMode`` taxonomy at N=2 — a format is a point on
337    # six orthogonal axes, not a subclass — and this field claims only the
338    # fact the side table already stores. ``False`` is every competition
339    # that predates AWD, which is the whole existing corpus.
340    is_awd_match: bool = False
341    # The competition this one is a *virtual sitting* of, empty for a
342    # real one.
343    #
344    # On the wire because the marker is a rendering decision: the row's
345    # ``title`` is the source's verbatim, deliberately, so a "(virtual)"
346    # suffix baked into the database would be untranslatable English in
347    # every locale's listing. A client reads this and labels it itself.
348    virtual_of: str = ""
349    # Whether this contest can be replayed on your own clock — the fact
350    # a client renders its "Replay" button from, so it does not have to
351    # POST and read a 400 to find out.
352    #
353    # Derived server-side from ``core.virtual.can_replay``, never per
354    # client, for the same reason ``is_awd_match`` is: a client working
355    # the refusal set out itself is a second implementation of
356    # ``plan_virtual_run``, and it would drift once, silently, into a
357    # button that fails the moment it is pressed.
358    #
359    # ⚠️ Deliberately **not** viewer-scoped, unlike ``can_participate``.
360    # "Am I signed in" the client already knows, and "have I already
361    # started one" is answered by the route being idempotent — asking it
362    # here would put a per-user allowlist read inside a projection that
363    # runs once per row of the listing, which is the N+1 shape three
364    # other fields on this model have already had to have removed.
365    # Defaults ``False``: a button that always 400s is worse than none.
366    can_replay: bool = False
367    # Total registered teams. Surfaced on cards so users can gauge
368    # popularity at a glance.
369    registered_count: int = 0
370    # Viewer-scoped fields — see class docstring.
371    is_registered: bool = False
372    # Whether the viewer may register for this comp. Always ``True`` for
373    # ``public`` comps; for private comps it's ``True`` only when the
374    # viewer is invited or can administer it. Lets the frontend render a
375    # "private — invitation required" locked card without a probe POST.
376    # Defaults ``True`` so public / anonymous-on-public responses are open.
377    can_participate: bool = True
378    my_team_id: str = ""
379    my_team_name: str = ""
380    my_role: Literal["captain", "member", ""] = ""
381    my_rank: int = -1
382    my_score: int = -1
383    my_solves: int = -1

Summary row used in the list view.

Per-viewer fields (is_registered / my_*) are populated only when the request carries an authenticated user; for anonymous requests they fall back to sentinels (empty strings, -1) so the response shape stays stable. Front-end treats my_rank < 0 as "no value" rather than "rank zero".

id: str = PydanticUndefined
title: str = PydanticUndefined
description: str = PydanticUndefined
starts_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
created_at: datetime.datetime | None = None
updated_at: datetime.datetime | None = None
created_by: str = PydanticUndefined
created_by_name: str = PydanticUndefined
challenge_ids: list[str] = PydanticUndefined
status: Literal['draft', 'published', 'archived'] = 'published'
access: Literal['public', 'private_listed', 'private_hidden'] = 'public'
series_id: str = ''
eval_standard: bool = False
corpus_sha: str = ''
registration_starts_at: datetime.datetime | None = None
registration_ends_at: datetime.datetime | None = None
max_team_size: int = 0
registration_review: bool = False
registration_fields: dict[str, typing.Literal['hidden', 'optional', 'required']] = PydanticUndefined
pii_retention_days: int = 0
scoring_rule: str = ''
scoring_params: dict[str, typing.Any] = PydanticUndefined
scoring_locked: bool = False
phase: Literal['upcoming', 'running', 'past'] = 'running'
sessions: list[CompetitionSessionInfo] = PydanticUndefined
play_open: bool = True
next_session_starts_at: datetime.datetime | None = None
current_session_ends_at: datetime.datetime | None = None
is_awd_match: bool = False
virtual_of: str = ''
can_replay: bool = False
registered_count: int = 0
is_registered: bool = False
can_participate: bool = True
my_team_id: str = ''
my_team_name: str = ''
my_role: Literal['captain', 'member', ''] = ''
my_rank: int = -1
my_score: int = -1
my_solves: int = -1
class CompetitionInviteInfo(ctfy.core.models.CtfyModel):
451class CompetitionInviteInfo(CtfyModel):
452    """One participation-allowlist entry, for the admin "Invitations"
453    card on a private competition. The body for POST is the shared
454    ``GrantCompetitionAdminRequest`` (resolve by id or email)."""
455
456    user_id: str
457    display_name: str = ""
458    email: str = ""
459    invited_by: str = ""
460    invited_at: datetime | None = None
461    # True while the invitation is still waiting for its person: the
462    # address was invited before anyone signed up with it, so there is no
463    # account to name yet. ``user_id`` is empty in that state, which is
464    # why the card keys its rows on ``email``.
465    pending_signup: bool = False

One participation-allowlist entry, for the admin "Invitations" card on a private competition. The body for POST is the shared GrantCompetitionAdminRequest (resolve by id or email).

user_id: str = PydanticUndefined
display_name: str = ''
email: str = ''
invited_by: str = ''
invited_at: datetime.datetime | None = None
pending_signup: bool = False
class CompetitionMembershipInfo(ctfy.core.models.CtfyModel):
30class CompetitionMembershipInfo(CtfyModel):
31    """One row on ``MeResponse.competition_teams``.
32
33    Lets the frontend hydrate a per-comp team picker / "My
34    competitions" hub from a single ``/me`` call without N+1
35    fetches. Captain status surfaces in the UI for buttons gated
36    on ``role == "captain"`` (mint invite / kick / rename).
37
38    ``competition_title`` / ``competition_phase`` are denormalized
39    from the competition the membership points at so the switcher
40    and the dashboard can label rows by the *competition* (the
41    thing the user is choosing between) rather than the team name —
42    every user is auto-joined to an identically named personal team
43    per comp, so team name alone makes the rows indistinguishable.
44    """
45
46    competition_id: str
47    competition_title: str
48    competition_phase: Literal["upcoming", "running", "past"]
49    team_id: str
50    team_name: str
51    role: Literal["captain", "member"]
52    # Whether this competition runs a classic AWD tick loop — the same
53    # fact ``CompetitionInfo.is_awd_match`` carries, denormalized here
54    # for the one reader that cannot fetch it. ``Sidebar`` is mounted
55    # above the routes while ``CompetitionContext`` lives inside
56    # ``CompetitionLayout``, so the nav cannot see the competition the
57    # layout already loaded; it *can* see this, through ``AuthContext``,
58    # with no request of its own. Without it the per-competition block
59    # sends every AWD player to the Jeopardy board, which a match never
60    # populates.
61    is_awd_match: bool = False
62    # Rail-enrichment fields (denormalized so the Slack-style rail can
63    # render instance count / rank / a time-progress ring from one /me
64    # call, no per-tile fetch fan-out).
65    #
66    # Live instances for THIS team in THIS comp. Mirrors
67    # InstanceCountContext semantics (all non-terminal InstanceState
68    # rows for the team — terminal ones already left the live store as
69    # InstanceRecord); 0 when none.
70    running_instances: int = 0
71    # Competition window, copied straight off the CompetitionState the
72    # /me loop already fetches. "" start = live immediately, "" end =
73    # never ends (same convention as CompetitionState / phase()).
74    starts_at: datetime | None = None
75    ends_at: datetime | None = None
76    # Caller's current rank in this comp. -1 sentinel = "not computed"
77    # (upcoming phase, or the per-request rank budget was exhausted) —
78    # matches the existing my_rank<0 frontend convention.
79    my_rank: int = -1
80    # Registered-team count, the denominator the UI shows as "#3 / 40".
81    competition_team_count: int = 0

One row on MeResponse.competition_teams.

Lets the frontend hydrate a per-comp team picker / "My competitions" hub from a single /me call without N+1 fetches. Captain status surfaces in the UI for buttons gated on role == "captain" (mint invite / kick / rename).

competition_title / competition_phase are denormalized from the competition the membership points at so the switcher and the dashboard can label rows by the competition (the thing the user is choosing between) rather than the team name — every user is auto-joined to an identically named personal team per comp, so team name alone makes the rows indistinguishable.

competition_id: str = PydanticUndefined
competition_title: str = PydanticUndefined
competition_phase: Literal['upcoming', 'running', 'past'] = PydanticUndefined
team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
role: Literal['captain', 'member'] = PydanticUndefined
is_awd_match: bool = False
running_instances: int = 0
starts_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
my_rank: int = -1
competition_team_count: int = 0
class CompetitionRegistrationInfo(ctfy.core.models.CtfyModel):
417class CompetitionRegistrationInfo(CtfyModel):
418    """One row in the admin "who's registered" table."""
419
420    team_id: str
421    team_name: str = ""
422    registered_at: datetime | None = None

One row in the admin "who's registered" table.

team_id: str = PydanticUndefined
team_name: str = ''
registered_at: datetime.datetime | None = None
class CompetitionRoster(ctfy.core.models.CtfyModel):
68class CompetitionRoster(CtfyModel):
69    """``GET /competitions/{id}/roster`` — every registered team with its
70    members, in one read.
71
72    Exists because the alternative is a fan-out: a caller that wants to
73    know who is playing has to call ``GET /teams/{id}`` once per team,
74    which for a 400-team event is 400 round trips for a page that has to
75    stay live. The backend answers it in three bulk queries whatever the
76    team count.
77
78    ⚠️ **Public identity only.** ``TeamMemberInfo`` carries
79    ``display_name`` / ``avatar_url`` and nothing else, which is exactly
80    what ``GET /teams/{id}`` already publishes per team. The entrant
81    dossier — ``real_name`` / ``phone`` / ``mailing_address``, 个人敏感信息
82    sealed at the backend boundary — reaches the reviewer roster
83    (``RegistrationTeamRow``) and nowhere else, and must never be added
84    here: this response is public and cacheable, so a field added to it
85    is a field published to everyone forever.
86    """
87
88    teams: list[CompetitionRosterTeam] = Field(default_factory=list)
89    generated_at: float = 0.0

GET /competitions/{id}/roster — every registered team with its members, in one read.

Exists because the alternative is a fan-out: a caller that wants to know who is playing has to call GET /teams/{id} once per team, which for a 400-team event is 400 round trips for a page that has to stay live. The backend answers it in three bulk queries whatever the team count.

⚠️ Public identity only. TeamMemberInfo carries display_name / avatar_url and nothing else, which is exactly what GET /teams/{id} already publishes per team. The entrant dossier — real_name / phone / mailing_address, 个人敏感信息 sealed at the backend boundary — reaches the reviewer roster (RegistrationTeamRow) and nowhere else, and must never be added here: this response is public and cacheable, so a field added to it is a field published to everyone forever.

teams: list[CompetitionRosterTeam] = PydanticUndefined
generated_at: float = 0.0
class CompetitionRosterTeam(ctfy.core.models.CtfyModel):
59class CompetitionRosterTeam(CtfyModel):
60    """One team plus its roster, for the competition-wide roster read."""
61
62    team_id: str
63    name: str = ""
64    captain_user_id: str = ""
65    members: list[TeamMemberInfo] = Field(default_factory=list)

One team plus its roster, for the competition-wide roster read.

team_id: str = PydanticUndefined
name: str = ''
captain_user_id: str = ''
members: list[TeamMemberInfo] = PydanticUndefined
class CompetitionScoreDistribution(ctfy.core.models.CtfyModel):
721class CompetitionScoreDistribution(CtfyModel):
722    """Aggregate companion to the (now server-paginated) competition
723    scoreboard: the score-distribution histogram + the true team count.
724
725    The standings table pages server-side, so the histogram / "N teams"
726    figure can't be re-derived from one page in the browser — this
727    bins every registered team's ``flags_solved`` with the same
728    adaptive scheme the frontend used to do client-side.
729    """
730
731    total_teams: int = 0
732    bins: list[ScoreBucket] = Field(default_factory=list)

Aggregate companion to the (now server-paginated) competition scoreboard: the score-distribution histogram + the true team count.

The standings table pages server-side, so the histogram / "N teams" figure can't be re-derived from one page in the browser — this bins every registered team's flags_solved with the same adaptive scheme the frontend used to do client-side.

total_teams: int = 0
bins: list[ScoreBucket] = PydanticUndefined
class CompetitionScoreHistory(ctfy.core.models.CtfyModel):
678class CompetitionScoreHistory(CtfyModel):
679    """Top-N teams' score/rank progression, reconstructed from the
680    competition's solve log (no snapshot dependency — exact for past
681    and live competitions alike)."""
682
683    series: list[ScoreHistorySeries] = Field(default_factory=list)
684    generated_at: float = 0.0

Top-N teams' score/rank progression, reconstructed from the competition's solve log (no snapshot dependency — exact for past and live competitions alike).

series: list[ScoreHistorySeries] = PydanticUndefined
generated_at: float = 0.0
class CompetitionSeriesConfig(ctfy.core.models.CtfyModel):
34class CompetitionSeriesConfig(CtfyModel):
35    """What an organiser sets on a series."""
36
37    model_config = ConfigDict(extra="forbid")
38
39    name: str = ""
40    enabled: bool = False
41
42    weekday: int = 5
43    hour: int = 20
44    minute: int = 0
45    timezone: str = "UTC"
46    every_n_weeks: int = 1
47    first_at: AwareDatetime | None = None
48    lead_seconds: int = Field(default=172800, ge=0)
49
50    title_template: str = ""
51    description: str = ""
52    duration_seconds: int = Field(default=10800, ge=1)
53    pool: list[str] = Field(default_factory=list)
54    slate_size: int = Field(default=1, ge=1)
55    scoring_rule: str = ""
56    scoring_params: dict[str, Any] = Field(default_factory=dict)
57    status: Literal["draft", "published", "archived"] = "published"
58    access: Literal["public", "private_listed", "private_hidden"] = "public"
59
60    @model_validator(mode="after")
61    def _cadence_is_expressible(self) -> CompetitionSeriesConfig:
62        """⚠️ Validated through `Cadence`, never re-checked here.
63
64        Two copies of "is this a legal weekday / a real timezone" is how
65        the form and the job come to disagree about which Saturdays
66        exist — and the job's copy is the one nobody sees fail.
67        """
68        Cadence(
69            weekday=self.weekday,
70            hour=self.hour,
71            minute=self.minute,
72            timezone=self.timezone,
73            every_n_weeks=self.every_n_weeks,
74        )
75        return self

What an organiser sets on a series.

name: str = ''
enabled: bool = False
weekday: int = 5
hour: int = 20
minute: int = 0
timezone: str = 'UTC'
every_n_weeks: int = 1
first_at: pydantic.types.AwareDatetime | None = None
lead_seconds: int = 172800
title_template: str = ''
description: str = ''
duration_seconds: int = 10800
pool: list[str] = PydanticUndefined
slate_size: int = 1
scoring_rule: str = ''
scoring_params: dict[str, typing.Any] = PydanticUndefined
status: Literal['draft', 'published', 'archived'] = 'published'
access: Literal['public', 'private_listed', 'private_hidden'] = 'public'
class CompetitionSeriesInfo(ctfy.server.models.CompetitionSeriesConfig):
 78class CompetitionSeriesInfo(CompetitionSeriesConfig):
 79    """A series as the organiser's page reads it."""
 80
 81    id: str = ""
 82    created_at: AwareDatetime | None = None
 83    created_by: str = ""
 84    #: The next scheduled start, or ``None`` for a series whose schedule
 85    #: is over or unusable. Derived, never stored.
 86    next_occurrence_at: AwareDatetime | None = None
 87    #: How many occurrences already exist. A count is the right shape
 88    #: *here* — this is a read for a human, not the job's idempotency,
 89    #: which asks the database which instants exist.
 90    occurrences_spawned: int = 0
 91    #: ⚠️ Whether the job that actually spawns is switched on. It ships
 92    #: enabled, unlike `awd_tick`, but a super-admin can pause it from
 93    #: `/admin/scheduled-jobs` — and a series page that cannot say so
 94    #: leaves an organiser reading a perfectly configured schedule that
 95    #: will never run. Absent means not seeded, which is the same thing
 96    #: as off to a reader; reporting it as on is the false reassurance
 97    #: this field exists to remove.
 98    spawn_job_enabled: bool = False
 99    #: How many of `pool` declare a patchable surface.
100    #:
101    #: ⚠️ Derived here rather than in each client, which is the opposite
102    #: of how the competition wizard answers the same question — and the
103    #: difference is not an inconsistency. That form has just *browsed*
104    #: the catalog, so it holds the rows; a series client holds ids and
105    #: nothing else, and there is no by-id read on `GET /challenges`. Two
106    #: clients deriving it would be two clients disagreeing about whether
107    #: a weekly can score defence at all.
108    #:
109    #: The reason it is worth a field: `scoring_rule` and `pool` are set
110    #: in one form and nothing cross-checks them, so an "attack &
111    #: defence" weekly over a pool with nothing patchable scores zero
112    #: defence **every week**, unattended, with no error anywhere. This
113    #: is a report, never a refusal — a pool an organiser intends to fill
114    #: later is legitimate.
115    defensible_pool_count: int = 0
116    #: Pool entries the catalog does not have. A separate and equally
117    #: silent failure: `plan_slate` refuses an occurrence whose slate it
118    #: cannot fill, so a pool naming a since-deleted challenge produces a
119    #: Saturday with no contest rather than an error.
120    unknown_pool_ids: list[str] = Field(default_factory=list)
121    #: Pool entries **another series** also draws from — the third member
122    #: of the same family, and the one with a security edge rather than a
123    #: scoring one.
124    #:
125    #: `plan_slate` consumes a pool *within one series*, so two series
126    #: overlapping is invisible from either: nothing refuses, nothing
127    #: logs, and both weeklies are individually correct. What it costs is
128    #: that a challenge played on the internal ladder is **pre-leaked**
129    #: when the public one draws it, and the first sign is a public
130    #: Saturday where some squad solves in ninety seconds.
131    #:
132    #: A report, never a refusal, exactly like the two above: an
133    #: organiser staging both series out of one draft pool, or
134    #: deliberately re-running a problem for a different audience, is
135    #: doing something legitimate.
136    shared_pool_ids: list[str] = Field(default_factory=list)

A series as the organiser's page reads it.

id: str = ''
created_at: pydantic.types.AwareDatetime | None = None
created_by: str = ''
next_occurrence_at: pydantic.types.AwareDatetime | None = None
occurrences_spawned: int = 0
spawn_job_enabled: bool = False
defensible_pool_count: int = 0
unknown_pool_ids: list[str] = PydanticUndefined
shared_pool_ids: list[str] = PydanticUndefined
class CompetitionSessionInfo(ctfy.core.models.CtfyModel):
104class CompetitionSessionInfo(CtfyModel):
105    """One playable slice of a competition's window, on the wire."""
106
107    starts_at: datetime
108    ends_at: datetime

One playable slice of a competition's window, on the wire.

starts_at: datetime.datetime = PydanticUndefined
ends_at: datetime.datetime = PydanticUndefined
class CompetitionUpdate(ctfy.core.models.CtfyModel):
189class CompetitionUpdate(CtfyModel):
190    """PATCH body — every field optional, ``None`` means "leave alone"."""
191
192    title: str | None = Field(default=None, min_length=1, max_length=200)
193    description: str | None = Field(default=None, max_length=20000)
194    starts_at: datetime | None = None
195    ends_at: datetime | None = None
196    challenge_ids: list[str] | None = Field(default=None)  # no cap — see CompetitionCreate
197    status: Literal["draft", "published", "archived"] | None = None
198    access: Literal["public", "private_listed", "private_hidden"] | None = None
199    eval_standard: bool | None = None
200    corpus_sha: str | None = None
201    # Rejected with 409 once the competition is live — re-submitting the
202    # value it already holds is a no-op, so the edit form can keep
203    # sending every field when the admin saves any of them.
204    scoring_rule: str | None = None
205    scoring_params: dict[str, Any] | None = None
206    #: ``None`` leaves the schedule alone; ``[]`` clears every session and
207    #: returns the competition to a single continuous window.
208    sessions: list[CompetitionSessionInfo] | None = None
209    # --- registration ---------------------------------------------------
210    # The window sign-ups are open, distinct from the play window above:
211    # a real event closes entries well before the first challenge unlocks
212    # so the organiser can vet entrants and print certificates.
213    registration_starts_at: datetime | None = None
214    registration_ends_at: datetime | None = None
215    # Per-competition roster cap. 0 defers to the platform-wide setting,
216    # so two events on one deployment can carry different rules.
217    max_team_size: int | None = Field(default=None, ge=0)
218    # When true a newly registered team lands ``pending`` and cannot play
219    # until an organiser approves it.
220    registration_review: bool | None = None
221    # field name -> "hidden" | "optional" | "required". Keys are validated
222    # against REGISTRATION_FIELDS. Anything absent is treated as hidden,
223    # so an unconfigured competition collects nothing extra.
224    registration_fields: dict[str, RegistrationFieldPolicy] | None = None
225    #: ``None`` leaves it alone, same as every other field here.
226    pii_retention_days: int | None = Field(default=None, ge=0)
227
228    _norm_times = field_validator(
229        "starts_at", "ends_at", "registration_starts_at", "registration_ends_at", mode="before"
230    )(_blank_to_none)
231
232    @field_validator("registration_fields")
233    @classmethod
234    def _known_registration_fields(
235        cls, v: dict[str, RegistrationFieldPolicy] | None
236    ) -> dict[str, RegistrationFieldPolicy] | None:
237        return None if v is None else _check_registration_fields(v)

PATCH body — every field optional, None means "leave alone".

title: str | None = None
description: str | None = None
starts_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
challenge_ids: list[str] | None = None
status: Optional[Literal['draft', 'published', 'archived']] = None
access: Optional[Literal['public', 'private_listed', 'private_hidden']] = None
eval_standard: bool | None = None
corpus_sha: str | None = None
scoring_rule: str | None = None
scoring_params: dict[str, typing.Any] | None = None
sessions: list[CompetitionSessionInfo] | None = None
registration_starts_at: datetime.datetime | None = None
registration_ends_at: datetime.datetime | None = None
max_team_size: int | None = None
registration_review: bool | None = None
registration_fields: dict[str, typing.Literal['hidden', 'optional', 'required']] | None = None
pii_retention_days: int | None = None
class CreateFineGrainedTokenRequest(ctfy.core.models.CtfyModel):
171class CreateFineGrainedTokenRequest(CtfyModel):
172    """Body for ``POST /auth/tokens`` (mints a fine-grained token).
173
174    Back-compat: when ``competition_access`` / ``permissions`` are
175    omitted the server mints the broad legacy profile (every
176    competition, full participate access) so existing
177    ``mint_token(label)`` callers — SDK, CLI, MCP — keep getting a
178    token equivalent to the old agent token.
179    """
180
181    label: str = Field(default="", max_length=64)
182    # Lifetime in days. ``None`` uses the server default (30); ``0`` means
183    # "never expires". The server caps this at ~5 years to reject absurd
184    # values.
185    expires_in_days: int | None = Field(default=None, ge=0, le=365 * 5)
186    # ``None`` → legacy-broad default. Otherwise: none | all | selected.
187    competition_access: str | None = None
188    competition_ids: list[str] = Field(default_factory=list)
189    # Category → "none" | "read" | "write". Clamped server-side to each
190    # category's max level. ``None`` → legacy-broad default.
191    permissions: dict[str, str] | None = None

Body for POST /auth/tokens (mints a fine-grained token).

Back-compat: when competition_access / permissions are omitted the server mints the broad legacy profile (every competition, full participate access) so existing mint_token(label) callers — SDK, CLI, MCP — keep getting a token equivalent to the old agent token.

label: str = ''
expires_in_days: int | None = None
competition_access: str | None = None
competition_ids: list[str] = PydanticUndefined
permissions: dict[str, str] | None = None
class CreateFineGrainedTokenResponse(ctfy.server.models.TokenInfo):
194class CreateFineGrainedTokenResponse(TokenInfo):
195    """Includes the plaintext ``token`` — returned once, never persisted."""
196
197    token: str

Includes the plaintext token — returned once, never persisted.

token: str = PydanticUndefined
class CreateInviteRequest(ctfy.core.models.CtfyModel):
114class CreateInviteRequest(CtfyModel):
115    """Body for ``POST /api/v1/nodes/invites``.
116
117    ``ttl_seconds`` controls how long the minted registration token stays
118    valid — it cannot be renewed, only re-created.
119    """
120
121    ttl_seconds: int = Field(default=3600, ge=60, le=7 * 24 * 3600)
122    # Optional vendor binding: a node registered with this invite is stamped
123    # as that vendor's bring-your-own-node (docs/model-eval-vendor-tenancy.md
124    # §6.3). "" = a platform-shared node (the default). Validated to an
125    # existing vendor at mint time.
126    owner_vendor_id: str = ""

Body for POST /api/v1/nodes/invites.

ttl_seconds controls how long the minted registration token stays valid — it cannot be renewed, only re-created.

ttl_seconds: int = 3600
owner_vendor_id: str = ''
class CreateInviteResponse(ctfy.core.models.CtfyModel):
129class CreateInviteResponse(CtfyModel):
130    """Plaintext registration token is returned **exactly once** here.
131
132    Subsequent ``GET /nodes/invites`` lookups only expose metadata; the
133    plaintext is never persisted — only ``token_hash`` is stored.
134    """
135
136    id: str
137    registration_token: str
138    expires_at: datetime | None = None

Plaintext registration token is returned exactly once here.

Subsequent GET /nodes/invites lookups only expose metadata; the plaintext is never persisted — only token_hash is stored.

id: str = PydanticUndefined
registration_token: str = PydanticUndefined
expires_at: datetime.datetime | None = None
class DefenceInfo(ctfy.core.models.CtfyModel):
76class DefenceInfo(CtfyModel):
77    """What a defender may do on this challenge — AWD+ only.
78
79    Present iff the challenge declares a ``patch:`` block. ``None``
80    everywhere else, which is what lets a client decide whether to render
81    a defence surface at all: without this the only way to find out was
82    to POST a submission and read the refusal, so the web UI had no way
83    to know AWD+ existed.
84
85    ``targets`` is deliberately public. It is a whitelist, not a secret —
86    a defender is *given* this source (they can read it in their own
87    box), the refusal message already names a rejected path, and a UI
88    that cannot say which files it will accept sends players guessing.
89    """
90
91    #: Challenge-relative paths a submission may replace. Anything else
92    #: is refused at submit time rather than at judge time.
93    targets: list[str] = Field(default_factory=list)
94    #: Whether ``patch.live`` declares a defender's shell, i.e. whether
95    #: ``ctfy patch shell`` / the terminal button will work here. False
96    #: means uploads are the only channel — a real and supported shape,
97    #: not a degraded one, so the UI says so rather than hiding.
98    live_shell: bool = False

What a defender may do on this challenge — AWD+ only.

Present iff the challenge declares a patch: block. None everywhere else, which is what lets a client decide whether to render a defence surface at all: without this the only way to find out was to POST a submission and read the refusal, so the web UI had no way to know AWD+ existed.

targets is deliberately public. It is a whitelist, not a secret — a defender is given this source (they can read it in their own box), the refusal message already names a rejected path, and a UI that cannot say which files it will accept sends players guessing.

targets: list[str] = PydanticUndefined
live_shell: bool = False
class DefenceSource(ctfy.core.models.CtfyModel):
46class DefenceSource(CtfyModel):
47    """The patchable source of an AWD+ challenge, as the defender gets it.
48
49    AWD+ hands every team the same vulnerable service **and its source**
50    (§15.1) — you cannot patch what you cannot read. Until this there
51    was no way to get it: the only channel was `patch.live`, i.e. ssh
52    into your own box, which is the *classic AWD* shape and leaves
53    virtual participation and upsolve impossible, since after the event
54    there is no box to enter. That is the feature §15.5 calls decisive
55    for a weekly series.
56
57    ⚠️ **Exactly `patch.targets`, never the tree.** A benchmark
58    directory also holds `solution/exploit.py` and the checker, and for
59    some challenges the groundtruth — serving the directory would hand a
60    player the reference exploit and the thing that grades them. The
61    whitelist is already the format's one real security boundary (see
62    `routes/patches.py`), so the download reuses it rather than
63    inventing a second, weaker one.
64
65    ``files`` is keyed by challenge-relative path with **base64**
66    values, which is deliberately the shape `POST /patches` accepts: a
67    defender — or an agent — round-trips download → edit → upload with
68    no transformation, and a binary target does not need a second code
69    path.
70    """
71
72    challenge_id: str
73    files: dict[str, str] = Field(default_factory=dict)

The patchable source of an AWD+ challenge, as the defender gets it.

AWD+ hands every team the same vulnerable service and its source (§15.1) — you cannot patch what you cannot read. Until this there was no way to get it: the only channel was patch.live, i.e. ssh into your own box, which is the classic AWD shape and leaves virtual participation and upsolve impossible, since after the event there is no box to enter. That is the feature §15.5 calls decisive for a weekly series.

⚠️ Exactly patch.targets, never the tree. A benchmark directory also holds solution/exploit.py and the checker, and for some challenges the groundtruth — serving the directory would hand a player the reference exploit and the thing that grades them. The whitelist is already the format's one real security boundary (see routes/patches.py), so the download reuses it rather than inventing a second, weaker one.

files is keyed by challenge-relative path with base64 values, which is deliberately the shape POST /patches accepts: a defender — or an agent — round-trips download → edit → upload with no transformation, and a binary target does not need a second code path.

challenge_id: str = PydanticUndefined
files: dict[str, str] = PydanticUndefined
class DeleteMeRequest(ctfy.core.models.CtfyModel):
136class DeleteMeRequest(CtfyModel):
137    """Body for ``DELETE /me``. ``confirm_display_name`` must match the
138    caller's user display name exactly (case-sensitive) — the typo
139    gate that stops a fat-fingered click from cascading the account's
140    data away. (Falls back to email when display_name is empty.)
141    """
142
143    confirm_display_name: str = Field(min_length=1, max_length=120)

Body for DELETE /me. confirm_display_name must match the caller's user display name exactly (case-sensitive) — the typo gate that stops a fat-fingered click from cascading the account's data away. (Falls back to email when display_name is empty.)

confirm_display_name: str = PydanticUndefined
class DeploymentConfig(ctfy.core.models.CtfyModel):
239class DeploymentConfig(CtfyModel):
240    """Read-only deployment posture for the admin overview — env-only
241    knobs that are *not* on public ``/meta`` and not runtime-tunable via
242    platform-settings, so an operator can see how this deployment is
243    configured at a glance. ``rate_limiting_enabled`` is deliberately
244    absent: it is runtime-tunable, so the platform-settings page owns its
245    effective value + source tag. Defaults mirror ``CtfyConfig``."""
246
247    single_process_guard: bool = True
248    auto_migrate: bool = True
249    password_auth_enabled: bool = True
250    # HSTS is emitted only when the deployment is reachable over https
251    # (``oauth_redirect_base`` is https) — see security_headers.
252    hsts_enabled: bool = False

Read-only deployment posture for the admin overview — env-only knobs that are not on public /meta and not runtime-tunable via platform-settings, so an operator can see how this deployment is configured at a glance. rate_limiting_enabled is deliberately absent: it is runtime-tunable, so the platform-settings page owns its effective value + source tag. Defaults mirror CtfyConfig.

single_process_guard: bool = True
auto_migrate: bool = True
password_auth_enabled: bool = True
hsts_enabled: bool = False
class DeviceApproveRequest(ctfy.core.models.CtfyModel):
128class DeviceApproveRequest(CtfyModel):
129    user_code: str = Field(min_length=1, max_length=64)

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.

user_code: str = PydanticUndefined
class DeviceCodeResponse(ctfy.core.models.CtfyModel):
102class DeviceCodeResponse(CtfyModel):
103    """RFC 8628-style response to ``POST /auth/device/code`` — the CLI
104    shows ``user_code`` + ``verification_uri`` and polls with
105    ``device_code``."""
106
107    device_code: str
108    user_code: str
109    verification_uri: str
110    verification_uri_complete: str
111    expires_in: int
112    interval: int

RFC 8628-style response to POST /auth/device/code — the CLI shows user_code + verification_uri and polls with device_code.

device_code: str = PydanticUndefined
user_code: str = PydanticUndefined
verification_uri: str = PydanticUndefined
verification_uri_complete: str = PydanticUndefined
expires_in: int = PydanticUndefined
interval: int = PydanticUndefined
class DeviceInfoResponse(ctfy.core.models.CtfyModel):
132class DeviceInfoResponse(CtfyModel):
133    """What the approval page shows about a pending device request."""
134
135    status: str
136    requested_at: datetime | None = None
137    requester_ip: str = ""
138    requester_user_agent: str = ""

What the approval page shows about a pending device request.

status: str = PydanticUndefined
requested_at: datetime.datetime | None = None
requester_ip: str = ''
requester_user_agent: str = ''
class DeviceTokenRequest(ctfy.core.models.CtfyModel):
115class DeviceTokenRequest(CtfyModel):
116    device_code: str = Field(min_length=1, max_length=512)

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.

device_code: str = PydanticUndefined
class DeviceTokenResponse(ctfy.core.models.CtfyModel):
119class DeviceTokenResponse(CtfyModel):
120    """Poll result for ``POST /auth/device/token``. ``status`` is one of
121    ``pending`` / ``approved`` / ``denied`` / ``expired``; ``token`` is
122    set only when ``approved``."""
123
124    status: str
125    token: str = ""

Poll result for POST /auth/device/token. status is one of pending / approved / denied / expired; token is set only when approved.

status: str = PydanticUndefined
token: str = ''
class DifficultyStat(ctfy.core.models.CtfyModel):
170class DifficultyStat(CtfyModel):
171    difficulty: str = ""  # easy | medium | hard | ...
172    solved: int = 0
173    total: int = 0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

difficulty: str = ''
solved: int = 0
total: int = 0
class EasterEggClaim(ctfy.core.models.CtfyModel):
 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.

egg_id: str = PydanticUndefined
achievement: TeamAchievement = PydanticUndefined
already_unlocked: bool = False
class EmailCodeAuthInfo(ctfy.core.models.CtfyModel):
35class EmailCodeAuthInfo(CtfyModel):
36    """Whether passwordless email sign-in is offered.
37
38    Tracks outbound-email configuration exactly — there is no separate
39    feature flag, because a sign-in method that cannot deliver its code
40    is not a sign-in method, and offering the button anyway just produces
41    a dead end.
42    """
43
44    enabled: bool = False

Whether passwordless email sign-in is offered.

Tracks outbound-email configuration exactly — there is no separate feature flag, because a sign-in method that cannot deliver its code is not a sign-in method, and offering the button anyway just produces a dead end.

enabled: bool = False
class EmailSuppressionCreate(ctfy.core.models.CtfyModel):
104class EmailSuppressionCreate(CtfyModel):
105    email: str
106    #: Free text; stored as the ``detail`` an operator reads later when
107    #: deciding whether the entry still applies.
108    detail: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

email: str = PydanticUndefined
detail: str = ''
class EmailSuppressionInfo(ctfy.core.models.CtfyModel):
78class EmailSuppressionInfo(CtfyModel):
79    """One address the platform will not send to."""
80
81    email: str
82    #: ``hard_bounce`` | ``complaint`` | ``manual``.
83    reason: str
84    #: The provider's own words, when it gave any.
85    detail: str = ""
86    #: ``webhook`` | ``admin`` — a provider's verdict or a human's.
87    source: str = ""
88    #: The message whose bounce caused this, for cross-referencing the
89    #: outbox row that sent it.
90    provider_message_id: str = ""
91    created_at: str = ""
92    created_by: str = ""

One address the platform will not send to.

email: str = PydanticUndefined
reason: str = PydanticUndefined
detail: str = ''
source: str = ''
provider_message_id: str = ''
created_at: str = ''
created_by: str = ''
class EmailSuppressionListResponse(ctfy.core.models.CtfyModel):
 95class EmailSuppressionListResponse(CtfyModel):
 96    items: list[EmailSuppressionInfo]
 97    total: int
 98    #: False when no Svix secret is set, in which case nothing will ever
 99    #: be added automatically and the list is manual-only. The UI says so
100    #: rather than letting an operator conclude that nothing bounces.
101    webhook_configured: bool

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

items: list[EmailSuppressionInfo] = PydanticUndefined
total: int = PydanticUndefined
webhook_configured: bool = PydanticUndefined
class EmailTemplateInfo(ctfy.core.models.CtfyModel):
55class EmailTemplateInfo(CtfyModel):
56    """One row of the admin template list.
57
58    ``category`` and ``forced`` come from the same registry the
59    preference matrix reads, so the admin sees *why* a message is or is
60    not suppressible without cross-referencing another page.
61    """
62
63    name: str
64    category: str
65    forced: bool
66    subject: str

One row of the admin template list.

category and forced come from the same registry the preference matrix reads, so the admin sees why a message is or is not suppressible without cross-referencing another page.

name: str = PydanticUndefined
category: str = PydanticUndefined
forced: bool = PydanticUndefined
subject: str = PydanticUndefined
class EmailTemplateListResponse(ctfy.core.models.CtfyModel):
69class EmailTemplateListResponse(CtfyModel):
70    templates: list[EmailTemplateInfo]
71    #: False when Resend is unconfigured. The UI disables Send rather
72    #: than letting the operator discover it through a 400.
73    email_configured: bool
74    #: Where a test send goes when the request omits a recipient.
75    default_recipient: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

templates: list[EmailTemplateInfo] = PydanticUndefined
email_configured: bool = PydanticUndefined
default_recipient: str = ''
class ErrorResponse(ctfy.core.models.CtfyModel):
136class ErrorResponse(CtfyModel):
137    """Unified shape for every HTTP 4xx/5xx body produced by platform routes.
138
139    ``detail`` mirrors the pre-existing field (so SDK / CLI / frontend code
140    that reads ``data.detail`` keeps working); ``code`` adds a stable
141    machine-readable identifier derived from the raising exception class,
142    and ``timestamp`` is server-side UTC in ISO 8601 — useful when
143    correlating client-side and server-side logs.
144
145    ``detail`` is a union because three sources feed the envelope:
146      * a plain string from legacy ``HTTPException`` / ``raise_error`` calls;
147      * a structured dict (``{"message": "...", "competition_id": ...}``
148        or the legacy ``{"error": "..."}``) when the route wants to attach
149        machine-readable context the frontend can branch on;
150      * a list of FastAPI ``RequestValidationError`` issues, wrapped under
151        ``{"message", "issues"}`` by ``_handle_validation_error``.
152    """
153
154    code: str
155    detail: str | dict[str, Any] | list[dict[str, Any]]
156    timestamp: datetime | None = None

Unified shape for every HTTP 4xx/5xx body produced by platform routes.

detail mirrors the pre-existing field (so SDK / CLI / frontend code that reads data.detail keeps working); code adds a stable machine-readable identifier derived from the raising exception class, and timestamp is server-side UTC in ISO 8601 — useful when correlating client-side and server-side logs.

detail is a union because three sources feed the envelope:

  • a plain string from legacy HTTPException / raise_error calls;
  • 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 RequestValidationError issues, wrapped under {"message", "issues"} by _handle_validation_error.
code: str = PydanticUndefined
detail: str | dict[str, typing.Any] | list[dict[str, typing.Any]] = PydanticUndefined
timestamp: datetime.datetime | None = None
class EvalLeaderboard(ctfy.core.models.CtfyModel):
154class EvalLeaderboard(CtfyModel):
155    """The full leaderboard: ranked model standings + the dimensions available."""
156
157    competition_id: str = ""
158    challenge_count: int = 0
159    #: the single corpus version all runs share (reproducibility header);
160    #: blank when runs span mixed corpus revisions.
161    corpus_sha: str = ""
162    dimensions: list[str] = Field(default_factory=list)
163    models: list[ModelStanding] = Field(default_factory=list)
164    #: vendor scorecards (pooled across each vendor's models); unattributed
165    #: models (blank vendor) are omitted.
166    vendors: list[VendorStanding] = Field(default_factory=list)
167    #: harness scorecards (pooled across each harness's runs) — the harness
168    #: comparison axis. The blank harness id is the built-in ctfy harness.
169    harnesses: list[HarnessStanding] = Field(default_factory=list)
170    #: per-(model, harness) cross-tab cells — the apples-to-apples view that
171    #: isolates the harness effect on a fixed model. Empty unless ≥2 harnesses.
172    cross: list[HarnessModelCell] = Field(default_factory=list)
173    #: run activity + run-level solve rate bucketed by UTC day (chronological).
174    trend: list[EvalTrendPoint] = Field(default_factory=list)

The full leaderboard: ranked model standings + the dimensions available.

competition_id: str = ''
challenge_count: int = 0
corpus_sha: str = ''
dimensions: list[str] = PydanticUndefined
models: list[ModelStanding] = PydanticUndefined
vendors: list[VendorStanding] = PydanticUndefined
harnesses: list[HarnessStanding] = PydanticUndefined
cross: list[HarnessModelCell] = PydanticUndefined
trend: list[EvalTrendPoint] = PydanticUndefined
class EvalTrendPoint(ctfy.core.models.CtfyModel):
121class EvalTrendPoint(CtfyModel):
122    """Eval activity + run-level performance for one UTC day.
123
124    ``solve_rate`` here is *run-level* (fully-solved runs ÷ runs that day),
125    a different lens from the per-challenge best-of-k rate in the standings:
126    it tracks how the fleet is performing over time, not cumulative coverage.
127    """
128
129    period: str = ""  # UTC calendar day, YYYY-MM-DD
130    runs: int = 0
131    fully_solved: int = 0
132    solve_rate: float = 0.0
133    cost_usd: float = 0.0

Eval activity + run-level performance for one UTC day.

solve_rate here is run-level (fully-solved runs ÷ runs that day), a different lens from the per-challenge best-of-k rate in the standings: it tracks how the fleet is performing over time, not cumulative coverage.

period: str = ''
runs: int = 0
fully_solved: int = 0
solve_rate: float = 0.0
cost_usd: float = 0.0
class FeedbackStats(ctfy.core.models.CtfyModel):
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.

challenge_id: str = PydanticUndefined
counts: dict[typing.Literal['addictive', 'mindblown', 'learned', 'goat_setter', 'overthought', 'guessy', 'brutal', 'buggy', 'infra_broken'], int] = PydanticUndefined
total: int = PydanticUndefined
class GrantCompetitionAdminRequest(ctfy.core.models.CtfyModel):
437class GrantCompetitionAdminRequest(CtfyModel):
438    """Body for ``PUT /admin/competitions/{id}/admins`` — resolve the
439    target user by id or (case-insensitive) email.
440
441    ``role`` picks the grant tier: ``admin`` (full organiser control of
442    this event) or ``reviewer`` (roster read + eligibility verdicts +
443    export, nothing that removes a team). Defaults to ``admin`` so an
444    older client that omits it keeps the prior behaviour."""
445
446    user_id: str = ""
447    email: str = ""
448    role: CompetitionGrantRole = "admin"

Body for PUT /admin/competitions/{id}/admins — resolve the target user by id or (case-insensitive) email.

role picks the grant tier: admin (full organiser control of this event) or reviewer (roster read + eligibility verdicts + export, nothing that removes a team). Defaults to admin so an older client that omits it keeps the prior behaviour.

user_id: str = ''
email: str = ''
role: Literal['admin', 'reviewer'] = 'admin'
class HarnessModelCell(ctfy.core.models.CtfyModel):
103class HarnessModelCell(CtfyModel):
104    """One ``(model, harness)`` pair's solve stats — a cross-tab cell.
105
106    The apples-to-apples lens the pooled rollups can't give: holding the
107    model fixed and varying the harness isolates the harness's effect on
108    that model (e.g. ``gpt-5`` solves 80% under one harness, 60% under
109    another). Display labels are resolved frontend-side from the model /
110    harness standings, so the cell carries only ids + stats.
111    """
112
113    model_id: str = ""
114    #: harness registry id; blank = the built-in ctfy harness.
115    harness_id: str = ""
116    challenges_attempted: int = 0
117    challenges_solved: int = 0
118    solve_rate: float = 0.0

One (model, harness) pair's solve stats — a cross-tab cell.

The apples-to-apples lens the pooled rollups can't give: holding the model fixed and varying the harness isolates the harness's effect on that model (e.g. gpt-5 solves 80% under one harness, 60% under another). Display labels are resolved frontend-side from the model / harness standings, so the cell carries only ids + stats.

model_id: str = ''
harness_id: str = ''
challenges_attempted: int = 0
challenges_solved: int = 0
solve_rate: float = 0.0
class HarnessStanding(ctfy.core.models.CtfyModel):
 79class HarnessStanding(CtfyModel):
 80    """One harness's aggregate scorecard across all the models it drove.
 81
 82    The harness's runs are pooled across models: a challenge counts as
 83    *solved* if **any** run under the harness solved it. Like the vendor
 84    rollup this ranks harness *plus its model fleet*, not the harness in
 85    isolation — a harness paired with stronger models scores higher.
 86    """
 87
 88    rank: int = 0
 89    #: harness registry id; blank = the built-in ctfy harness.
 90    harness_id: str = ""
 91    display_name: str = ""
 92    #: distinct models evaluated under this harness.
 93    models: int = 0
 94    challenges_attempted: int = 0
 95    challenges_solved: int = 0
 96    solve_rate: float = 0.0
 97    questions_solved: int = 0
 98    total_tokens: int = 0
 99    #: estimated USD cost summed across the harness's runs (0 when unpriced).
100    cost_usd: float = 0.0

One harness's aggregate scorecard across all the models it drove.

The harness's runs are pooled across models: a challenge counts as solved if any run under the harness solved it. Like the vendor rollup this ranks harness plus its model fleet, not the harness in isolation — a harness paired with stronger models scores higher.

rank: int = 0
harness_id: str = ''
display_name: str = ''
models: int = 0
challenges_attempted: int = 0
challenges_solved: int = 0
solve_rate: float = 0.0
questions_solved: int = 0
total_tokens: int = 0
cost_usd: float = 0.0
class HealthResponse(ctfy.core.models.CtfyModel):
19class HealthResponse(CtfyModel):
20    status: str = "ok"
21    hostname: str = ""
22    running_instances: int = 0
23    capacity: int = 0
24    # Server's installed ``ctfy`` version. Lets the SDK/CLI flag a
25    # client/server skew off the probe they already make — no extra
26    # round trip. Defaults to "" so older servers (and the model's own
27    # default construction) stay valid.
28    version: str = ""
29    # Deployment tier + label, carried on the same probe so the CLI can
30    # warn an operator they're pointed at a non-production deployment (and
31    # add friction to destructive commands on prod) without a second
32    # round trip. ``prod`` is the fail-safe default.
33    environment: Environment = "prod"
34    environment_label: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

status: str = 'ok'
hostname: str = ''
running_instances: int = 0
capacity: int = 0
version: str = ''
environment: Literal['dev', 'staging', 'prod'] = 'prod'
environment_label: str = ''
class InboxAnnouncement(ctfy.core.models.CtfyModel):
160class InboxAnnouncement(CtfyModel):
161    """Announcement projected onto the Inbox surface.
162
163    Carries the same fields as :class:`AnnouncementInfo` plus a
164    per-user ``is_read`` flag derived from the
165    ``announcement_reads`` table. The frontend renders unread rows
166    prominently and lets the user mark them read via
167    ``POST /me/announcements/{id}/read``.
168    """
169
170    id: str
171    title: str
172    body: str
173    severity: AnnouncementSeverity
174    starts_at: datetime | None = None
175    ends_at: datetime | None = None
176    created_at: datetime | None = None
177    updated_at: datetime | None = None
178    created_by: str
179    created_by_name: str
180    is_read: bool = False

Announcement projected onto the Inbox surface.

Carries the same fields as AnnouncementInfo plus a per-user is_read flag derived from the announcement_reads table. The frontend renders unread rows prominently and lets the user mark them read via POST /me/announcements/{id}/read.

id: str = PydanticUndefined
title: str = PydanticUndefined
body: str = PydanticUndefined
severity: Literal['info', 'warning', 'critical'] = PydanticUndefined
starts_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
created_at: datetime.datetime | None = None
updated_at: datetime.datetime | None = None
created_by: str = PydanticUndefined
created_by_name: str = PydanticUndefined
is_read: bool = False
class InboxCaptainRequest(ctfy.core.models.CtfyModel):
125class InboxCaptainRequest(CtfyModel):
126    """Pending join request against any team the calling user
127    captains. Frontend renders an Approve / Reject pair pointing at
128    ``/competitions/{competition_id}/invites/{invite_id}/approve|reject``.
129    """
130
131    id: str
132    competition_id: str
133    competition_title: str
134    team_id: str
135    team_name: str
136    requester_user_id: str
137    requester_display_name: str
138    created_at: datetime | None = None

Pending join request against any team the calling user captains. Frontend renders an Approve / Reject pair pointing at /competitions/{competition_id}/invites/{invite_id}/approve|reject.

id: str = PydanticUndefined
competition_id: str = PydanticUndefined
competition_title: str = PydanticUndefined
team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
requester_user_id: str = PydanticUndefined
requester_display_name: str = PydanticUndefined
created_at: datetime.datetime | None = None
class InboxCompetitionInvite(ctfy.core.models.CtfyModel):
141class InboxCompetitionInvite(CtfyModel):
142    """A standing invitation to participate in a PRIVATE competition the
143    calling user has been granted but not yet joined.
144
145    Unlike the team invites above this isn't accept/decline — the
146    allowlist row is a standing permission, so the frontend renders a
147    "Register" CTA linking to ``/competitions/{competition_id}/team``.
148    Drops off the inbox once the user registers (or the comp ends / is
149    flipped back to public).
150    """
151
152    competition_id: str
153    competition_title: str
154    competition_phase: Literal["upcoming", "running", "past"]
155    invited_by_user_id: str = ""
156    invited_by_display_name: str = ""
157    invited_at: datetime | None = None

A standing invitation to participate in a PRIVATE competition the calling user has been granted but not yet joined.

Unlike the team invites above this isn't accept/decline — the allowlist row is a standing permission, so the frontend renders a "Register" CTA linking to /competitions/{competition_id}/team. Drops off the inbox once the user registers (or the comp ends / is flipped back to public).

competition_id: str = PydanticUndefined
competition_title: str = PydanticUndefined
competition_phase: Literal['upcoming', 'running', 'past'] = PydanticUndefined
invited_by_user_id: str = ''
invited_by_display_name: str = ''
invited_at: datetime.datetime | None = None
class InboxIncomingInvite(ctfy.core.models.CtfyModel):
 92class InboxIncomingInvite(CtfyModel):
 93    """Pending direct invite where the calling user is the named target.
 94
 95    The frontend renders an Accept / Decline pair pointing at
 96    ``/competitions/{competition_id}/invites/{invite_id}/accept|decline``.
 97    """
 98
 99    id: str
100    competition_id: str
101    competition_title: str
102    team_id: str
103    team_name: str
104    captain_user_id: str
105    captain_display_name: str
106    created_at: datetime | None = None

Pending direct invite where the calling user is the named target.

The frontend renders an Accept / Decline pair pointing at /competitions/{competition_id}/invites/{invite_id}/accept|decline.

id: str = PydanticUndefined
competition_id: str = PydanticUndefined
competition_title: str = PydanticUndefined
team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
captain_user_id: str = PydanticUndefined
captain_display_name: str = PydanticUndefined
created_at: datetime.datetime | None = None
class InboxOutgoingRequest(ctfy.core.models.CtfyModel):
109class InboxOutgoingRequest(CtfyModel):
110    """Pending join request the calling user opened — surfaces so the
111    requester can see "did the captain see this yet?" without
112    refreshing the team page.
113    """
114
115    id: str
116    competition_id: str
117    competition_title: str
118    team_id: str
119    team_name: str
120    captain_user_id: str
121    captain_display_name: str
122    created_at: datetime | None = None

Pending join request the calling user opened — surfaces so the requester can see "did the captain see this yet?" without refreshing the team page.

id: str = PydanticUndefined
competition_id: str = PydanticUndefined
competition_title: str = PydanticUndefined
team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
captain_user_id: str = PydanticUndefined
captain_display_name: str = PydanticUndefined
created_at: datetime.datetime | None = None
class InboxResponse(ctfy.core.models.CtfyModel):
183class InboxResponse(CtfyModel):
184    """``GET /me/inbox`` payload — pending invites/requests +
185    actionable announcements grouped by the action the calling
186    user can take.
187    """
188
189    incoming_invites: list[InboxIncomingInvite] = Field(default_factory=list)
190    outgoing_requests: list[InboxOutgoingRequest] = Field(default_factory=list)
191    captain_requests: list[InboxCaptainRequest] = Field(default_factory=list)
192    # Standing participation invites to private competitions the user
193    # hasn't joined yet (rendered with a "Register" CTA, not accept/decline).
194    competition_invites: list[InboxCompetitionInvite] = Field(default_factory=list)
195    # Site-wide announcements relevant right now (currently live + a
196    # short tail of recently-expired) with the user's read state.
197    announcements: list[InboxAnnouncement] = Field(default_factory=list)
198    # Convenience count so the sidebar can render an unread badge
199    # without re-iterating ``announcements``.
200    unread_announcement_count: int = 0

GET /me/inbox payload — pending invites/requests + actionable announcements grouped by the action the calling user can take.

incoming_invites: list[InboxIncomingInvite] = PydanticUndefined
outgoing_requests: list[InboxOutgoingRequest] = PydanticUndefined
captain_requests: list[InboxCaptainRequest] = PydanticUndefined
competition_invites: list[InboxCompetitionInvite] = PydanticUndefined
announcements: list[InboxAnnouncement] = PydanticUndefined
unread_announcement_count: int = 0
class InstanceInfo(ctfy.core.models.CtfyModel):
 65class InstanceInfo(CtfyModel):
 66    id: str
 67    challenge_id: str = ""
 68    team_id: str = ""
 69    # Competition the instance is scoped to. Stamped from the resolved
 70    # per-comp team at start time; empty for legacy rows or unscoped
 71    # admin instances. Frontend keys per-comp instance pages off this
 72    # field so a user registered for two comps doesn't see comp A's
 73    # instances in comp B's tab.
 74    competition_id: str = ""
 75    # Worker node currently hosting this instance. Empty only for
 76    # records created before nodes were tracked (legacy).
 77    node_id: str = ""
 78    name: str = ""
 79    category: ChallengeCategory | None = None
 80    difficulty: str = ""
 81    status: str = InstanceStatus.STARTING
 82    started_at: float = 0.0
 83    ttl: int = 0
 84    expires_at: float = 0.0
 85    services: list[ServiceEndpoint] = Field(default_factory=list)
 86    description: str = ""
 87    # Per-instance question view: same shape as ``ChallengeInfo.questions``
 88    # but additionally carries the agent-visible state. ``unlocked`` is
 89    # ``False`` when the question's ``requires:`` predecessors haven't
 90    # all been answered correctly yet; the UI hides the prompt for locked
 91    # questions to avoid leaking route hints. ``answered_correctly`` is
 92    # ``True`` once this team has captured this question on this instance.
 93    questions: list[InstanceQuestionInfo] = Field(default_factory=list)
 94    # Mirrored from ``ChallengeInfo.attachments`` so players who jump
 95    # straight to the instance page (e.g. via a launch link) see the
 96    # download list without re-fetching the catalog row.
 97    attachments: list[AttachmentInfo] = Field(default_factory=list)
 98    # How the platform exposes this instance to the player. Always
 99    # surfaced (defaults to ``"simple"``) so the agent can branch on
100    # `if info.network_topology == "engagement": ...` without dealing
101    # with a missing field.
102    network_topology: Literal["simple", "engagement"] = "simple"
103    # Set only when ``network_topology == "engagement"``. Carries the
104    # tunnel endpoint host:port + the URL the client downloads the
105    # `.ovpn` body from. ``None`` in simple mode.
106    vpn_endpoint: VpnEndpoint | None = None
107    # Landing-page hints declared in the challenge's ``metadata.yaml``
108    # (``entry_urls:`` field, validated by META013). Surfaced verbatim
109    # to the frontend so the VPN / services panel can render a
110    # "start here" list without the player having to read the
111    # description for the canonical first URL.
112    entry_urls: list[str] = Field(default_factory=list)
113    # Which supply this box came from. ``on_demand`` is a player's own
114    # launch and behaves the way every instance always has; ``match`` is
115    # an arena gamebox an organiser provisioned, and it is a genuinely
116    # different object to its owner — it never expires, stopping it is
117    # not theirs to do, and it is the one the round loop rotates flags
118    # into. The client cannot tell them apart from `challenge_id` alone,
119    # because a player may also launch the gamebox on demand.
120    lease: Literal["on_demand", "match"] = "on_demand"

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

id: str = PydanticUndefined
challenge_id: str = ''
team_id: str = ''
competition_id: str = ''
node_id: str = ''
name: str = ''
category: ctfy.challenge.benchmark.ChallengeCategory | None = None
difficulty: str = ''
status: str = <InstanceStatus.STARTING: 'starting'>
started_at: float = 0.0
ttl: int = 0
expires_at: float = 0.0
services: list[ctfy.core.target.ServiceEndpoint] = PydanticUndefined
description: str = ''
questions: list[InstanceQuestionInfo] = PydanticUndefined
attachments: list[ctfy.core.target.AttachmentInfo] = PydanticUndefined
network_topology: Literal['simple', 'engagement'] = 'simple'
vpn_endpoint: ctfy.core.target.VpnEndpoint | None = None
entry_urls: list[str] = PydanticUndefined
lease: Literal['on_demand', 'match'] = 'on_demand'
class InstanceQuestionInfo(ctfy.core.models.CtfyModel):
27class InstanceQuestionInfo(CtfyModel):
28    """One question, projected for a specific running instance.
29
30    Mirrors :class:`QuestionPublicInfo` plus per-instance state. The
31    ``prompt`` is replaced with an empty string when ``unlocked`` is
32    ``False`` so dependent questions never leak their route hints
33    before the predecessor is solved.
34    """
35
36    id: str
37    prompt: str
38    mode: str | None = None
39    choices: list[str] | None = None
40    requires: list[str] = Field(default_factory=list)
41    # ``True`` once every id in ``requires`` has been captured by the
42    # calling team on this instance. ``False`` keeps the prompt empty
43    # to avoid leaking the question text early.
44    unlocked: bool = True
45    # ``True`` once the calling team has submitted a correct answer
46    # for this question against this instance. Drives the UI checkmark.
47    answered_correctly: bool = False
48    # Wrong-attempt budget remaining for the calling team on this
49    # specific question. ``None`` when the question's mode is uncapped
50    # (dynamic free-form, or any mode the operator opted out of via
51    # ``question_attempt_caps``). The UI renders an ``X/N attempts``
52    # badge from this on first page load and decrements it locally on
53    # each wrong submit (the submission response also carries it).
54    attempts_remaining: int | None = None
55    # The cap that ``attempts_remaining`` is being measured against —
56    # echoed so the UI can render ``X/N`` without recomputing N from
57    # the question's mode + current platform settings.
58    attempts_cap: int | None = None
59    # ``True`` for a checker question whose ``check.poll`` is on: the
60    # platform auto-verifies it in the background, so the UI shows an
61    # "auto-verify on" affordance. Never exposes the checker command.
62    auto_check: bool = False

One question, projected for a specific running instance.

Mirrors QuestionPublicInfo plus per-instance state. The prompt is replaced with an empty string when unlocked is False so dependent questions never leak their route hints before the predecessor is solved.

id: str = PydanticUndefined
prompt: str = PydanticUndefined
mode: str | None = None
choices: list[str] | None = None
requires: list[str] = PydanticUndefined
unlocked: bool = True
answered_correctly: bool = False
attempts_remaining: int | None = None
attempts_cap: int | None = None
auto_check: bool = False
class InstanceRecordArtifacts(ctfy.core.models.CtfyModel):
165class InstanceRecordArtifacts(CtfyModel):
166    """Per-artifact presence flags + counts for an archived instance.
167
168    Returned alongside the full :class:`InstanceRecord` from
169    ``GET /admin/instance-records/{id}`` so the admin UI can show
170    ``Manifest / Events / Submissions / Container log`` tabs at a glance
171    without a round-trip per tab.
172    """
173
174    has_manifest: bool = False
175    has_container_log: bool = False
176    events_count: int = 0
177    submissions_count: int = 0
178    traffic_count: int = 0
179    # Bytes on disk for the archived ``traffic.pcap``. 0 when no capture
180    # was persisted (sidecar disabled, archive disabled, or fetch
181    # failed). The admin UI uses this both to gate the download button
182    # and to render an "X MB" hint next to it.
183    pcap_bytes: int = 0

Per-artifact presence flags + counts for an archived instance.

Returned alongside the full InstanceRecord from GET /admin/instance-records/{id} so the admin UI can show Manifest / Events / Submissions / Container log tabs at a glance without a round-trip per tab.

has_manifest: bool = False
has_container_log: bool = False
events_count: int = 0
submissions_count: int = 0
traffic_count: int = 0
pcap_bytes: int = 0
class InstanceRecordDetail(ctfy.core.models.CtfyModel):
186class InstanceRecordDetail(CtfyModel):
187    record: InstanceRecordInfoDetail
188    artifacts: InstanceRecordArtifacts = Field(default_factory=InstanceRecordArtifacts)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

record: InstanceRecordInfoDetail = PydanticUndefined
artifacts: InstanceRecordArtifacts = PydanticUndefined
class InstanceRecordInfo(ctfy.core.models.CtfyModel):
123class InstanceRecordInfo(CtfyModel):
124    """Archived-instance summary for the admin history list.
125
126    Mirrors :class:`ctfy.core.state.models.InstanceRecord` minus the heavy
127    fields (``spec``, ``surface``). Used by ``GET /admin/instance-records``.
128    """
129
130    id: str
131    team_id: str = ""
132    challenge_id: str = ""
133    node_id: str = ""
134    name: str = ""
135    category: ChallengeCategory | None = None
136    difficulty: str = ""
137    status: str = ""
138    stop_reason: str = ""
139    error: str = ""
140    # Full node diagnostic (e.g. ``docker compose up`` stderr tail).
141    # Admin-only: this model backs ``/admin/instance-records*`` only;
142    # the player-facing ``InstanceInfo`` / ``InstanceStatusResponse``
143    # deliberately omit it.
144    error_detail: str = ""
145    started_at: float = 0.0
146    stopped_at: float = 0.0
147    duration_s: float = 0.0
148    ttl: int = 0
149    solved: bool = False
150    attempts: int = 0
151    # Total HTTP requests captured by the mitmproxy sidecar during the
152    # instance's lifetime. 0 when the archive sink was disabled or no
153    # traffic was captured.
154    request_count: int = 0

Archived-instance summary for the admin history list.

Mirrors ctfy.core.state.models.InstanceRecord minus the heavy fields (spec, surface). Used by GET /admin/instance-records.

id: str = PydanticUndefined
team_id: str = ''
challenge_id: str = ''
node_id: str = ''
name: str = ''
category: ctfy.challenge.benchmark.ChallengeCategory | None = None
difficulty: str = ''
status: str = ''
stop_reason: str = ''
error: str = ''
error_detail: str = ''
started_at: float = 0.0
stopped_at: float = 0.0
duration_s: float = 0.0
ttl: int = 0
solved: bool = False
attempts: int = 0
request_count: int = 0
class InstanceRecordInfoDetail(ctfy.server.models.InstanceRecordInfo):
157class InstanceRecordInfoDetail(InstanceRecordInfo):
158    """Full record — includes spec + surface, returned by detail endpoint."""
159
160    spec: dict[str, Any] = Field(default_factory=dict)
161    surface: AttackSurface | None = None
162    artifact_dir: str = ""

Full record — includes spec + surface, returned by detail endpoint.

spec: dict[str, typing.Any] = PydanticUndefined
surface: ctfy.core.target.AttackSurface | None = None
artifact_dir: str = ''
class InstanceStatusResponse(ctfy.core.models.CtfyModel):
248class InstanceStatusResponse(CtfyModel):
249    id: str
250    status: str = InstanceStatus.STARTING
251    attack_surface: AttackSurface | None = None
252    error: str = ""
253    # CA volume for the per-instance mitmproxy. Operators trust this CA
254    # if they want to MITM HTTPS; HTTP traffic is captured transparently
255    # without it.
256    cert_volume: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

id: str = PydanticUndefined
status: str = <InstanceStatus.STARTING: 'starting'>
attack_surface: ctfy.core.target.AttackSurface | None = None
error: str = ''
cert_volume: str = ''
class LeaderboardCell(ctfy.core.models.CtfyModel):
17class LeaderboardCell(CtfyModel):
18    """One model's solve stats within one dimension bucket (e.g. difficulty=hard)."""
19
20    key: str = ""
21    attempted: int = 0
22    solved: int = 0
23    solve_rate: float = 0.0

One model's solve stats within one dimension bucket (e.g. difficulty=hard).

key: str = ''
attempted: int = 0
solved: int = 0
solve_rate: float = 0.0
class LinkStartResponse(ctfy.core.models.CtfyModel):
217class LinkStartResponse(CtfyModel):
218    """Returned by ``POST /auth/identities/link/{provider}``.
219
220    The caller is already authenticated via XHR, so we don't 302 them;
221    instead we hand back the authorize URL for the frontend to navigate
222    the browser to.
223    """
224
225    authorize_url: str

Returned by POST /auth/identities/link/{provider}.

The caller is already authenticated via XHR, so we don't 302 them; instead we hand back the authorize URL for the frontend to navigate the browser to.

authorize_url: str = PydanticUndefined
class LinkedIdentity(ctfy.core.models.CtfyModel):
53class LinkedIdentity(CtfyModel):
54    id: str
55    # "github" | "google" (OAuth) or "password" for a local credential.
56    provider: str
57    provider_email: str = ""
58    provider_display_name: str = ""
59    # Stable third-party account id (GitHub numeric id, Google `sub`);
60    # empty for the local password credential.
61    provider_user_id: str = ""
62    # GitHub `@handle`; empty for Google and password.
63    provider_login: str = ""
64    avatar_url: str = ""
65    created_at: datetime | None = None
66    last_used_at: datetime | None = None

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

id: str = PydanticUndefined
provider: str = PydanticUndefined
provider_email: str = ''
provider_display_name: str = ''
provider_user_id: str = ''
provider_login: str = ''
avatar_url: str = ''
created_at: datetime.datetime | None = None
last_used_at: datetime.datetime | None = None
class LlmBudgetPage(ctfy.core.models.CtfyModel):
456class LlmBudgetPage(CtfyModel):
457    """The organiser's LLM-spend roster.
458
459    ``gateway_configured`` is on the envelope for the reason
460    ``webhook_configured`` is on the email-suppression list: an empty
461    table otherwise reads as "nobody has spent anything" when it means
462    "this deployment enforces no budgets at all", and those call for
463    opposite actions.
464    """
465
466    items: list[LlmBudgetRow]
467    total: int
468    gateway_configured: bool

The organiser's LLM-spend roster.

gateway_configured is on the envelope for the reason webhook_configured is on the email-suppression list: an empty table otherwise reads as "nobody has spent anything" when it means "this deployment enforces no budgets at all", and those call for opposite actions.

items: list[LlmBudgetRow] = PydanticUndefined
total: int = PydanticUndefined
gateway_configured: bool = PydanticUndefined
class LlmBudgetResetInfo(ctfy.core.models.CtfyModel):
396class LlmBudgetResetInfo(CtfyModel):
397    """Response from the LLM-budget reset endpoint.
398
399    ⚠️ ``outcome`` is the point of the model. A reset touches two stores
400    and can legitimately land as *nothing to reset*; reporting only
401    ``200`` would make "the team has a fresh allowance" and "the gateway
402    is unreachable so nothing changed" the same reply, which is exactly
403    the failure the seam is written to avoid.
404    """
405
406    team_id: str
407    competition_id: str
408    challenge_id: str
409    outcome: str
410    reason: str

Response from the LLM-budget reset endpoint.

⚠️ outcome is the point of the model. A reset touches two stores and can legitimately land as nothing to reset; reporting only 200 would make "the team has a fresh allowance" and "the gateway is unreachable so nothing changed" the same reply, which is exactly the failure the seam is written to avoid.

team_id: str = PydanticUndefined
competition_id: str = PydanticUndefined
challenge_id: str = PydanticUndefined
outcome: str = PydanticUndefined
reason: str = PydanticUndefined
class LlmBudgetResetRequest(ctfy.core.models.CtfyModel):
386class LlmBudgetResetRequest(CtfyModel):
387    """Request body for ``POST /admin/teams/.../llm-budget/.../reset``.
388
389    ``reason`` is free-form audit metadata, capped like its
390    question-attempt twin so it round-trips through the SSE frame.
391    """
392
393    reason: str = Field(default="", max_length=500)

Request body for POST /admin/teams/.../llm-budget/.../reset.

reason is free-form audit metadata, capped like its question-attempt twin so it round-trips through the SSE frame.

reason: str = ''
class LlmBudgetRow(ctfy.core.models.CtfyModel):
413class LlmBudgetRow(CtfyModel):
414    """One team's LLM spend on one challenge.
415
416    ⚠️ ``budget_tokens`` is the cap **as configured right now**, not the
417    cap that applied when the spend happened: it is one deployment-wide
418    setting the operator can retune mid-event. So a row can read
419    ``spent 90k of 200k`` for a team that really was refused at 50k, and
420    the honest place to see what happened *then* is the
421    ``llm_quota_exhausted`` activity row, which carries the cap it
422    crossed. Reporting a per-row historical cap would mean storing one,
423    which is a column that exists only to be believed.
424
425    ``exhausted`` is derived from the pair rather than stored, for the
426    same reason: it is a statement about the two numbers on this row and
427    must not disagree with them.
428    """
429
430    team_id: str
431    team_name: str = ""
432    competition_id: str
433    challenge_id: str
434    spent_tokens: int
435    budget_tokens: int
436    #: Whether this team still holds a live grant for this challenge.
437    #: A budget outlives the box that spent it — the key is
438    #: ``(team, competition, challenge)`` precisely so a fresh instance
439    #: does not refill — so a row with no live grant is the normal
440    #: shape after a box stops, not an orphan.
441    live: bool = False
442
443    @computed_field
444    @property
445    def exhausted(self) -> bool:
446        """⚠️ A **`computed_field`**, unlike ``TeamState.is_disqualified``
447        which is deliberately a plain ``@property``. Opposite reasons,
448        and the distinction is the model's kind: that one is *state*, so
449        ``model_dump`` feeds ``to_orm`` and every dumped key has to be a
450        column; this is a *wire* model whose whole purpose is to reach a
451        client, and a plain property simply never leaves the process.
452        """
453        return self.budget_tokens > 0 and self.spent_tokens >= self.budget_tokens

One team's LLM spend on one challenge.

⚠️ budget_tokens is the cap as configured right now, not the cap that applied when the spend happened: it is one deployment-wide setting the operator can retune mid-event. So a row can read spent 90k of 200k for a team that really was refused at 50k, and the honest place to see what happened then is the llm_quota_exhausted activity row, which carries the cap it crossed. Reporting a per-row historical cap would mean storing one, which is a column that exists only to be believed.

exhausted is derived from the pair rather than stored, for the same reason: it is a statement about the two numbers on this row and must not disagree with them.

team_id: str = PydanticUndefined
team_name: str = ''
competition_id: str = PydanticUndefined
challenge_id: str = PydanticUndefined
spent_tokens: int = PydanticUndefined
budget_tokens: int = PydanticUndefined
live: bool = False
exhausted: bool
443    @computed_field
444    @property
445    def exhausted(self) -> bool:
446        """⚠️ A **`computed_field`**, unlike ``TeamState.is_disqualified``
447        which is deliberately a plain ``@property``. Opposite reasons,
448        and the distinction is the model's kind: that one is *state*, so
449        ``model_dump`` feeds ``to_orm`` and every dumped key has to be a
450        column; this is a *wire* model whose whole purpose is to reach a
451        client, and a plain property simply never leaves the process.
452        """
453        return self.budget_tokens > 0 and self.spent_tokens >= self.budget_tokens

⚠️ A computed_field, unlike TeamState.is_disqualified which is deliberately a plain @property. Opposite reasons, and the distinction is the model's kind: that one is state, so model_dump feeds to_orm and every dumped key has to be a column; this is a wire model whose whole purpose is to reach a client, and a plain property simply never leaves the process.

class LoginRequest(ctfy.core.models.CtfyModel):
86class LoginRequest(CtfyModel):
87    email: EmailStr
88    password: str = Field(min_length=1, max_length=256)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

email: pydantic.networks.EmailStr = PydanticUndefined
password: str = PydanticUndefined
class MeResponse(ctfy.server.models.UserInfo):
33class MeResponse(UserInfo):
34    """``GET /me`` payload — the logged-in user's full profile.
35
36    Extends :class:`UserInfo` with auth-state context so the frontend
37    can render Settings (linked providers, admin badge) with a single
38    request. ``providers`` lists provider names currently bound
39    (e.g. ``["github", "google"]``); ``token_kind`` distinguishes
40    ``"user"`` (browser session) from ``"fine_grained"`` (CLI / agent
41    token).
42
43    ``is_admin`` is the OR of admin + super_admin so existing clients
44    keep working without inspecting ``role`` directly.
45
46    ``competition_teams`` carries every per-comp team the user is
47    currently on. The legacy ``current_team_id`` /
48    ``current_team_name`` fields were dropped — there is no global
49    "current team" any more.
50    """
51
52    email: str = ""
53    is_admin: bool = False
54    role: Literal["user", "admin", "super_admin"] = "user"
55    providers: list[str] = Field(default_factory=list)
56    token_kind: str = "user"
57    profile_visibility: dict[str, bool] = Field(default_factory=dict)
58    competition_teams: list[CompetitionMembershipInfo] = Field(default_factory=list)
59    # Competitions this user per-comp-administers. Empty for global
60    # admins/super_admins (already a superset — the frontend treats
61    # ``is_admin`` as "admin of every competition").
62    competition_admin_ids: list[str] = Field(default_factory=list)
63    # Competitions whose registration roster this user may read + rule on.
64    # A superset of ``competition_admin_ids`` (an admin can review), plus
65    # any narrow ``reviewer`` grant. Drives whether the frontend shows the
66    # registrations link; the server re-checks on every request.
67    competition_reviewer_ids: list[str] = Field(default_factory=list)

GET /me payload — the logged-in user's full profile.

Extends UserInfo with auth-state context so the frontend can render Settings (linked providers, admin badge) with a single request. providers lists provider names currently bound (e.g. ["github", "google"]); token_kind distinguishes "user" (browser session) from "fine_grained" (CLI / agent token).

is_admin is the OR of admin + super_admin so existing clients keep working without inspecting role directly.

competition_teams carries every per-comp team the user is currently on. The legacy current_team_id / current_team_name fields were dropped — there is no global "current team" any more.

email: str = ''
is_admin: bool = False
role: Literal['user', 'admin', 'super_admin'] = 'user'
providers: list[str] = PydanticUndefined
token_kind: str = 'user'
profile_visibility: dict[str, bool] = PydanticUndefined
competition_teams: list[CompetitionMembershipInfo] = PydanticUndefined
competition_admin_ids: list[str] = PydanticUndefined
competition_reviewer_ids: list[str] = PydanticUndefined
class MetaChallenges(ctfy.core.models.CtfyModel):
58class MetaChallenges(CtfyModel):
59    """Source-control identity of the challenges repository, plus the
60    public count of available challenges.
61
62    ``commit_sha`` is None when the challenges directory isn't a git
63    working tree (e.g. ``CTFY_CHALLENGES_REPO=""`` or a bare checkout).
64    ``total`` is visible to everyone — the list itself is browsable.
65
66    ``suites`` / ``environments`` are the other two axes of "how big is
67    the catalog" (see :class:`~ctfy.core.challenge.CatalogCounts`):
68    distinct source buckets, and the subset that ships a runnable
69    Docker topology rather than being a pure QA item. Both are derived
70    from the same public spec list, so they carry no more information
71    than ``GET /challenges`` already does.
72    """
73
74    commit_sha: str | None = None
75    commit_url: str | None = None
76    repo_url: str = ""
77    total: int = 0
78    suites: int = 0
79    environments: int = 0

Source-control identity of the challenges repository, plus the public count of available challenges.

commit_sha is None when the challenges directory isn't a git working tree (e.g. CTFY_CHALLENGES_REPO="" or a bare checkout). total is visible to everyone — the list itself is browsable.

suites / environments are the other two axes of "how big is the catalog" (see ~ctfy.core.challenge.CatalogCounts): distinct source buckets, and the subset that ships a runnable Docker topology rather than being a pure QA item. Both are derived from the same public spec list, so they carry no more information than GET /challenges already does.

commit_sha: str | None = None
commit_url: str | None = None
repo_url: str = ''
total: int = 0
suites: int = 0
environments: int = 0
class MetaPlatform(ctfy.core.models.CtfyModel):
44class MetaPlatform(CtfyModel):
45    """Source-control identity of the running ctfy build.
46
47    ``commit_sha`` is None when the build couldn't determine its own
48    revision (no ``CTFY_GIT_COMMIT``, no usable git binary).
49    ``commit_url`` is built from ``repo_url`` + sha so the frontend
50    doesn't have to know the URL convention.
51    """
52
53    commit_sha: str | None = None
54    commit_url: str | None = None
55    repo_url: str = ""

Source-control identity of the running ctfy build.

commit_sha is None when the build couldn't determine its own revision (no CTFY_GIT_COMMIT, no usable git binary). commit_url is built from repo_url + sha so the frontend doesn't have to know the URL convention.

commit_sha: str | None = None
commit_url: str | None = None
repo_url: str = ''
class MetaResponse(ctfy.core.models.CtfyModel):
 82class MetaResponse(CtfyModel):
 83    version: str = ""
 84    platform: MetaPlatform = Field(default_factory=MetaPlatform)
 85    challenges: MetaChallenges = Field(default_factory=MetaChallenges)
 86    started_at_ts: float = 0.0
 87    server_time_ts: float = 0.0
 88    # Cluster-wide counters — admin-only. ``None`` for non-admin
 89    # callers so the frontend can distinguish "not authorised" from
 90    # "happens to be zero" and hide the segment entirely instead of
 91    # rendering a misleading "0 nodes".
 92    teams_total: int | None = None
 93    users_total: int | None = None
 94    nodes_total: int | None = None
 95    nodes_healthy: int | None = None
 96    running_instances: int | None = None
 97    solves_total: int | None = None
 98    # Public surface bit for the super-admin docker-exec feature. The
 99    # frontend uses this to decide whether to render the Shell entry
100    # in the admin instance list — false means the routes aren't
101    # mounted, so any UI link would 404. Never carries the
102    # token/recording details — those stay server-side.
103    admin_shell_enabled: bool = False
104    # Where a defender's own `ssh` should knock, and — by being empty —
105    # whether the channel exists at all. ``POST /instances/{id}/ssh``
106    # refuses with 503 unless the CA key *and* the bastion host are both
107    # configured, so these two report the same pair rather than the raw
108    # config values: advertising a host with no CA would put a command
109    # on the defence card that always 503s, which a player reads as
110    # their box being broken. Public deliberately, unlike the counters
111    # below — it is the address the player types, and knowing where to
112    # knock buys nothing without a certificate the platform mints per
113    # instance. ``ssh_bastion_port`` is 0 (not its 2222 config default)
114    # when the channel is off, so neither field can imply a bastion the
115    # other denies.
116    ssh_bastion_host: str = ""
117    ssh_bastion_port: int = 0
118    # Operator-set deployment label (``CTFY_ENVIRONMENT_LABEL``), shown
119    # as a badge next to the brand so a staging tab is visually distinct
120    # from production. Empty = no badge. Public on purpose: it names the
121    # deployment, not anything about its internals.
122    environment_label: str = ""
123    # Canonical tier derived from the label (``ctfy.core.environment``).
124    # The client keys the frame / favicon tone and the prod-only confirm
125    # friction off this instead of substring-matching the free-text label
126    # itself, so classification lives in one place. ``prod`` is the
127    # fail-safe default (the unmarked deployment is production).
128    environment: Environment = "prod"

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

version: str = ''
platform: MetaPlatform = PydanticUndefined
challenges: MetaChallenges = PydanticUndefined
started_at_ts: float = 0.0
server_time_ts: float = 0.0
teams_total: int | None = None
users_total: int | None = None
nodes_total: int | None = None
nodes_healthy: int | None = None
running_instances: int | None = None
solves_total: int | None = None
admin_shell_enabled: bool = False
ssh_bastion_host: str = ''
ssh_bastion_port: int = 0
environment_label: str = ''
environment: Literal['dev', 'staging', 'prod'] = 'prod'
class MilestoneProgress(ctfy.core.models.CtfyModel):
568class MilestoneProgress(CtfyModel):
569    """Per-challenge milestone progress for the calling user.
570
571    Returned by ``GET /me/milestone-progress``. One row per challenge
572    with at least one captured question, regardless of whether the
573    challenge is fully solved. The Challenges list page renders a
574    progress bar from ``len(solved_question_ids) / total_questions``
575    so a player can see "2/5 milestones" instead of an all-or-nothing
576    solved badge.
577
578    Like ``MySolveSummary`` this is user-scoped: ``solved_question_ids``
579    aggregates every question id the user captured for the challenge
580    across every team they have ever been on. The optional
581    ``competition_id`` query param narrows the aggregation to solves
582    stamped against the user's team in that comp.
583    """
584
585    challenge_id: str
586    solved_question_ids: list[str] = Field(default_factory=list)
587    total_questions: int = 0

Per-challenge milestone progress for the calling user.

Returned by GET /me/milestone-progress. One row per challenge with at least one captured question, regardless of whether the challenge is fully solved. The Challenges list page renders a progress bar from len(solved_question_ids) / total_questions so a player can see "2/5 milestones" instead of an all-or-nothing solved badge.

Like MySolveSummary this is user-scoped: solved_question_ids aggregates every question id the user captured for the challenge across every team they have ever been on. The optional competition_id query param narrows the aggregation to solves stamped against the user's team in that comp.

challenge_id: str = PydanticUndefined
solved_question_ids: list[str] = PydanticUndefined
total_questions: int = 0
class ModelStanding(ctfy.core.models.CtfyModel):
26class ModelStanding(CtfyModel):
27    """One model's overall standing plus its per-dimension breakdowns."""
28
29    rank: int = 0
30    model_id: str = ""
31    display_name: str = ""
32    flavor: str = ""
33    #: owning vendor / org label (blank when unattributed).
34    vendor: str = ""
35    runs: int = 0
36    challenges_attempted: int = 0
37    challenges_solved: int = 0
38    solve_rate: float = 0.0
39    questions_solved: int = 0
40    input_tokens: int = 0
41    output_tokens: int = 0
42    #: input + output across all the model's succeeded runs.
43    total_tokens: int = 0
44    #: cost-efficiency: total tokens per solved challenge (0 if none solved).
45    tokens_per_solve: float = 0.0
46    #: estimated USD cost (tokens × the model's price); 0 when unpriced.
47    cost_usd: float = 0.0
48    #: USD per solved challenge (0 when unpriced or nothing solved).
49    cost_per_solve: float = 0.0
50    avg_turns: float = 0.0
51    #: deepest pass@k sampling — max succeeded runs against any one challenge.
52    max_samples: int = 0
53    #: corpus version these runs were graded against (the model's modal sha).
54    corpus_sha: str = ""
55    #: dimension name (``difficulty`` / ``bucket`` / ``tag``) → its cells.
56    breakdowns: dict[str, list[LeaderboardCell]] = Field(default_factory=dict)

One model's overall standing plus its per-dimension breakdowns.

rank: int = 0
model_id: str = ''
display_name: str = ''
flavor: str = ''
vendor: str = ''
runs: int = 0
challenges_attempted: int = 0
challenges_solved: int = 0
solve_rate: float = 0.0
questions_solved: int = 0
input_tokens: int = 0
output_tokens: int = 0
total_tokens: int = 0
tokens_per_solve: float = 0.0
cost_usd: float = 0.0
cost_per_solve: float = 0.0
avg_turns: float = 0.0
max_samples: int = 0
corpus_sha: str = ''
breakdowns: dict[str, list[LeaderboardCell]] = PydanticUndefined
class MyAchievementsResponse(ctfy.core.models.CtfyModel):
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).

unlocked: list[TeamAchievement] = PydanticUndefined
locked: list[AchievementCatalogEntry] = PydanticUndefined
summary: AchievementSummary = PydanticUndefined
class MyReactionsResponse(ctfy.core.models.CtfyModel):
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.

reactions: list[typing.Literal['addictive', 'mindblown', 'learned', 'goat_setter', 'overthought', 'guessy', 'brutal', 'buggy', 'infra_broken']] = PydanticUndefined
updated_at: datetime.datetime | None = None
class MyRegistrationResponse(ctfy.core.models.CtfyModel):
107class MyRegistrationResponse(CtfyModel):
108    """``GET /competitions/{id}/registration`` — the caller's own dossier.
109
110    Carries the form spec alongside the values so the wizard can render
111    from one round trip, and ``missing_required`` so it can tell the user
112    exactly what still blocks submission without reimplementing the
113    server's rule.
114    """
115
116    competition_id: str
117    form: RegistrationFormSpec
118    registered: bool = False
119    submitted_at: datetime | None = None
120    updated_at: datetime | None = None
121    missing_required: list[str] = Field(default_factory=list)
122
123    real_name: str = ""
124    phone: str = ""
125    mailing_address: str = ""
126    gender: str = ""
127    organization: str = ""
128    academic_stage: str = ""
129    grade: str = ""
130    undergrad_school: str = ""
131    specialties: list[str] = Field(default_factory=list)
132
133    # Team-scoped answers, echoed so the captain's wizard round-trips.
134    team_id: str = ""
135    team_name: str = ""
136    team_honors: str = ""
137    team_has_logo: bool = False
138    is_captain: bool = False
139    review_status: RegistrationReviewStatus = "approved"
140    review_note: str = ""

GET /competitions/{id}/registration — the caller's own dossier.

Carries the form spec alongside the values so the wizard can render from one round trip, and missing_required so it can tell the user exactly what still blocks submission without reimplementing the server's rule.

competition_id: str = PydanticUndefined
form: RegistrationFormSpec = PydanticUndefined
registered: bool = False
submitted_at: datetime.datetime | None = None
updated_at: datetime.datetime | None = None
missing_required: list[str] = PydanticUndefined
real_name: str = ''
phone: str = ''
mailing_address: str = ''
gender: str = ''
organization: str = ''
academic_stage: str = ''
grade: str = ''
undergrad_school: str = ''
specialties: list[str] = PydanticUndefined
team_id: str = ''
team_name: str = ''
team_honors: str = ''
is_captain: bool = False
review_status: Literal['pending', 'approved', 'rejected'] = 'approved'
review_note: str = ''
class MySolveSummary(ctfy.core.models.CtfyModel):
540class MySolveSummary(CtfyModel):
541    """Per-challenge solve summary for the calling team.
542
543    Returned by ``GET /me/solves``. One row per challenge with at least
544    one captured flag. ``best_rank`` is the team's best (smallest) rank
545    across the flags they captured for the challenge — drives the
546    1血/2血/3血 badge on the challenge cards. Multi-flag challenges may
547    have different ranks per flag; reporting the best one gives players
548    credit for whichever piece they nailed first.
549    """
550
551    challenge_id: str
552    best_rank: int
553    solved_at: datetime | None = None
554    # Whether **every** capture the team holds on this challenge is on
555    # the competition's board.
556    #
557    # ⚠️ ``all``, not ``any``: the card renders this as "some of what you
558    # captured here does not count", and a partial split has to say so.
559    # ``any`` would stay quiet about two uncounted flags out of three,
560    # which is the same silence as before the field existed.
561    #
562    # Unscoped (``competition_id=""``) this is always ``True``: the
563    # cross-competition history applies no window, so no capture there
564    # is "not counted" — see ``/me/solves``.
565    ranked: bool = True

Per-challenge solve summary for the calling team.

Returned by GET /me/solves. One row per challenge with at least one captured flag. best_rank is the team's best (smallest) rank across the flags they captured for the challenge — drives the 1血/2血/3血 badge on the challenge cards. Multi-flag challenges may have different ranks per flag; reporting the best one gives players credit for whichever piece they nailed first.

challenge_id: str = PydanticUndefined
best_rank: int = PydanticUndefined
solved_at: datetime.datetime | None = None
ranked: bool = True
class NodeHeartbeat(ctfy.core.models.CtfyModel):
70class NodeHeartbeat(CtfyModel):
71    """Body for ``POST /nodes/heartbeat``.
72
73    Node reports its live counts + resource utilisation on every beat.
74    The percentages are 0–100; 0 is a safe default for the first beat
75    where psutil hasn't had a prior sample to diff against.
76    """
77
78    id: str
79    running: int = 0
80    capacity: int = 50
81    cpu_percent: float = 0.0
82    memory_percent: float = 0.0
83    # Physical RAM in MiB (not a percentage). Memory-aware admission
84    # needs the absolute size; 0 = not reported, which older node builds
85    # send and which admission reads as "unknown, fall back to slots".
86    memory_total_mb: float = 0.0
87    disk_percent: float = 0.0
88    # Unix seconds when the node sampled the metrics. The server stores
89    # ``received_at - sampled_at`` as one-way latency on the resulting
90    # health sample. Default 0 means "not measured" — older node builds
91    # that haven't been upgraded keep working.
92    sampled_at: float = 0.0

Body for POST /nodes/heartbeat.

Node reports its live counts + resource utilisation on every beat. The percentages are 0–100; 0 is a safe default for the first beat where psutil hasn't had a prior sample to diff against.

id: str = PydanticUndefined
running: int = 0
capacity: int = 50
cpu_percent: float = 0.0
memory_percent: float = 0.0
memory_total_mb: float = 0.0
disk_percent: float = 0.0
sampled_at: float = 0.0
class NodeInfo(ctfy.core.models.CtfyModel):
29class NodeInfo(CtfyModel):
30    id: str
31    url: str
32    display_name: str = ""
33    capacity: int = 0
34    running: int = 0
35    is_healthy: bool = True
36    last_heartbeat: datetime | None = None
37    # First successful registration. ``None`` only for rows that
38    # pre-date the field; read-path code in
39    # ``ctfy/server/routes/nodes.py`` falls back to ``last_heartbeat``
40    # so the admin UI never shows "—" for established nodes.
41    registered_at: datetime | None = None
42    labels: dict[str, str] = Field(default_factory=dict)
43    # Latest resource sample reported on heartbeat (0–100).
44    cpu_percent: float = 0.0
45    memory_percent: float = 0.0
46    disk_percent: float = 0.0
47    # Physical RAM in MiB; 0 = not reported yet. Surfaced so the admin
48    # Nodes page can show real headroom rather than a bare percentage.
49    memory_total_mb: float = 0.0
50    # Owning vendor slug for a bring-your-own-node; "" = platform-shared.
51    owner_vendor_id: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

id: str = PydanticUndefined
url: str = PydanticUndefined
display_name: str = ''
capacity: int = 0
running: int = 0
is_healthy: bool = True
last_heartbeat: datetime.datetime | None = None
registered_at: datetime.datetime | None = None
labels: dict[str, str] = PydanticUndefined
cpu_percent: float = 0.0
memory_percent: float = 0.0
disk_percent: float = 0.0
memory_total_mb: float = 0.0
owner_vendor_id: str = ''
class NodeInviteInfo(ctfy.core.models.CtfyModel):
141class NodeInviteInfo(CtfyModel):
142    """Admin-facing summary (``GET /nodes/invites``). No token fields."""
143
144    id: str
145    created_at: datetime | None = None
146    expires_at: datetime | None = None
147    status: str  # "active" | "consumed" | "expired"
148    consumed_by_node_id: str = ""
149    owner_vendor_id: str = ""  # vendor binding, if any ("" = shared)

Admin-facing summary (GET /nodes/invites). No token fields.

id: str = PydanticUndefined
created_at: datetime.datetime | None = None
expires_at: datetime.datetime | None = None
status: str = PydanticUndefined
consumed_by_node_id: str = ''
owner_vendor_id: str = ''
class NodePatch(ctfy.core.models.CtfyModel):
54class NodePatch(CtfyModel):
55    """Body for ``PATCH /admin/nodes/{node_id}``.
56
57    Fields are optional — absent fields keep their current value.
58    display_name is the common case; labels allow adding/replacing the
59    full dict (partial label edits need a second round-trip).
60    ``owner_vendor_id`` re-assigns (or clears, with ``""``) the node's
61    vendor binding — an admin correction path complementing the
62    invite-time binding.
63    """
64
65    display_name: str | None = None
66    labels: dict[str, str] | None = None
67    owner_vendor_id: str | None = None

Body for PATCH /admin/nodes/{node_id}.

Fields are optional — absent fields keep their current value. display_name is the common case; labels allow adding/replacing the full dict (partial label edits need a second round-trip). owner_vendor_id re-assigns (or clears, with "") the node's vendor binding — an admin correction path complementing the invite-time binding.

display_name: str | None = None
labels: dict[str, str] | None = None
owner_vendor_id: str | None = None
class NodeRegister(ctfy.core.models.CtfyModel):
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.

url: str = PydanticUndefined
display_name: str = PydanticUndefined
capacity: int = 50
labels: dict[str, str] = PydanticUndefined
class NodeRegisterResponse(ctfy.server.models.NodeInfo):
 95class NodeRegisterResponse(NodeInfo):
 96    """Registration response carries the node's bearer token (plaintext).
 97
 98    Returned exactly once, at registration. The same token is used in
 99    both directions:
100
101    * **node→platform** (heartbeat, deregister) — node presents it as
102      the Bearer; platform looks the row up by comparing plaintexts.
103    * **platform→node** (start/stop/status) — platform presents the
104      same plaintext on every call; node verifies against its in-memory
105      copy.
106
107    Re-registration rotates the token. The node never persists it on
108    disk; "restart = re-register = fresh credential".
109    """
110
111    token: str = ""

Registration response carries the node's bearer token (plaintext).

Returned exactly once, at registration. The same token is used in both directions:

  • node→platform (heartbeat, deregister) — node presents it as the Bearer; platform looks the row up by comparing plaintexts.
  • platform→node (start/stop/status) — platform presents the same plaintext on every call; node verifies against its in-memory copy.

Re-registration rotates the token. The node never persists it on disk; "restart = re-register = fresh credential".

token: str = ''
class NotificationCategoryInfo(ctfy.core.models.CtfyModel):
28class NotificationCategoryInfo(CtfyModel):
29    id: str
30    title: str
31    description: str
32    #: Account & security. Not switchable; the API refuses to store a cell.
33    forced: bool
34    #: ``transactional`` | ``marketing``. A marketing category is off
35    #: until the recipient explicitly turns it on, and a recorded
36    #: opt-out on one cannot be reverted away — so the UI must not
37    #: offer "revert to default" there, which would imply reverting
38    #: could switch it back on.
39    policy: str = "transactional"
40    channels: dict[str, NotificationChannelCell]

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

id: str = PydanticUndefined
title: str = PydanticUndefined
description: str = PydanticUndefined
forced: bool = PydanticUndefined
policy: str = 'transactional'
channels: dict[str, NotificationChannelCell] = PydanticUndefined
class NotificationChannelCell(ctfy.core.models.CtfyModel):
14class NotificationChannelCell(CtfyModel):
15    """One (category, channel) cell, as the settings UI needs it.
16
17    Three values rather than one because storage is sparse: ``effective``
18    is what happens now, ``default`` is what reverting would restore, and
19    ``explicit`` says whether the user chose this or is inheriting it —
20    which is also whether a "revert" control should appear at all.
21    """
22
23    effective: bool
24    explicit: bool
25    default: bool

One (category, channel) cell, as the settings UI needs it.

Three values rather than one because storage is sparse: effective is what happens now, default is what reverting would restore, and explicit says whether the user chose this or is inheriting it — which is also whether a "revert" control should appear at all.

effective: bool = PydanticUndefined
explicit: bool = PydanticUndefined
default: bool = PydanticUndefined
class NotificationPreferenceUpdate(ctfy.core.models.CtfyModel):
47class NotificationPreferenceUpdate(CtfyModel):
48    """PATCH body for one cell."""
49
50    category: str
51    channel: str = "email"
52    enabled: bool

PATCH body for one cell.

category: str = PydanticUndefined
channel: str = 'email'
enabled: bool = PydanticUndefined
class NotificationPreferencesResponse(ctfy.core.models.CtfyModel):
43class NotificationPreferencesResponse(CtfyModel):
44    categories: list[NotificationCategoryInfo]

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.

categories: list[NotificationCategoryInfo] = PydanticUndefined
class OAuthProviderInfo(ctfy.core.models.CtfyModel):
17class OAuthProviderInfo(CtfyModel):
18    name: str  # "github" | "google" | "oidc"
19    enabled: bool
20    authorize_path: str  # relative, e.g. "/api/v1/auth/login/github"
21    # Operator-supplied button label. Empty for the built-in providers,
22    # whose names the client already translates ("Sign in with GitHub").
23    # A generic OIDC provider has no name the client could know — the
24    # only useful label is the one the operator chose ("清华大学统一身份
25    # 认证"), so it travels on the wire rather than living in a bundle.
26    display_name: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

name: str = PydanticUndefined
enabled: bool = PydanticUndefined
authorize_path: str = PydanticUndefined
display_name: str = ''
class PasswordAuthInfo(ctfy.core.models.CtfyModel):
29class PasswordAuthInfo(CtfyModel):
30    """Gating flag for the email+password sign-in form on the login page."""
31
32    enabled: bool = False

Gating flag for the email+password sign-in form on the login page.

enabled: bool = False
class PatchSubmissionCreate(ctfy.core.models.CtfyModel):
757class PatchSubmissionCreate(CtfyModel):
758    """An AWD+ defence submission, addressed one of two ways.
759
760    ``instance_id`` is the handle when a box is running, exactly as on
761    :class:`SubmissionCreate`: the instance row carries the challenge,
762    the team and the competition, so the caller repeats none of them.
763
764    ⚠️ **But a defence submission does not need a box, and requiring one
765    made the format's natural order unplayable.** Judging builds from
766    the pristine tree plus the submitted bytes and never touches the
767    instance, ``GET /challenges/{id}/defence/source`` is already
768    challenge-scoped for exactly this reason, and upsolve, virtual
769    participation and a post-event fix all mean patching with nothing
770    running. So ``challenge_id`` (plus ``competition_id``, which scopes
771    which team is credited) addresses it directly.
772
773    Naming neither is refused rather than guessed at: falling back to
774    "the caller's only instance" would silently judge a challenge they
775    did not name. Naming an instance *and* a different challenge is
776    refused for the same reason.
777
778    **This is not a weaker authorisation than the instance form.** There
779    is no team on the wire either way — the credited team is resolved
780    from the caller's own membership, as ``GET /patches`` already does —
781    so the challenge form has no id to spoof at all.
782    """
783
784    instance_id: str = ""
785    #: The challenge to defend, when no box is running. Ignored (but
786    #: cross-checked) when ``instance_id`` is given.
787    challenge_id: str = ""
788    #: Which competition credits the patch. Empty means the caller's
789    #: unscoped team, the same convention ``GET /patches`` follows.
790    competition_id: str = ""
791    #: Where the patched bytes come from.
792    #:
793    #: ``upload`` carries them in ``files``. ``live`` reads them out of
794    #: the player's own running box — they SSH in, edit, and submit with
795    #: no payload at all, which is how an AWD player actually works.
796    #:
797    #: Explicit rather than inferred from an empty ``files``: an empty
798    #: map is also what a client bug produces, and silently switching
799    #: channel on a bug would grade something the player never meant to
800    #: send.
801    source: Literal["upload", "live"] = "upload"
802    #: ``{challenge-relative path: base64(bytes)}``. Base64 because a
803    #: patch target may legitimately be binary, and JSON has no way to
804    #: carry those bytes intact otherwise. Must be empty when
805    #: ``source="live"``.
806    files: dict[str, str] = Field(default_factory=dict)

An AWD+ defence submission, addressed one of two ways.

instance_id is the handle when a box is running, exactly as on SubmissionCreate: the instance row carries the challenge, the team and the competition, so the caller repeats none of them.

⚠️ But a defence submission does not need a box, and requiring one made the format's natural order unplayable. Judging builds from the pristine tree plus the submitted bytes and never touches the instance, GET /challenges/{id}/defence/source is already challenge-scoped for exactly this reason, and upsolve, virtual participation and a post-event fix all mean patching with nothing running. So challenge_id (plus competition_id, which scopes which team is credited) addresses it directly.

Naming neither is refused rather than guessed at: falling back to "the caller's only instance" would silently judge a challenge they did not name. Naming an instance and a different challenge is refused for the same reason.

This is not a weaker authorisation than the instance form. There is no team on the wire either way — the credited team is resolved from the caller's own membership, as GET /patches already does — so the challenge form has no id to spoof at all.

instance_id: str = ''
challenge_id: str = ''
competition_id: str = ''
source: Literal['upload', 'live'] = 'upload'
files: dict[str, str] = PydanticUndefined
class PatchSubmissionInfo(ctfy.core.models.CtfyModel):
809class PatchSubmissionInfo(CtfyModel):
810    """A defence submission's status — deliberately without its code.
811
812    The stored row keeps the submitted bytes so a verdict can be
813    re-derived after a checker fix, but a *status* view is polled, and
814    shipping every patch body on every poll is bulk nobody asked for.
815    """
816
817    id: str
818    challenge_id: str
819    competition_id: str
820    submitted_at: datetime | None = None
821    #: The paths this submission replaced.
822    files: list[str] = Field(default_factory=list)
823    status: str = "pending"
824    #: Empty until judged, and empty forever on a submission the
825    #: platform failed to judge — our outage is not the player's loss.
826    verdict: str = ""
827    detail: str = ""
828    judged_at: datetime | None = None

A defence submission's status — deliberately without its code.

The stored row keeps the submitted bytes so a verdict can be re-derived after a checker fix, but a status view is polled, and shipping every patch body on every poll is bulk nobody asked for.

id: str = PydanticUndefined
challenge_id: str = PydanticUndefined
competition_id: str = PydanticUndefined
submitted_at: datetime.datetime | None = None
files: list[str] = PydanticUndefined
status: str = 'pending'
verdict: str = ''
detail: str = ''
judged_at: datetime.datetime | None = None
class PlatformSettingInfo(ctfy.core.models.CtfyModel):
323class PlatformSettingInfo(CtfyModel):
324    """One row from ``GET /admin/platform-settings``.
325
326    ``value`` is the live resolved value (DB > env > default); the
327    other two fields show what env and the built-in default would
328    resolve to in isolation, so the UI can render a tooltip
329    explaining what a ``DELETE`` would land on. ``source`` is the
330    tier the current value came from.
331    """
332
333    key: str = ""
334    type: str = ""
335    description: str = ""
336    value: Any = None
337    source: str = ""  # "db" | "env" | "default"
338    env_value: Any = None
339    default_value: Any = None

One row from GET /admin/platform-settings.

value is the live resolved value (DB > env > default); the other two fields show what env and the built-in default would resolve to in isolation, so the UI can render a tooltip explaining what a DELETE would land on. source is the tier the current value came from.

key: str = ''
type: str = ''
description: str = ''
value: Any = None
source: str = ''
env_value: Any = None
default_value: Any = None
class PlatformSettingPatch(ctfy.core.models.CtfyModel):
342class PlatformSettingPatch(CtfyModel):
343    """Body of ``PATCH /admin/platform-settings/{key}``.
344
345    ``value`` is JSON-typed; the resolver's per-key validator decides
346    whether it's accepted.
347    """
348
349    value: Any = None

Body of PATCH /admin/platform-settings/{key}.

value is JSON-typed; the resolver's per-key validator decides whether it's accepted.

value: Any = None
class PlayerShellCreate(ctfy.core.models.CtfyModel):
358class PlayerShellCreate(CtfyModel):
359    """Ask for a shell into your own AWD+ box.
360
361    Note what is *absent*: no container. The admin shell takes one
362    because a super-admin legitimately picks among an instance's
363    containers; accepting one here would hand the caller the choice the
364    endpoint exists to deny them. The platform resolves the target from
365    the challenge's own ``patch.live.service``.
366    """
367
368    #: ``bash`` or ``sh``. A tiny allow-list rather than free text —
369    #: the value becomes the exec'd command on the node.
370    shell: str = "bash"

Ask for a shell into your own AWD+ box.

Note what is absent: no container. The admin shell takes one because a super-admin legitimately picks among an instance's containers; accepting one here would hand the caller the choice the endpoint exists to deny them. The platform resolves the target from the challenge's own patch.live.service.

shell: str = 'bash'
class PlayerShellTicket(ctfy.core.models.CtfyModel):
424class PlayerShellTicket(CtfyModel):
425    """A single-use ticket for one shell session.
426
427    Two-step because a browser cannot attach an ``Authorization`` header
428    to a WebSocket upgrade. Worth keeping for terminal clients too: it
429    means the long-lived bearer never rides in a URL.
430    """
431
432    session_id: str
433    #: Burns on first WebSocket use and is never returned again.
434    session_token: str
435    #: Platform-relative; the client makes it absolute against its own
436    #: base URL, since only the client knows the scheme it reached us on.
437    ws_url: str
438    #: The resolved compose container. Empty for the browser, which
439    #: never needs it; the bastion does, because it opens the node
440    #: WebSocket itself rather than going through the platform page.
441    container: str = ""
442    #: Seconds the unburned ticket stays valid.
443    expires_in: int
444    #: The compose service the session will land in. Echoed back so a
445    #: player knows which container they are in without reading the
446    #: challenge's metadata — the platform resolved it, so it says so.
447    service: str

A single-use ticket for one shell session.

Two-step because a browser cannot attach an Authorization header to a WebSocket upgrade. Worth keeping for terminal clients too: it means the long-lived bearer never rides in a URL.

session_id: str = PydanticUndefined
session_token: str = PydanticUndefined
ws_url: str = PydanticUndefined
container: str = ''
expires_in: int = PydanticUndefined
service: str = PydanticUndefined
class ProfilePatchRequest(ctfy.core.models.CtfyModel):
101class ProfilePatchRequest(CtfyModel):
102    """Body for ``PATCH /me/profile``. Every field is optional so the
103    UI can send only the diff; the route only writes the fields that
104    were present in the JSON. ``None`` means "clear this field"."""
105
106    # ``Field(default=...)`` with a non-None sentinel is awkward in
107    # pydantic; we instead declare each field as Optional + default-None
108    # and rely on the request's raw dict (via ``model_fields_set``) to
109    # know which keys were actually present.
110    bio: str | None = Field(default=None, max_length=280)
111    country: str | None = None
112    website_url: WebUrl | None = Field(default=None, max_length=200)
113    timezone: str | None = Field(default=None, max_length=80)
114    # Values are *handles*, not URLs — ``{"github": "octocat"}``. The
115    # client owns href construction from a fixed per-provider template
116    # (``https://github.com/<handle>``), and the two providers that do
117    # accept a free-form value run it through ``normalizeUrl`` /
118    # ``mastodonUrl``, both of which only ever emit ``https://…``. So a
119    # scheme constraint here would be the wrong shape: it would reject
120    # every legitimate handle while defending against nothing. What was
121    # actually missing is a size bound.
122    social_links: dict[str, str] | None = None
123
124    @field_validator("social_links")
125    @classmethod
126    def _bound_social_links(cls, v: dict[str, str] | None) -> dict[str, str] | None:
127        if v is None:
128            return None
129        if len(v) > 10:
130            raise ValueError("social_links may contain at most 10 entries")
131        if any(len(handle) > 200 for handle in v.values()):
132            raise ValueError("each social link must be at most 200 characters")
133        return v

Body for PATCH /me/profile. Every field is optional so the UI can send only the diff; the route only writes the fields that were present in the JSON. None means "clear this field".

bio: str | None = None
country: str | None = None
website_url: Optional[Annotated[str, AfterValidator(func=<function _require_http_scheme at 0x7f5b7c695080>)]] = None
timezone: str | None = None
class ProfileStats(ctfy.core.models.CtfyModel):
183class ProfileStats(CtfyModel):
184    """Aggregate per-team analytics surfaced on the public profile.
185
186    Every section is independently visibility-gated; non-owner viewers
187    see ``[]`` for a section the team has marked private. Owner and
188    admin always see the full payload."""
189
190    calendar: list[CalendarBucket] = Field(default_factory=list)
191    by_tag: list[TagStat] = Field(default_factory=list)
192    by_difficulty: list[DifficultyStat] = Field(default_factory=list)
193    solve_trend: list[TrendPoint] = Field(default_factory=list)

Aggregate per-team analytics surfaced on the public profile.

Every section is independently visibility-gated; non-owner viewers see [] for a section the team has marked private. Owner and admin always see the full payload.

calendar: list[CalendarBucket] = PydanticUndefined
by_tag: list[TagStat] = PydanticUndefined
by_difficulty: list[DifficultyStat] = PydanticUndefined
solve_trend: list[TrendPoint] = PydanticUndefined
class ProvidersResponse(ctfy.core.models.CtfyModel):
47class ProvidersResponse(CtfyModel):
48    providers: list[OAuthProviderInfo] = Field(default_factory=list)
49    password_auth: PasswordAuthInfo = Field(default_factory=PasswordAuthInfo)
50    email_code_auth: EmailCodeAuthInfo = Field(default_factory=EmailCodeAuthInfo)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

providers: list[OAuthProviderInfo] = PydanticUndefined
password_auth: PasswordAuthInfo = PydanticUndefined
email_code_auth: EmailCodeAuthInfo = PydanticUndefined
class QaChallengeView(ctfy.core.models.CtfyModel):
514class QaChallengeView(CtfyModel):
515    """One QA challenge as seen by a team on the quiz surface.
516
517    Carries the full question / choices / description data alongside
518    per-team solve and attempt state, so the frontend can render the
519    quiz list with progress badges in a single round-trip.
520    """
521
522    id: str
523    name: str
524    # Provenance bucket prefix — ``SECQA`` / ``MMLU-CS`` / ``CTI-MCQ``
525    # / etc — derived from the challenge id (everything before the
526    # trailing numeric suffix). Lets the frontend group / filter
527    # without splitting the id again client-side.
528    bucket: str
529    description: str
530    difficulty: str = ""
531    tags: list[str] = Field(default_factory=list)
532    questions: list[QuestionPublicInfo] = Field(default_factory=list)
533    # Question ids the calling team has already captured.
534    solved_question_ids: list[str] = Field(default_factory=list)
535    # Question ids the team has submitted at least one wrong answer
536    # against but never solved. Drives the "Try again" amber badge.
537    attempted_wrong_question_ids: list[str] = Field(default_factory=list)

One QA challenge as seen by a team on the quiz surface.

Carries the full question / choices / description data alongside per-team solve and attempt state, so the frontend can render the quiz list with progress badges in a single round-trip.

id: str = PydanticUndefined
name: str = PydanticUndefined
bucket: str = PydanticUndefined
description: str = PydanticUndefined
difficulty: str = ''
tags: list[str] = PydanticUndefined
questions: list[QuestionPublicInfo] = PydanticUndefined
solved_question_ids: list[str] = PydanticUndefined
attempted_wrong_question_ids: list[str] = PydanticUndefined
class QaSubmissionCreate(ctfy.core.models.CtfyModel):
497class QaSubmissionCreate(CtfyModel):
498    """Request body for ``POST /qa/submissions`` — the instance-free
499    submission path for pure question-answer challenges.
500
501    No ``instance_id``: QA challenges have no Docker infra to launch,
502    so there's no per-team instance row to read context off. The
503    caller passes ``challenge_id`` + ``competition_id`` explicitly,
504    and the server resolves the submitter's team for the named
505    competition via the standard auth helper.
506    """
507
508    challenge_id: str
509    competition_id: str
510    question_id: str = "answer"
511    answer: str | list[str]

Request body for POST /qa/submissions — the instance-free submission path for pure question-answer challenges.

No instance_id: QA challenges have no Docker infra to launch, so there's no per-team instance row to read context off. The caller passes challenge_id + competition_id explicitly, and the server resolves the submitter's team for the named competition via the standard auth helper.

challenge_id: str = PydanticUndefined
competition_id: str = PydanticUndefined
question_id: str = 'answer'
answer: str | list[str] = PydanticUndefined
class QuestionAttemptResetInfo(ctfy.core.models.CtfyModel):
471class QuestionAttemptResetInfo(CtfyModel):
472    """Response from the admin reset endpoint. Echoes the new baseline
473    row so the UI can update its local state without re-fetching the
474    instance.
475    """
476
477    team_id: str
478    challenge_id: str
479    question_id: str
480    reset_at: datetime
481    reset_by_user_id: str
482    reason: str

Response from the admin reset endpoint. Echoes the new baseline row so the UI can update its local state without re-fetching the instance.

team_id: str = PydanticUndefined
challenge_id: str = PydanticUndefined
question_id: str = PydanticUndefined
reset_at: datetime.datetime = PydanticUndefined
reset_by_user_id: str = PydanticUndefined
reason: str = PydanticUndefined
class QuestionAttemptResetRequest(ctfy.core.models.CtfyModel):
375class QuestionAttemptResetRequest(CtfyModel):
376    """Request body for ``POST /admin/teams/.../reset-attempts``.
377
378    ``reason`` is free-form audit metadata recorded onto the activity
379    log row; capped at 500 chars so it round-trips through the SSE
380    payload without bloating the wire frame.
381    """
382
383    reason: str = Field(default="", max_length=500)

Request body for POST /admin/teams/.../reset-attempts.

reason is free-form audit metadata recorded onto the activity log row; capped at 500 chars so it round-trips through the SSE payload without bloating the wire frame.

reason: str = ''
class QuestionPublicInfo(ctfy.core.models.CtfyModel):
20class QuestionPublicInfo(CtfyModel):
21    """One question, as exposed to agents and the web UI.
22
23    Carries the prompt + mode + ``choices:`` enum (when present) so the
24    UI can render a radio/checkbox/free-text input without an extra
25    round-trip to fetch the spec. ``answer`` is NEVER projected — the
26    groundtruth lives on the platform either as metadata.yaml or as
27    a per-instance mint.
28    """
29
30    id: str
31    prompt: str
32    # Grading mode: ``dynamic`` | ``static`` | ``single_select`` |
33    # ``multi_select``. ``None`` only on malformed metadata (which the
34    # audit catches before deploy).
35    mode: str | None = None
36    # Closed-list options for ``single_select`` / ``multi_select``;
37    # ``None`` for free-form modes.
38    choices: list[str] | None = None
39    # Question ids that must be answered correctly first. The
40    # platform gates visibility / submission acceptance on this list
41    # (a question with non-empty ``requires:`` is hidden until every
42    # listed predecessor is solved).
43    requires: list[str] = Field(default_factory=list)

One question, as exposed to agents and the web UI.

Carries the prompt + mode + choices: enum (when present) so the UI can render a radio/checkbox/free-text input without an extra round-trip to fetch the spec. answer is NEVER projected — the groundtruth lives on the platform either as metadata.yaml or as a per-instance mint.

id: str = PydanticUndefined
prompt: str = PydanticUndefined
mode: str | None = None
choices: list[str] | None = None
requires: list[str] = PydanticUndefined
class RecentUnlock(ctfy.core.models.CtfyModel):
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.

team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
achievement_id: str = PydanticUndefined
name: str = PydanticUndefined
icon: str = PydanticUndefined
tier: str = PydanticUndefined
unlocked_at: datetime.datetime | None = None
class RegisterRequest(ctfy.core.models.CtfyModel):
74class RegisterRequest(CtfyModel):
75    email: EmailStr
76    # Length minimum mirrors ``MIN_PASSWORD_LENGTH`` in
77    # ``ctfy.server.password``. ``validate_password_strength`` runs the
78    # full check (length + common-password reject) inside the route
79    # handler so the rule lives in one place; this floor is a cheap
80    # client-side hint that catches obviously short input before it
81    # reaches argon2.
82    password: str = Field(min_length=12, max_length=256)
83    display_name: str = Field(default="", max_length=120)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

email: pydantic.networks.EmailStr = PydanticUndefined
password: str = PydanticUndefined
display_name: str = ''
class RegistrationFieldSpec(ctfy.core.models.CtfyModel):
36class RegistrationFieldSpec(CtfyModel):
37    """One field the competition's form should render.
38
39    The server drives the form rather than the frontend hard-coding it,
40    so an organiser flipping a field from optional to required takes
41    effect without a redeploy — and so the client cannot disagree with
42    the server about what is mandatory.
43    """
44
45    name: str
46    policy: RegistrationFieldPolicy
47    # True for the fields the captain answers once for the whole squad
48    # (team logo, team honours) rather than each member answering.
49    team_scoped: bool = False
50    # True for 个人敏感信息 — the frontend renders the consent notice and
51    # the "organisers only" hint against exactly these.
52    sensitive: bool = False

One field the competition's form should render.

The server drives the form rather than the frontend hard-coding it, so an organiser flipping a field from optional to required takes effect without a redeploy — and so the client cannot disagree with the server about what is mandatory.

name: str = PydanticUndefined
policy: Literal['hidden', 'optional', 'required'] = PydanticUndefined
team_scoped: bool = False
sensitive: bool = False
class RegistrationFormSpec(ctfy.core.models.CtfyModel):
55class RegistrationFormSpec(CtfyModel):
56    """Everything the client needs to render + gate the wizard."""
57
58    competition_id: str
59    fields: list[RegistrationFieldSpec] = Field(default_factory=list)
60    registration_starts_at: datetime | None = None
61    registration_ends_at: datetime | None = None
62    # "not_open" | "open" | "closed" — the client shows a countdown, the
63    # form, or a dead end respectively.
64    phase: Literal["not_open", "open", "closed"] = "open"
65    max_team_size: int = 0
66    review_required: bool = False
67    collects_sensitive_pii: bool = False

Everything the client needs to render + gate the wizard.

competition_id: str = PydanticUndefined
fields: list[RegistrationFieldSpec] = PydanticUndefined
registration_starts_at: datetime.datetime | None = None
registration_ends_at: datetime.datetime | None = None
phase: Literal['not_open', 'open', 'closed'] = 'open'
max_team_size: int = 0
review_required: bool = False
collects_sensitive_pii: bool = False
class RegistrationRosterRow(ctfy.core.models.CtfyModel):
143class RegistrationRosterRow(CtfyModel):
144    """One entrant on the organiser's roster.
145
146    This is the **only** wire model that carries the sensitive fields, and
147    it is only ever returned from a route gated on
148    ``require_registration_reviewer``. Anything added here is, by
149    construction, organiser-visible personal data — think before widening
150    it.
151    """
152
153    user_id: str
154    display_name: str = ""
155    email: str = ""
156    team_id: str = ""
157    team_name: str = ""
158    is_captain: bool = False
159
160    real_name: str = ""
161    phone: str = ""
162    mailing_address: str = ""
163    gender: str = ""
164    organization: str = ""
165    academic_stage: str = ""
166    grade: str = ""
167    undergrad_school: str = ""
168    specialties: list[str] = Field(default_factory=list)
169
170    submitted_at: datetime | None = None
171    review_status: RegistrationReviewStatus = "approved"

One entrant on the organiser's roster.

This is the only wire model that carries the sensitive fields, and it is only ever returned from a route gated on require_registration_reviewer. Anything added here is, by construction, organiser-visible personal data — think before widening it.

user_id: str = PydanticUndefined
display_name: str = ''
email: str = ''
team_id: str = ''
team_name: str = ''
is_captain: bool = False
real_name: str = ''
phone: str = ''
mailing_address: str = ''
gender: str = ''
organization: str = ''
academic_stage: str = ''
grade: str = ''
undergrad_school: str = ''
specialties: list[str] = PydanticUndefined
submitted_at: datetime.datetime | None = None
review_status: Literal['pending', 'approved', 'rejected'] = 'approved'
class RegistrationSubmitRequest(ctfy.core.models.CtfyModel):
 70class RegistrationSubmitRequest(CtfyModel):
 71    """Body for ``PUT /competitions/{id}/registration``.
 72
 73    Every field is optional at the type level; which ones are actually
 74    mandatory is decided per competition at request time against
 75    ``registration_fields``. Encoding "required" in the type would hard-
 76    code one event's rules into the wire contract.
 77    """
 78
 79    real_name: str = Field(default="", max_length=120)
 80    phone: str = Field(default="", max_length=40)
 81    mailing_address: str = Field(default="", max_length=500)
 82    gender: Literal["", "male", "female", "other", "undisclosed"] = ""
 83    organization: str = Field(default="", max_length=200)
 84    academic_stage: str = Field(default="", max_length=80)
 85    grade: str = Field(default="", max_length=80)
 86    undergrad_school: str = Field(default="", max_length=200)
 87    specialties: list[str] = Field(default_factory=list)
 88    # Captain-only; ignored (not rejected) when a non-captain sends them,
 89    # so a member re-submitting a form the wizard prefilled does not 422.
 90    team_honors: str = Field(default="", max_length=2000)
 91
 92    @field_validator("phone")
 93    @classmethod
 94    def _check_phone(cls, v: str) -> str:
 95        return validate_phone_shape(v)
 96
 97    @field_validator("specialties")
 98    @classmethod
 99    def _check_specialties(cls, v: list[str]) -> list[str]:
100        if len(v) > 12:
101            raise ValueError("specialties may contain at most 12 entries")
102        if any(len(s) > 40 for s in v):
103            raise ValueError("each specialty must be at most 40 characters")
104        return v

Body for PUT /competitions/{id}/registration.

Every field is optional at the type level; which ones are actually mandatory is decided per competition at request time against registration_fields. Encoding "required" in the type would hard- code one event's rules into the wire contract.

real_name: str = ''
phone: str = ''
mailing_address: str = ''
gender: Literal['', 'male', 'female', 'other', 'undisclosed'] = ''
organization: str = ''
academic_stage: str = ''
grade: str = ''
undergrad_school: str = ''
specialties: list[str] = PydanticUndefined
team_honors: str = ''
class RegistrationSummary(ctfy.core.models.CtfyModel):
208class RegistrationSummary(CtfyModel):
209    """Per-verdict totals for one competition's roster.
210
211    Split from the roster itself because the filter tabs need counts for
212    the *whole* competition while the list is paged — counting the rows
213    in hand would make every tab report the page size. It is also far
214    cheaper: a grouped ``COUNT`` rather than a page of decrypted
215    dossiers, which is why it can be fetched on every tab render.
216
217    Carries no personal data by construction, so it is not audited as a
218    PII read.
219    """
220
221    competition_id: str
222    total: int = 0
223    pending: int = 0
224    approved: int = 0
225    rejected: int = 0
226    # Teams with at least one member missing a required answer. Counted
227    # in SQL against blank columns, never by reading the dossiers — an
228    # unanswered sealed field is stored empty, so this stays a count and
229    # not a bulk decrypt. Teams rather than people because that is the
230    # unit a reviewer works through.
231    incomplete_teams: int = 0

Per-verdict totals for one competition's roster.

Split from the roster itself because the filter tabs need counts for the whole competition while the list is paged — counting the rows in hand would make every tab report the page size. It is also far cheaper: a grouped COUNT rather than a page of decrypted dossiers, which is why it can be fetched on every tab render.

Carries no personal data by construction, so it is not audited as a PII read.

competition_id: str = PydanticUndefined
total: int = 0
pending: int = 0
approved: int = 0
rejected: int = 0
incomplete_teams: int = 0
class RegistrationTeamRow(ctfy.core.models.CtfyModel):
174class RegistrationTeamRow(CtfyModel):
175    """One squad on the organiser's review queue.
176
177    Review is a team-level verdict, so the queue is grouped by team with
178    the members nested — an organiser approves a squad after reading all
179    of its members' details, never one person at a time.
180    """
181
182    team_id: str
183    team_name: str = ""
184    description: str = ""
185    honors: str = ""
186    has_logo: bool = False
187    captain_user_id: str = ""
188    created_at: datetime | None = None
189    review_status: RegistrationReviewStatus = "approved"
190    reviewed_by: str = ""
191    reviewed_by_name: str = ""
192    reviewed_at: datetime | None = None
193    review_note: str = ""
194    members: list[RegistrationRosterRow] = Field(default_factory=list)
195    # Members who have not completed the form yet. Surfaced as a count so
196    # the reviewer can spot an incomplete squad without opening it.
197    incomplete_members: int = 0
198    # True when a member's dossier changed after the verdict was recorded,
199    # which makes that verdict stale. Derived from ``reviewed_at`` against
200    # the members' ``updated_at`` rather than stored: a column would have
201    # to be reset on every review and cleared on every edit, and a missed
202    # write there reads exactly like "nothing changed". The alternative —
203    # reverting the team to ``pending`` on any edit — would force a
204    # re-review over a corrected phone digit.
205    edited_after_review: bool = False

One squad on the organiser's review queue.

Review is a team-level verdict, so the queue is grouped by team with the members nested — an organiser approves a squad after reading all of its members' details, never one person at a time.

team_id: str = PydanticUndefined
team_name: str = ''
description: str = ''
honors: str = ''
captain_user_id: str = ''
created_at: datetime.datetime | None = None
review_status: Literal['pending', 'approved', 'rejected'] = 'approved'
reviewed_by: str = ''
reviewed_by_name: str = ''
reviewed_at: datetime.datetime | None = None
review_note: str = ''
members: list[RegistrationRosterRow] = PydanticUndefined
incomplete_members: int = 0
edited_after_review: bool = False
class RenewResponse(ctfy.core.models.CtfyModel):
291class RenewResponse(CtfyModel):
292    status: str = "renewed"
293    id: str
294    expires_at: float

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.

status: str = 'renewed'
id: str = PydanticUndefined
expires_at: float = PydanticUndefined
class ReviewDecisionRequest(ctfy.core.models.CtfyModel):
234class ReviewDecisionRequest(CtfyModel):
235    """Body for the organiser's approve / reject on one team."""
236
237    status: Literal["approved", "rejected", "pending"]
238    # Mandatory on rejection: a rejected team is told why, and "no reason
239    # given" turns into an organiser support ticket every time.
240    note: str = Field(default="", max_length=2000)
241
242    @field_validator("note")
243    @classmethod
244    def _note_required_on_reject(cls, v: str, info) -> str:
245        if info.data.get("status") == "rejected" and not v.strip():
246            raise ValueError("a note explaining the rejection is required")
247        return v

Body for the organiser's approve / reject on one team.

status: Literal['approved', 'rejected', 'pending'] = PydanticUndefined
note: str = ''
class ScheduledJobInfo(ctfy.core.models.CtfyModel):
549class ScheduledJobInfo(CtfyModel):
550    """One recurring job on ``GET /admin/scheduled-jobs``: a projection of
551    ``ScheduledJobState`` plus the code-side ``default_interval_s`` so the
552    UI can show "(default: 30s)" next to a tuned value."""
553
554    name: str = ""
555    enabled: bool = True
556    interval_s: int = 0
557    default_interval_s: int = 0
558    last_run_at_ts: float = 0.0
559    last_status: str = ""
560    last_summary: str = ""
561    last_error: str = ""
562    last_duration_s: float = 0.0
563    run_count: int = 0
564    updated_by_user_id: str = ""

One recurring job on GET /admin/scheduled-jobs: a projection of ScheduledJobState plus the code-side default_interval_s so the UI can show "(default: 30s)" next to a tuned value.

name: str = ''
enabled: bool = True
interval_s: int = 0
default_interval_s: int = 0
last_run_at_ts: float = 0.0
last_status: str = ''
last_summary: str = ''
last_error: str = ''
last_duration_s: float = 0.0
run_count: int = 0
updated_by_user_id: str = ''
class ScheduledJobPatch(ctfy.core.models.CtfyModel):
567class ScheduledJobPatch(CtfyModel):
568    """Body of ``PATCH /admin/scheduled-jobs/{name}``. Either field may be
569    omitted; ``interval_s`` is clamped to ``[5, 86400]`` by the route."""
570
571    enabled: bool | None = None
572    interval_s: int | None = None

Body of PATCH /admin/scheduled-jobs/{name}. Either field may be omitted; interval_s is clamped to [5, 86400] by the route.

enabled: bool | None = None
interval_s: int | None = None
class ScopeCatalogEntry(ctfy.core.models.CtfyModel):
200class ScopeCatalogEntry(CtfyModel):
201    """One assignable permission category, for the token-creation UI."""
202
203    key: str
204    label: str
205    description: str
206    competition_scoped: bool
207    max_level: str  # highest grantable level: "read" | "write"

One assignable permission category, for the token-creation UI.

key: str = PydanticUndefined
label: str = PydanticUndefined
description: str = PydanticUndefined
competition_scoped: bool = PydanticUndefined
max_level: str = PydanticUndefined
class ScoreBucket(ctfy.core.models.CtfyModel):
714class ScoreBucket(CtfyModel):
715    """One bucket of the scoreboard score-distribution histogram."""
716
717    label: str
718    count: int = 0

One bucket of the scoreboard score-distribution histogram.

label: str = PydanticUndefined
count: int = 0
class ScoreHistoryPoint(ctfy.core.models.CtfyModel):
660class ScoreHistoryPoint(CtfyModel):
661    """One sample on a team's score/rank-over-time curve.
662
663    Both metrics travel together so the frontend can toggle
664    Score ⇄ Rank without a refetch.
665    """
666
667    ts: float = 0.0  # Unix seconds (solve time of the driving event)
668    score: int = 0  # Cumulative flags captured up to ``ts``
669    rank: int = 0  # 1-based rank among scoring teams at ``ts``

One sample on a team's score/rank-over-time curve.

Both metrics travel together so the frontend can toggle Score ⇄ Rank without a refetch.

ts: float = 0.0
score: int = 0
rank: int = 0
class ScoreHistorySeries(ctfy.core.models.CtfyModel):
672class ScoreHistorySeries(CtfyModel):
673    team_id: str
674    team_name: str
675    points: list[ScoreHistoryPoint] = Field(default_factory=list)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
points: list[ScoreHistoryPoint] = PydanticUndefined
class ScoreboardEntry(ctfy.core.models.CtfyModel):
595class ScoreboardEntry(CtfyModel):
596    rank: int = 0
597    team_id: str
598    team_name: str
599    # Challenges where the team captured every declared flag. Preserved
600    # for parity with the legacy single-flag scoreboard.
601    solved: int = 0
602    # Total individual flags captured across all challenges. Primary
603    # scoreboard metric now that multi-flag challenges exist — rewards
604    # partial progress (e.g. foothold without root).
605    flags_solved: int = 0
606    # The number this board is ranked by, as decided by the
607    # competition's scoring rule (``ctfy/server/scoring/``). Under the
608    # default ``flag_count`` rule it equals ``flags_solved``; under a
609    # points rule it is the point total. Kept separate from
610    # ``flags_solved`` so the UI can show both "how much progress" and
611    # "how many points" without knowing which rule produced the row.
612    score: int = 0
613    attempts: int = 0
614    last_solve_at: datetime | None = None
615    # Most recent activity of any kind. Distinct from ``last_solve_at`` —
616    # a team may be actively attempting without a solve, and the UI
617    # wants to show that they're not idle.
618    last_active_at: datetime | None = None
619    # OAuth provider avatar URL for the team's earliest identity that
620    # supplies one. Empty for password-only teams; the frontend falls
621    # back to a generated initials avatar in that case.
622    avatar_url: str = ""
623    # The AWD+ split of ``score``. Zero on every other format, where the
624    # board renders one number and these columns stay hidden.
625    #
626    # Both are carried even though ``attack + defence == score``, because
627    # the point of the split *is* the breakdown: a single total never
628    # explains why a team that solved less is ranked higher. Deriving one
629    # from the other in the client would put the format's arithmetic in
630    # two places.
631    attack_score: int = 0
632    defence_score: int = 0
633    # Challenges this team holds a ``fixed`` defence verdict on. A count,
634    # not the ids — the board is a ranking, and the ids are one click
635    # away on the team's own page.
636    defended: int = 0
637    # The team's eligibility verdict, and **only when the viewer is
638    # entitled to it** — a competition admin or reviewer. Everywhere else
639    # this is ``""``.
640    #
641    # ``""`` means "not disclosed", never "approved". Defaulting it to
642    # ``approved`` would have been the natural-looking choice and is the
643    # bug: a public board would then assert that every team passed
644    # review, which is a stronger claim than the platform is entitled to
645    # make and is false during the review backlog.
646    #
647    # Not public because no other surface publishes it either — the
648    # verdict reaches the team's own members and the organisers, and
649    # nobody else. Putting "rejected" next to a team name on a page the
650    # whole event reads is a different act from recording it on the
651    # review queue.
652    #
653    # It never affects ``rank``: review is an eligibility marker, not a
654    # gate, and an unapproved team plays and places exactly as it would
655    # have. ``TestReviewIsNotAGate`` pins that; this field is what makes
656    # the fact *visible* to the organiser without changing it.
657    review_status: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

rank: int = 0
team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
solved: int = 0
flags_solved: int = 0
score: int = 0
attempts: int = 0
last_solve_at: datetime.datetime | None = None
last_active_at: datetime.datetime | None = None
avatar_url: str = ''
attack_score: int = 0
defence_score: int = 0
defended: int = 0
review_status: str = ''
class SeriesOccurrencePreview(ctfy.core.models.CtfyModel):
139class SeriesOccurrencePreview(CtfyModel):
140    """One occurrence a series *would* create, without creating it."""
141
142    occurrence_at: AwareDatetime
143    title: str = ""
144    challenge_ids: list[str] = Field(default_factory=list)
145    #: Empty when this occurrence could be filled. Otherwise why not —
146    #: the pool ran dry, or names challenges the catalog no longer has.
147    refused: str = ""

One occurrence a series would create, without creating it.

occurrence_at: pydantic.types.AwareDatetime = PydanticUndefined
title: str = ''
challenge_ids: list[str] = PydanticUndefined
refused: str = ''
class SeriesPreview(ctfy.core.models.CtfyModel):
150class SeriesPreview(CtfyModel):
151    """The dry run.
152
153    Its own route rather than a flag on the save, for the same reason
154    `GET /awd/plan` is: the answer is what decides whether to commit. A
155    weekly's first real feedback is otherwise a week away, and the
156    mistakes it catches — wrong weekday, wrong timezone, a pool that
157    covers three weeks of a schedule meant to run indefinitely — are all
158    invisible in the form.
159
160    ⚠️ It projects **past the lead window** on purpose. `plan_spawns`
161    answers "what should exist now", which for a freshly-saved series is
162    usually one contest; an organiser needs to see the *fourth* week to
163    know the pool runs out.
164    """
165
166    series_id: str = ""
167    occurrences: list[SeriesOccurrencePreview] = Field(default_factory=list)
168    #: Occurrences already past, reported for the same reason the job
169    #: reports them: a schedule whose anchor is in the past is a common
170    #: mistake and silently produces nothing.
171    missed: list[AwareDatetime] = Field(default_factory=list)

The dry run.

Its own route rather than a flag on the save, for the same reason GET /awd/plan is: the answer is what decides whether to commit. A weekly's first real feedback is otherwise a week away, and the mistakes it catches — wrong weekday, wrong timezone, a pool that covers three weeks of a schedule meant to run indefinitely — are all invisible in the form.

⚠️ It projects past the lead window on purpose. plan_spawns answers "what should exist now", which for a freshly-saved series is usually one contest; an organiser needs to see the fourth week to know the pool runs out.

series_id: str = ''
occurrences: list[SeriesOccurrencePreview] = PydanticUndefined
missed: list[pydantic.types.AwareDatetime] = PydanticUndefined
class SetPasswordRequest(ctfy.core.models.CtfyModel):
141class SetPasswordRequest(CtfyModel):
142    # Required iff the caller already has a password identity — absent on
143    # the first-time "attach password to an OAuth account" flow.
144    current_password: str = Field(default="", max_length=256)
145    # See RegisterRequest.password for the length-minimum rationale.
146    new_password: str = Field(min_length=12, max_length=256)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

current_password: str = ''
new_password: str = PydanticUndefined
class ShellRecordingInfo(ctfy.core.models.CtfyModel):
357class ShellRecordingInfo(CtfyModel):
358    """One row from ``GET /admin/shell-recordings``: a per-session
359    asciinema v2 cast file the platform wrote when a super-admin
360    opened a docker-exec shell. The frontend renders a playable
361    inline player; the raw bytes are pulled via the per-session
362    download route (also super-admin gated, also audited)."""
363
364    session_id: str = ""
365    path: str = ""
366    size_bytes: int = 0
367    modified_at_ts: float = 0.0

One row from GET /admin/shell-recordings: a per-session asciinema v2 cast file the platform wrote when a super-admin opened a docker-exec shell. The frontend renders a playable inline player; the raw bytes are pulled via the per-session download route (also super-admin gated, also audited).

session_id: str = ''
path: str = ''
size_bytes: int = 0
modified_at_ts: float = 0.0
class SshCredential(ctfy.core.models.CtfyModel):
393class SshCredential(CtfyModel):
394    """Everything a defender needs to `ssh` into their own box, once.
395
396    Model (b): the platform mints the keypair rather than a player
397    registering one, so both halves are here. The private key is
398    returned exactly once and never stored — there is nothing to leak
399    later and nothing to revoke, because it dies with the certificate's
400    five-minute window.
401
402    ⚠️ **The username is not cosmetic.** The bastion reads the subject
403    back off the login name, and asyncssh refuses a certificate whose
404    signed principal list omits it — so a client that connects under any
405    other name is rejected at the handshake with nothing in the response
406    explaining why.
407    """
408
409    #: OpenSSH private key. Returned once; the platform keeps no copy.
410    private_key: str
411    #: The certificate over it, signed by the platform's SSH CA.
412    certificate: str
413    #: ``user_id:instance_id`` — what the certificate's principal says.
414    username: str
415    #: Where to present it. Only the operator knows how the bastion is
416    #: published, so the platform reports rather than derives it.
417    host: str
418    port: int
419    #: Seconds the certificate stays valid. It only has to cover
420    #: *connecting*; an established session outlives it.
421    expires_in: int

Everything a defender needs to ssh into their own box, once.

Model (b): the platform mints the keypair rather than a player registering one, so both halves are here. The private key is returned exactly once and never stored — there is nothing to leak later and nothing to revoke, because it dies with the certificate's five-minute window.

⚠️ The username is not cosmetic. The bastion reads the subject back off the login name, and asyncssh refuses a certificate whose signed principal list omits it — so a client that connects under any other name is rejected at the handshake with nothing in the response explaining why.

private_key: str = PydanticUndefined
certificate: str = PydanticUndefined
username: str = PydanticUndefined
host: str = PydanticUndefined
port: int = PydanticUndefined
expires_in: int = PydanticUndefined
class SshExchangeRequest(ctfy.core.models.CtfyModel):
373class SshExchangeRequest(CtfyModel):
374    """The subject a bastion has already authenticated by certificate.
375
376    ⚠️ **Not the certificate.** That was the first shape and it cannot
377    be built: asyncssh discards the client certificate once it has
378    validated it, so the bastion has nothing to forward. What survives
379    the handshake is the login name, which the CA signs — a certificate
380    whose principal list omits the requested username is refused — so
381    the bastion knows the subject but cannot re-prove it with the
382    credential. The bearer on this request is what makes the claim
383    trustworthy; see ``routes/ssh_exchange.py``.
384    """
385
386    #: Who the certificate named. Authorisation still runs against them.
387    user_id: str
388    #: Which box. Re-resolved here — it can stop or change hands between
389    #: the certificate mint and the connect.
390    instance_id: str

The subject a bastion has already authenticated by certificate.

⚠️ Not the certificate. That was the first shape and it cannot be built: asyncssh discards the client certificate once it has validated it, so the bastion has nothing to forward. What survives the handshake is the login name, which the CA signs — a certificate whose principal list omits the requested username is refused — so the bastion knows the subject but cannot re-prove it with the credential. The bearer on this request is what makes the claim trustworthy; see routes/ssh_exchange.py.

user_id: str = PydanticUndefined
instance_id: str = PydanticUndefined
class StarGazerVerifyResponse(ctfy.core.models.CtfyModel):
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.

verified: bool = PydanticUndefined
reason: str = ''
achievement: TeamAchievement | None = None
already_unlocked: bool = False
class StartRequest(ctfy.core.models.CtfyModel):
264class StartRequest(CtfyModel):
265    challenge_id: str
266    proxy_output_dir: str | None = None
267    # Per-instance TTL override in seconds. ``None`` (the default)
268    # defers to the platform's ``default_instance_ttl_s`` setting —
269    # admin-tunable, currently 24h. Explicit values are clamped to
270    # ``max_instance_ttl_s`` so a misbehaving SDK / agent can't pin
271    # a node by requesting an arbitrarily large TTL.
272    ttl: int | None = None
273    # Which competition this instance is for. The platform looks up
274    # the caller's per-comp team via ``(user_id, competition_id)`` and
275    # stamps the instance with that team. Optional only when the
276    # caller is on exactly one team — multi-comp users always pass
277    # it explicitly.
278    competition_id: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

challenge_id: str = PydanticUndefined
proxy_output_dir: str | None = None
ttl: int | None = None
competition_id: str = ''
class StartResponse(ctfy.core.models.CtfyModel):
281class StartResponse(CtfyModel):
282    id: str
283    status: str = InstanceStatus.STARTING

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.

id: str = PydanticUndefined
status: str = <InstanceStatus.STARTING: 'starting'>
class StopResponse(ctfy.core.models.CtfyModel):
286class StopResponse(CtfyModel):
287    status: str = InstanceStatus.STOPPED
288    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.

status: str = <InstanceStatus.STOPPED: 'stopped'>
id: str = PydanticUndefined
class SubmissionCreate(ctfy.core.models.CtfyModel):
441class SubmissionCreate(CtfyModel):
442    # ``instance_id`` is the canonical handle since 0.2: the server
443    # reads challenge_id and competition_id off the instance row, so
444    # callers don't need to repeat them and N instances per
445    # (team, challenge) are unambiguous on submit.
446    instance_id: str
447    # Which question on the challenge the agent is answering. Defaults
448    # to ``"flag"`` so single-question challenges stay one-liner submits;
449    # multi-question challenges (see ``ChallengeInfo.questions``) must
450    # specify the id explicitly.
451    question_id: str = "flag"
452    # The agent's submission. String for ``dynamic`` / ``static`` /
453    # ``single_select`` questions; list of strings for ``multi_select``
454    # (e.g. ``["idor", "ssrf"]``). The server picks the grader by
455    # consulting ``spec.questions[i].mode`` on the live instance.
456    answer: str | list[str]

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

instance_id: str = PydanticUndefined
question_id: str = 'flag'
answer: str | list[str] = PydanticUndefined
class SubmissionResponse(ctfy.core.models.CtfyModel):
459class SubmissionResponse(CtfyModel):
460    id: str
461    correct: bool
462    solve_time_s: float = 0  # seconds from instance start to correct submission
463    # 1 = first blood, 2 = second, 3 = third, 4+ = regular solve.
464    # 0 when incorrect or when the team already had a prior correct solve.
465    # Ranks within the specific question — independent of the
466    # full-challenge solve rank.
467    solve_rank: int = 0
468    # The question id this submission targeted. Echoed back to the
469    # caller so simple submission UIs don't have to thread the id
470    # separately. Equal to the request's ``question_id``.
471    question_id: str | None = None
472    # True iff this team has now captured every question declared on
473    # the challenge. Single-question challenges: always equals ``correct``.
474    challenge_fully_solved: bool = False
475    # Remaining wrong-attempt budget for the question this submission
476    # targeted, AFTER this attempt is counted (the cap is on wrongs;
477    # correct submissions don't consume budget). ``None`` when the
478    # question's mode is uncapped (dynamic free-form, or any mode the
479    # operator opted out of via ``question_attempt_caps``). The UI
480    # uses this to decrement its remaining-attempts badge without
481    # re-fetching the instance — see ``ctfy.server.submission_policy``.
482    attempts_remaining: int | None = None
483    # Whether the team's capture of this question is on the
484    # competition's board. Only meaningful when ``correct``.
485    #
486    # ⚠️ **Defaults to False on purpose**, which is the loud direction.
487    # A competition accepts submissions outside its play window and
488    # simply does not count them (only a competition declaring
489    # ``sessions`` refuses), so before this field the player got
490    # ``correct: true`` and scored nothing, with nothing anywhere
491    # saying why. A producer that forgets to set this makes every solve
492    # claim it did not count — visible in a minute; the other default
493    # restores exactly the silence this exists to end.
494    ranked: bool = False

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

id: str = PydanticUndefined
correct: bool = PydanticUndefined
solve_time_s: float = 0
solve_rank: int = 0
question_id: str | None = None
challenge_fully_solved: bool = False
attempts_remaining: int | None = None
ranked: bool = False
class TagStat(ctfy.core.models.CtfyModel):
157class TagStat(CtfyModel):
158    """One axis on the per-tag strength radar.
159
160    ``solved`` is the team's solve count for challenges carrying this
161    tag; ``total`` is the platform-wide challenge count for the tag,
162    so the frontend can render "team / total" or normalize to a 0–1
163    ratio for the radar polygon."""
164
165    tag: str = ""
166    solved: int = 0
167    total: int = 0

One axis on the per-tag strength radar.

solved is the team's solve count for challenges carrying this tag; total is the platform-wide challenge count for the tag, so the frontend can render "team / total" or normalize to a 0–1 ratio for the radar polygon.

tag: str = ''
solved: int = 0
total: int = 0
class TeamAchievement(ctfy.core.models.CtfyModel):
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.

achievement_id: str = PydanticUndefined
name: str = PydanticUndefined
description: str = PydanticUndefined
icon: str = PydanticUndefined
tier: str = PydanticUndefined
secret: bool = False
unlocked_at: datetime.datetime | None = None
context: dict[str, typing.Any] = PydanticUndefined
points: int = 0
earned_by_count: int = 0
rarity: str = 'unearned'
class TeamCaptainRequest(ctfy.core.models.CtfyModel):
272class TeamCaptainRequest(CtfyModel):
273    """Body for an organiser handing a team to one of its members."""
274
275    # The member to promote. Must already be on the team — promoting an
276    # outsider would be a join and a transfer in one call, which is how
277    # a squad ends up with a captain nobody on it recognises.
278    user_id: str = Field(min_length=1)

Body for an organiser handing a team to one of its members.

user_id: str = PydanticUndefined
class TeamDetail(ctfy.server.models.TeamInfo):
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.

members: list[TeamMemberInfo] = PydanticUndefined
class TeamDisqualifyRequest(ctfy.core.models.CtfyModel):
229class TeamDisqualifyRequest(CtfyModel):
230    """Body for an organiser removing a team from a competition."""
231
232    # Mandatory, and with a floor rather than merely non-empty. This is
233    # the row an appeal is argued from and the text the team is shown
234    # when their next submission is refused; "cheating" tells them
235    # nothing they can answer. Same reasoning as the admin shell's
236    # mandatory ≥10-char reason, which exists for the same audience.
237    reason: str = Field(min_length=10, max_length=2000)

Body for an organiser removing a team from a competition.

reason: str = PydanticUndefined
class TeamInfo(ctfy.core.models.CtfyModel):
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 = ""

A team's public-facing summary.

Post user/team-split a team is a pure container — no email, no role, no human profile fields. solves and attempts aggregate across every member's contribution while they were on this team.

id: str = PydanticUndefined
name: str = PydanticUndefined
description: str = ''
captain_user_id: str = ''
captain_display_name: str = ''
captain_avatar_url: str = ''
member_count: int = 0
created_at: datetime.datetime | None = None
solves: int = 0
attempts: int = 0
last_active_at: datetime.datetime | None = None
competition_id: str = ''
class TeamInviteInfo(ctfy.core.models.CtfyModel):
203class TeamInviteInfo(CtfyModel):
204    """Public projection of a :class:`TeamInviteState`.
205
206    Surfaces on captain CRUD. ``active`` is server-derived from the
207    expires/use/revoked triple. ``kind`` discriminates code-style
208    invites from directed invites/requests; ``target_user_id`` is
209    the recipient for ``kind="direct"`` (set at mint time) and the
210    requester for ``kind="join_request"`` (set when a member opens
211    a request).
212    """
213
214    id: str
215    code: str
216    team_id: str
217    created_by_user_id: str
218    created_at: datetime | None = None
219    expires_at: datetime | None = None
220    max_uses: int
221    use_count: int
222    revoked_at: datetime | None = None
223    active: bool = True
224    competition_id: str = ""
225    kind: Literal["code", "direct", "join_request"] = "code"
226    target_user_id: str = ""

Public projection of a TeamInviteState.

Surfaces on captain CRUD. active is server-derived from the expires/use/revoked triple. kind discriminates code-style invites from directed invites/requests; target_user_id is the recipient for kind="direct" (set at mint time) and the requester for kind="join_request" (set when a member opens a request).

id: str = PydanticUndefined
code: str = PydanticUndefined
team_id: str = PydanticUndefined
created_by_user_id: str = PydanticUndefined
created_at: datetime.datetime | None = None
expires_at: datetime.datetime | None = None
max_uses: int = PydanticUndefined
use_count: int = PydanticUndefined
revoked_at: datetime.datetime | None = None
active: bool = True
competition_id: str = ''
kind: Literal['code', 'direct', 'join_request'] = 'code'
target_user_id: str = ''
class TeamLogoUploadResponse(ctfy.core.models.CtfyModel):
250class TeamLogoUploadResponse(CtfyModel):
251    """``POST /competitions/{id}/team/logo`` result."""
252
253    object_key: str
254    content_type: str = ""
255    size_bytes: int = 0

POST /competitions/{id}/team/logo result.

object_key: str = PydanticUndefined
content_type: str = ''
size_bytes: int = 0
class TeamMemberInfo(ctfy.core.models.CtfyModel):
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.

user_id: str = PydanticUndefined
display_name: str = ''
avatar_url: str = ''
is_captain: bool = False
joined_at: datetime.datetime | None = None
class TeamStandingInfo(ctfy.core.models.CtfyModel):
240class TeamStandingInfo(CtfyModel):
241    """Whether a team is currently in the event, and why not.
242
243    A separate model from ``TeamDetail`` on purpose: this is what the
244    two enforcement routes return and what the admin table polls, so it
245    stays small enough to fetch per row without dragging a roster and a
246    logo key along.
247    """
248
249    id: str
250    name: str
251    competition_id: str = ""
252    disqualified: bool = False
253    disqualified_at: datetime | None = None
254    disqualified_by: str = ""
255    disqualification_reason: str = ""
256
257    @classmethod
258    def from_team(cls, team: TeamState) -> TeamStandingInfo:
259        return cls(
260            id=team.id,
261            name=team.name,
262            competition_id=team.competition_id,
263            # Projected as a plain bool so a client never has to know
264            # that "absent timestamp" is how the platform spells "in".
265            disqualified=team.is_disqualified,
266            disqualified_at=team.disqualified_at,
267            disqualified_by=team.disqualified_by,
268            disqualification_reason=team.disqualification_reason,
269        )

Whether a team is currently in the event, and why not.

A separate model from TeamDetail on purpose: this is what the two enforcement routes return and what the admin table polls, so it stays small enough to fetch per row without dragging a roster and a logo key along.

id: str = PydanticUndefined
name: str = PydanticUndefined
competition_id: str = ''
disqualified: bool = False
disqualified_at: datetime.datetime | None = None
disqualified_by: str = ''
disqualification_reason: str = ''
@classmethod
def from_team( cls, team: ctfy.core.state.models.TeamState) -> TeamStandingInfo:
257    @classmethod
258    def from_team(cls, team: TeamState) -> TeamStandingInfo:
259        return cls(
260            id=team.id,
261            name=team.name,
262            competition_id=team.competition_id,
263            # Projected as a plain bool so a client never has to know
264            # that "absent timestamp" is how the platform spells "in".
265            disqualified=team.is_disqualified,
266            disqualified_at=team.disqualified_at,
267            disqualified_by=team.disqualified_by,
268            disqualification_reason=team.disqualification_reason,
269        )
class TestEmailRequest(ctfy.core.models.CtfyModel):
111class TestEmailRequest(CtfyModel):
112    template: str
113    #: Defaults to the caller's own address. An arbitrary recipient is
114    #: allowed on purpose — checking how a template lands in Outlook or
115    #: QQ mail requires mailing an account on that provider — and every
116    #: send is audited with the address.
117    to: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

template: str = PydanticUndefined
to: str = ''
class TestEmailResult(ctfy.core.models.CtfyModel):
120class TestEmailResult(CtfyModel):
121    sent: bool
122    to: str
123    template: str
124    subject: str
125    #: The provider's id for the accepted message, for cross-referencing
126    #: its delivery dashboard. Empty when the send failed, and also
127    #: legitimately empty on success if the response could not be parsed
128    #: — ``sent`` is the outcome, this is only a handle on it.
129    message_id: str = ""
130    #: Provider-side reason when ``sent`` is false. Empty on success.
131    error: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

sent: bool = PydanticUndefined
to: str = PydanticUndefined
template: str = PydanticUndefined
subject: str = PydanticUndefined
message_id: str = ''
error: str = ''
class TokenInfo(ctfy.core.models.CtfyModel):
149class TokenInfo(CtfyModel):
150    id: str
151    kind: str  # "user" | "fine_grained" (legacy dumps may still say "agent")
152    label: str = ""
153    created_at: datetime | None = None
154    last_used_at: datetime | None = None
155    # ISO-8601 UTC timestamp; empty means the token never expires.
156    expires_at: datetime | None = None
157    # Client metadata captured when the session was minted. Only populated
158    # for user-kind tokens (browser sessions).
159    ip_address: str = ""
160    user_agent: str = ""
161    # True for the session that made this request — lets the UI show "this
162    # session" and disable the Revoke button so a user can't accidentally
163    # log themselves out.
164    is_current: bool = False
165    # Fine-grained scope summary (empty/"none" for user-kind tokens).
166    competition_access: str = "none"  # none | all | selected
167    competition_ids: list[str] = Field(default_factory=list)
168    permissions: dict[str, str] = Field(default_factory=dict)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

id: str = PydanticUndefined
kind: str = PydanticUndefined
label: str = ''
created_at: datetime.datetime | None = None
last_used_at: datetime.datetime | None = None
expires_at: datetime.datetime | None = None
ip_address: str = ''
user_agent: str = ''
is_current: bool = False
competition_access: str = 'none'
competition_ids: list[str] = PydanticUndefined
permissions: dict[str, str] = PydanticUndefined
class TokenScopesResponse(ctfy.core.models.CtfyModel):
210class TokenScopesResponse(CtfyModel):
211    """``GET /auth/tokens/scopes`` — the catalog the UI renders."""
212
213    categories: list[ScopeCatalogEntry] = Field(default_factory=list)
214    levels: list[str] = Field(default_factory=lambda: ["none", "read", "write"])

GET /auth/tokens/scopes — the catalog the UI renders.

categories: list[ScopeCatalogEntry] = PydanticUndefined
levels: list[str] = PydanticUndefined
class TrendPoint(ctfy.core.models.CtfyModel):
176class TrendPoint(CtfyModel):
177    """One UTC-day bucket on the solve-trend line."""
178
179    date: str = ""  # YYYY-MM-DD, UTC
180    solves: int = 0

One UTC-day bucket on the solve-trend line.

date: str = ''
solves: int = 0
class UpdateUserRoleRequest(ctfy.core.models.CtfyModel):
90class UpdateUserRoleRequest(CtfyModel):
91    """Body for ``PATCH /admin/users/{user_id}/role``.
92
93    Only ``"admin"`` and ``"user"`` are accepted — granting
94    ``"super_admin"`` is reserved for the env allowlist
95    (``CTFY_SUPER_ADMIN_EMAILS``) so the trust anchor stays out-of-band.
96    """
97
98    role: Literal["admin", "user"]

Body for PATCH /admin/users/{user_id}/role.

Only "admin" and "user" are accepted — granting "super_admin" is reserved for the env allowlist (CTFY_SUPER_ADMIN_EMAILS) so the trust anchor stays out-of-band.

role: Literal['admin', 'user'] = PydanticUndefined
class UserInfo(ctfy.core.models.CtfyModel):
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.

id: str = PydanticUndefined
display_name: str = ''
avatar_url: str = ''
created_at: datetime.datetime | None = None
bio: str | None = None
country: str | None = None
website_url: str | None = None
timezone: str | None = None
solves: int = 0
attempts: int = 0
last_active_at: datetime.datetime | None = None
class UserScoreboardEntry(ctfy.core.models.CtfyModel):
735class UserScoreboardEntry(CtfyModel):
736    """One row on the user-ranked global scoreboard.
737
738    Aggregates a single user's solves across every team they've
739    ever been on (each ``SolveState`` carries both ``user_id`` and
740    ``team_id``, so per-user totals are a straight filter over the
741    global solve table). Excludes users with zero solves to keep
742    the public list compact.
743    """
744
745    rank: int = 0
746    user_id: str
747    display_name: str
748    avatar_url: str = ""
749    country: str = ""
750    # Total flags solved across all teams the user has ever been on.
751    flags_solved: int = 0
752    # Challenges fully solved (every declared flag captured).
753    solved: int = 0
754    last_solve_at: datetime | None = None

One row on the user-ranked global scoreboard.

Aggregates a single user's solves across every team they've ever been on (each SolveState carries both user_id and team_id, so per-user totals are a straight filter over the global solve table). Excludes users with zero solves to keep the public list compact.

rank: int = 0
user_id: str = PydanticUndefined
display_name: str = PydanticUndefined
avatar_url: str = ''
country: str = ''
flags_solved: int = 0
solved: int = 0
last_solve_at: datetime.datetime | None = None
class UserSolveTrend(ctfy.core.models.CtfyModel):
196class UserSolveTrend(CtfyModel):
197    """The calling user's daily solve count for the last ``days`` UTC days,
198    aggregated across every team they have ever been on (matched by
199    ``user_id`` on each :class:`SolveState`).
200
201    Powers the Dashboard's "personal solve trend" chart, which is
202    user-scoped — independent of which competition the user is currently
203    registered for. Multi-flag challenges count once per flag day, the
204    same way :class:`ProfileStats.solve_trend` does it.
205    """
206
207    solve_trend: list[TrendPoint] = Field(default_factory=list)

The calling user's daily solve count for the last days UTC days, aggregated across every team they have ever been on (matched by user_id on each SolveState).

Powers the Dashboard's "personal solve trend" chart, which is user-scoped — independent of which competition the user is currently registered for. Multi-flag challenges count once per flag day, the same way ProfileStats.solve_trend does it.

solve_trend: list[TrendPoint] = PydanticUndefined
class VendorStandardSet(ctfy.core.models.CtfyModel):
136class VendorStandardSet(CtfyModel):
137    """A vendor-facing summary of one operator eval standard set.
138
139    A standard set is a competition marked ``eval_standard`` — the fixed
140    challenge subset a vendor self-service campaign runs a model over. This
141    projection is what a ``pv_`` token can see (id / title / size / corpus
142    pin), enough to populate the campaign-launch picker without exposing the
143    full competition surface or requiring a platform user token.
144    """
145
146    id: str = ""
147    title: str = ""
148    description: str = ""  # Markdown
149    challenge_count: int = 0
150    #: corpus snapshot the set was certified against (reproducibility pin).
151    corpus_sha: str = ""

A vendor-facing summary of one operator eval standard set.

A standard set is a competition marked eval_standard — the fixed challenge subset a vendor self-service campaign runs a model over. This projection is what a pv_ token can see (id / title / size / corpus pin), enough to populate the campaign-launch picker without exposing the full competition surface or requiring a platform user token.

id: str = ''
title: str = ''
description: str = ''
challenge_count: int = 0
corpus_sha: str = ''
class VendorStanding(ctfy.core.models.CtfyModel):
59class VendorStanding(CtfyModel):
60    """One vendor's aggregate scorecard across all its evaluated models.
61
62    The vendor's models are pooled: a challenge counts as *solved* if **any**
63    of the vendor's models solved it (the vendor's collective capability), so
64    a vendor fielding several models isn't double-counted per challenge.
65    """
66
67    rank: int = 0
68    vendor: str = ""
69    models: int = 0
70    challenges_attempted: int = 0
71    challenges_solved: int = 0
72    solve_rate: float = 0.0
73    questions_solved: int = 0
74    total_tokens: int = 0
75    #: estimated USD cost summed across the vendor's models (0 when unpriced).
76    cost_usd: float = 0.0

One vendor's aggregate scorecard across all its evaluated models.

The vendor's models are pooled: a challenge counts as solved if any of the vendor's models solved it (the vendor's collective capability), so a vendor fielding several models isn't double-counted per challenge.

rank: int = 0
vendor: str = ''
models: int = 0
challenges_attempted: int = 0
challenges_solved: int = 0
solve_rate: float = 0.0
questions_solved: int = 0
total_tokens: int = 0
cost_usd: float = 0.0
class VerifyAnswerRequest(ctfy.core.models.CtfyModel):
297class VerifyAnswerRequest(CtfyModel):
298    """Oracle-mode answer check — same grader as /submissions but writes
299    no submission record. Carries the same shape as :class:`SubmissionCreate`
300    minus the audit trail.
301    """
302
303    # Which question on the challenge the agent is checking. Defaults
304    # to ``"flag"`` for single-question challenges.
305    question_id: str = "flag"
306    # The answer to verify (str for dynamic / static / single_select;
307    # list[str] for multi_select).
308    answer: str | list[str]

Oracle-mode answer check — same grader as /submissions but writes no submission record. Carries the same shape as SubmissionCreate minus the audit trail.

question_id: str = 'flag'
answer: str | list[str] = PydanticUndefined
class VerifyAnswerResponse(ctfy.core.models.CtfyModel):
311class VerifyAnswerResponse(CtfyModel):
312    correct: bool
313    # Echoes the request's ``question_id`` on a correct match; ``None``
314    # on a wrong claim. Useful for batched verification calls where the
315    # client wants to confirm which check the response refers to.
316    question_id: str | None = None

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

correct: bool = PydanticUndefined
question_id: str | None = None
class VirtualGhostBoard(ctfy.core.models.CtfyModel):
522class VirtualGhostBoard(CtfyModel):
523    """The source contest's standings at the instant this replay has
524    reached, with the replayer merged in and ranked among them.
525
526    ⚠️ Nothing here is stored. The board is
527    `compute_scoreboard_filtered` with an `until_iso`, which the
528    platform has always supported — the replay's own progress and the
529    source's progression are two reads of facts that already exist.
530    """
531
532    source_competition_id: str
533    #: How far into the replay the reader is. The client renders a clock
534    #: from this rather than recomputing it from `starts_at`, so the two
535    #: cannot disagree about which minute the ghosts are showing.
536    elapsed_seconds: int = 0
537    #: The instant in the *source* being shown. Bounded by its end.
538    cutoff: datetime | None = None
539    items: list[VirtualGhostRow] = Field(default_factory=list)

The source contest's standings at the instant this replay has reached, with the replayer merged in and ranked among them.

⚠️ Nothing here is stored. The board is compute_scoreboard_filtered with an until_iso, which the platform has always supported — the replay's own progress and the source's progression are two reads of facts that already exist.

source_competition_id: str = PydanticUndefined
elapsed_seconds: int = 0
cutoff: datetime.datetime | None = None
items: list[VirtualGhostRow] = PydanticUndefined
class VirtualGhostRow(ctfy.server.models.ScoreboardEntry):
511class VirtualGhostRow(ScoreboardEntry):
512    """A standings row on a replay's ghost board.
513
514    `is_me` is the one thing a client cannot derive: the replayer's row
515    comes from a *different competition* than the ghosts', so there is no
516    team id it could compare against.
517    """
518
519    is_me: bool = False

A standings row on a replay's ghost board.

is_me is the one thing a client cannot derive: the replayer's row comes from a different competition than the ghosts', so there is no team id it could compare against.

is_me: bool = False