ctfy.server.models

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

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

Every question a challenge declares, gated ones included.

The organiser's counterpart to the catalog projection, which omits gated entries (see projections.question_infos). Without this an organiser running an exercise has no way to answer "which milestones exist, and why did this team not get credit for one" short of reading metadata.yaml off disk — and the corpus is a submodule the deployment mounts read-only, not something the admin console can see.

Deliberately the same QuestionPublicInfo rows rather than a wider admin model: this route changes who may read, not what is said. The groundtruth is still never projected — a static answer lives in metadata and a dynamic one is minted per instance, and neither belongs on a page. AdminTeamRow is a separate model because it adds a field the public one must not carry; there is no such field here.

challenge_id: str = PydanticUndefined
questions: list[QuestionPublicInfo] = PydanticUndefined
gated_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):
901class AdminPatchSubmissionDetail(AdminPatchSubmissionInfo):
902    """One submission with the code it carried.
903
904    This is what keeping the bytes was *for*: a team disputes a verdict,
905    or a checker bug is found on day two, and someone has to read what
906    was actually submitted. Without a reader the stored blobs bought
907    nothing.
908    """
909
910    #: ``{path: base64(bytes)}``. Base64 rather than text because a
911    #: patch target may legitimately be binary, and decoding it into a
912    #: string would corrupt it into something that merely looks like a
913    #: bad submission.
914    files_b64: dict[str, str] = Field(default_factory=dict)
915    #: ``{path: sha256}`` — an equality match across teams, which is
916    #: what is left once uniqueness has stopped flag sharing but nothing
917    #: stops *patch* sharing.
918    digests: dict[str, str] = Field(default_factory=dict)

One submission with the code it carried.

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

files_b64: dict[str, str] = PydanticUndefined
digests: dict[str, str] = PydanticUndefined
class AdminPatchSubmissionInfo(ctfy.server.models.PatchSubmissionInfo):
880class AdminPatchSubmissionInfo(PatchSubmissionInfo):
881    """One row of the organiser's defence queue.
882
883    The player's view plus the four things only an organiser asks:
884    *whose* it is, and — when a verdict is disputed — which worker
885    decided it after how many tries.
886
887    Still **without the code**, and here that is a stronger requirement
888    than on the parent: this is a competition-wide listing, so shipping
889    bodies would mean reading every stored patch of the event to render
890    a table of statuses. The bytes live on
891    :class:`AdminPatchSubmissionDetail`, one row at a time.
892    """
893
894    team_id: str = ""
895    user_id: str = ""
896    #: Which worker judged it, for correlating with that node's logs.
897    node_id: str = ""
898    attempts: int = 0

One row of the organiser's defence queue.

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

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

team_id: str = ''
user_id: str = ''
node_id: str = ''
attempts: int = 0
class AdminPatchSubmissionPage(ctfy.core.models.CtfyModel):
921class AdminPatchSubmissionPage(CtfyModel):
922    """A page of the defence queue, with the total behind it.
923
924    ``total`` is its own query rather than ``len(items)`` so a pager can
925    say "1-20 of 340" — and, with a ``status`` filter, it is also the
926    answer to "is the queue draining".
927    """
928
929    items: list[AdminPatchSubmissionInfo] = Field(default_factory=list)
930    total: int = 0

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

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

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):
614class AdminTaskInfo(CtfyModel):
615    """One background task on ``GET /admin/tasks`` / detail. A projection
616    of ``TaskState`` onto the wire (these routes are admin-only, so the
617    full ``error_detail`` traceback tail is included)."""
618
619    id: str = ""
620    kind: str = ""
621    status: str = ""
622    params: dict[str, Any] = Field(default_factory=dict)
623    result: dict[str, Any] = Field(default_factory=dict)
624    progress: float = 0.0
625    progress_message: str = ""
626    error: str = ""
627    error_detail: str = ""
628    cancel_requested: bool = False
629    created_by_user_id: str = ""
630    created_by_name: str = ""
631    created_at_ts: float = 0.0
632    started_at_ts: float = 0.0
633    finished_at_ts: float = 0.0

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

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):
636class AdminTaskListResponse(CtfyModel):
637    """Paginated task list."""
638
639    items: list[AdminTaskInfo] = Field(default_factory=list)
640    total: int = 0
641    offset: int = 0
642    limit: int = 50

Paginated task list.

items: list[AdminTaskInfo] = PydanticUndefined
total: int = 0
offset: int = 0
limit: int = 50
class AdminTaskLogLine(ctfy.core.models.CtfyModel):
645class AdminTaskLogLine(CtfyModel):
646    """One verbose log line for a task (admin-only)."""
647
648    seq: int = 0
649    ts: float = 0.0
650    level: str = "info"
651    message: str = ""

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

seq: int = 0
ts: float = 0.0
level: str = 'info'
message: str = ''
class AdminTaskLogsResponse(ctfy.core.models.CtfyModel):
654class AdminTaskLogsResponse(CtfyModel):
655    """A cursor page of task logs. ``next_after`` is the ``seq`` to pass as
656    ``?after=`` on the next poll (the last item's seq, or the request's
657    ``after`` when the page is empty — so an empty poll never rewinds the
658    cursor)."""
659
660    items: list[AdminTaskLogLine] = Field(default_factory=list)
661    next_after: int = 0

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

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

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):
85class AdminUserInfo(UserInfo):
86    """Admin-only view of a user. Carries the privilege tier and the
87    most recent role-change audit fields."""
88
89    email: str = ""
90    role: Literal["user", "admin", "super_admin"] = "user"
91    promoted_by: str | None = None
92    promoted_at: datetime | None = None
93    # OAuth providers the user has bound (e.g. ``["github", "google"]``),
94    # so the admin user list can surface which SSO accounts are linked.
95    providers: list[str] = Field(default_factory=list)

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

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):
237class AnswerTraceHit(CtfyModel):
238    """One instance that was minted the traced answer.
239
240    ``live`` distinguishes a still-running instance (matched against the
241    in-memory minted map) from an archived one (matched against the
242    persisted digest). Both are authoritative; the flag only says
243    whether the instance can still be inspected or stopped.
244    """
245
246    instance_id: str = ""
247    question_id: str = ""
248    team_id: str = ""
249    team_name: str = ""
250    challenge_id: str = ""
251    competition_id: str = ""
252    live: bool = False
253    started_at: float = 0.0
254    stopped_at: float = 0.0

One instance that was minted the traced answer.

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

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):
223class AnswerTraceRequest(CtfyModel):
224    """Body for ``POST /admin/answer-trace``.
225
226    The answer travels in the body rather than a query parameter
227    because a live flag in a URL lands in every access log, proxy
228    cache, and browser history between the operator and the app.
229
230    Accepted in either the ``FLAG{...}`` or bare form — the server
231    normalises both to one digest, matching how submissions grade.
232    """
233
234    answer: str = Field(min_length=1, max_length=512)

Body for POST /admin/answer-trace.

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

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

answer: str = PydanticUndefined
class AnswerTraceResponse(ctfy.core.models.CtfyModel):
257class AnswerTraceResponse(CtfyModel):
258    """Result of ``POST /admin/answer-trace``.
259
260    ``hits`` is normally empty or length 1. More than one means two
261    instances genuinely drew the same value — astronomically unlikely
262    for a 128-bit token, but reported rather than silently collapsed
263    because attributing a leak to the wrong team is worse than
264    reporting ambiguity.
265
266    ``traceable_records`` is the number of archived records that carry
267    a digest at all. Instances archived before answer fingerprinting
268    shipped have none, so an empty ``hits`` on a low count means "this
269    predates tracing", not "this flag was never minted here" — the
270    distinction matters when an organiser is deciding whether to act.
271    """
272
273    answer_hash: str = ""
274    hits: list[AnswerTraceHit] = Field(default_factory=list)
275    searched_live: int = 0
276    searched_records: int = 0
277    traceable_records: int = 0

Result of POST /admin/answer-trace.

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

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

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):
180class AttachmentList(CtfyModel):
181    """Response payload for ``GET /challenges/{id}/attachments``.
182
183    Same data as ``ChallengeInfo.attachments`` but addressable directly
184    so pre-launch UI / agent tooling can fetch it without paging through
185    the catalog. Order matches the on-disk sort.
186    """
187
188    files: list[AttachmentInfo] = Field(default_factory=list)

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

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

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):
555class BulkInviteRequest(CtfyModel):
556    """Body for ``POST /admin/competitions/{id}/invites/bulk``.
557
558    Organisers arrive with a qualifying list — a column pasted out of a
559    spreadsheet, newline- or comma-separated, usually with stray spaces
560    and the odd duplicate. Splitting it server-side keeps every client
561    (web, CLI, SDK) from having to agree on the same rules.
562    """
563
564    # One blob rather than a parsed list: a textarea is what the organiser
565    # actually has, and asking the client to split it invites three
566    # slightly different splitters.
567    emails: str = Field(default="", max_length=200_000)

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

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

emails: str = ''
class BulkInviteResponse(ctfy.core.models.CtfyModel):
583class BulkInviteResponse(CtfyModel):
584    """Per-address outcomes plus the tallies the toast reports.
585
586    Every address is echoed back rather than only the failures: an
587    organiser pasting 300 lines needs to see that 300 were understood,
588    and a silent drop is indistinguishable from success.
589    """
590
591    results: list[BulkInviteResultRow] = Field(default_factory=list)
592    invited: int = 0
593    pending: int = 0
594    already: int = 0
595    invalid: int = 0

Per-address outcomes plus the tallies the toast reports.

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

results: list[BulkInviteResultRow] = PydanticUndefined
invited: int = 0
pending: int = 0
already: int = 0
invalid: int = 0
class BulkInviteResultRow(ctfy.core.models.CtfyModel):
570class BulkInviteResultRow(CtfyModel):
571    """What happened to one address. ``status`` is one of:
572
573    ``invited`` — a new allowlist row (the user existed);
574    ``pending`` — recorded against the address, waiting for a sign-up;
575    ``already`` — invited before, so nothing changed;
576    ``invalid`` — not an email address, skipped.
577    """
578
579    email: str
580    status: str

What happened to one address. status is one of:

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

email: str = PydanticUndefined
status: str = PydanticUndefined
class CalendarBucket(ctfy.core.models.CtfyModel):
154class CalendarBucket(CtfyModel):
155    """One UTC-day cell on the contribution calendar.
156
157    ``count`` aggregates correct submissions + first-time solves on that
158    day; effectively "did anything productive happen". The series is
159    dense — every day in the requested window is present, zero-filled."""
160
161    date: str = ""  # YYYY-MM-DD, UTC
162    count: int = 0

One UTC-day cell on the contribution calendar.

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

date: str = ''
count: int = 0
class ChallengeBuildKickoffNodeResult(ctfy.core.models.CtfyModel):
318class ChallengeBuildKickoffNodeResult(CtfyModel):
319    """One node's response to a build kickoff (single or all).
320
321    ``queued`` is populated only on the ``build-all`` fan-out; for the
322    single-challenge variant the platform inspects ``status`` to learn
323    what the node accepted.
324    """
325
326    node_id: str
327    ok: bool
328    status: str = ""
329    queued: list[str] = Field(default_factory=list)
330    skipped_built: list[str] = Field(default_factory=list)
331    skipped_in_progress: list[str] = Field(default_factory=list)
332    error: str = ""

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):
335class ChallengeBuildKickoffResponse(CtfyModel):
336    """Admin ``POST /admin/challenges/{id}/build`` and ``…/build-all`` response."""
337
338    challenge_id: str = ""  # empty for build-all
339    nodes: list[ChallengeBuildKickoffNodeResult] = Field(default_factory=list)

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

challenge_id: str = ''
nodes: list[ChallengeBuildKickoffNodeResult] = PydanticUndefined
class ChallengeBuildNodeState(ctfy.core.models.CtfyModel):
275class ChallengeBuildNodeState(CtfyModel):
276    """One worker node's view of a single challenge's build state.
277
278    ``status`` is one of ``unbuilt`` / ``building`` / ``built`` /
279    ``failed``. ``node_error`` is non-empty only when the platform
280    could not reach the node at all (heartbeat stale / 5xx) — in that
281    case ``status`` is forced to ``unbuilt`` for the aggregate.
282    """
283
284    node_id: str
285    status: str = "unbuilt"
286    built_at: float = 0.0
287    error: str = ""
288    node_error: str = ""

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

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

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

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

rows: list[ChallengeBuildStateRow] = PydanticUndefined
class ChallengeBuildStateRow(ctfy.core.models.CtfyModel):
291class ChallengeBuildStateRow(CtfyModel):
292    """Per-challenge build state aggregated across every online node.
293
294    ``aggregated`` rolls up ``nodes`` using a worst-case rule so the
295    table's single status column behaves intuitively:
296
297    * any ``building`` → ``building`` (yellow)
298    * any ``failed``  → ``failed`` (red)
299    * any ``unbuilt`` → ``unbuilt`` (grey)
300    * else            → ``built`` (green)
301
302    A node that couldn't be reached at all contributes ``unbuilt`` so
303    the aggregate stays conservative — the admin still sees a
304    "missing" pip and can drill into the modal to find out why.
305    """
306
307    challenge_id: str
308    aggregated: str = "unbuilt"
309    nodes: list[ChallengeBuildNodeState] = Field(default_factory=list)

Per-challenge build state aggregated across every online node.

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

  • any 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):
191class ChallengeFacetCount(CtfyModel):
192    """One bucket on the challenge catalog facet summary.
193
194    ``value`` is the difficulty / tag string; ``total`` is how many
195    challenges in scope carry it. ``solved`` is the *calling* user's
196    solved count in that bucket (across every team they have been on);
197    it is 0 for anonymous callers and for tag buckets (the UI only
198    renders a solved/total ratio for difficulty).
199    """
200
201    value: str
202    total: int = 0
203    solved: int = 0

One bucket on the challenge catalog facet summary.

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

value: str = PydanticUndefined
total: int = 0
solved: int = 0
class ChallengeFacets(ctfy.core.models.CtfyModel):
206class ChallengeFacets(CtfyModel):
207    """Catalog aggregates for the Challenges page side panels + filter
208    pill counts — served by ``GET /challenges/facets`` so the page no
209    longer derives them from a bulk fetch of the whole catalog.
210
211    Scoped by ``competition_id`` (when given) exactly like the list
212    endpoint, so the pills/charts reflect the same subset the paged
213    list pages through.
214    """
215
216    total: int = 0
217    difficulty: list[ChallengeFacetCount] = Field(default_factory=list)
218    # Per-category counts (only categories with ≥1 challenge in scope
219    # show up). Ordered by the canonical category sequence so the
220    # filter pills always render web → pwn → reverse → crypto → misc.
221    category: list[ChallengeFacetCount] = Field(default_factory=list)
222    tags: list[ChallengeFacetCount] = Field(default_factory=list)
223    # Per-status counts for the calling caller (``solved`` / ``unsolved``
224    # / ``running``). Drives the count chip on each status filter pill
225    # so the page doesn't N+1 a status-filtered list per pill. Buckets
226    # are always present (in canonical order) for layout stability; on
227    # anonymous callers every count is 0 — never leaking another
228    # team's state.
229    status: list[ChallengeFacetCount] = Field(default_factory=list)
230    # Per-source-bucket counts — the prefix of a challenge id
231    # (``CVE-2021-1234`` → ``CVE``), i.e. the benchmark suite an item
232    # was ingested from. Ordered by count descending then id, so a
233    # caller rendering a top-N breakdown gets a stable, meaningful
234    # slice order without re-sorting. ``solved`` is always 0 here:
235    # this facet answers "what is the catalog made of", not "how am I
236    # doing", and the landing page that consumes it is anonymous.
237    buckets: list[ChallengeFacetCount] = Field(default_factory=list)

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

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

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):
407class ChallengeFlagStats(CtfyModel):
408    """Per-flag aggregate for one challenge (for the detail page)."""
409
410    flag_id: str
411    solves_count: int = 0

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

flag_id: str = PydanticUndefined
solves_count: int = 0
class ChallengeInfo(ctfy.core.models.CtfyModel):
134class ChallengeInfo(CtfyModel):
135    id: str
136    name: str
137    category: ChallengeCategory = ChallengeCategory.WEB
138    difficulty: str = ""
139    description: str = ""
140    # Full-challenge solves: teams that captured every declared
141    # question (across all modes — dynamic + static + select).
142    solves_count: int = 0
143    # Times this challenge has been instantiated, ever — one per
144    # archived InstanceRecord (every terminal path writes one). A
145    # "how much has this been run" signal for the challenge card;
146    # platform-wide, not scoped to the viewing team or competition.
147    launch_count: int = 0
148    tags: list[str] = Field(default_factory=list)
149    # Every question declared on this challenge, in author-declared
150    # order. Single-question challenges have a single entry with
151    # id ``"flag"``. ``QuestionPublicInfo`` exposes prompt/mode/choices
152    # but never the answer.
153    questions: list[QuestionPublicInfo] = Field(default_factory=list)
154    # Per-question solve counts (how many teams captured each
155    # question). Keyed by question id. Useful for the challenge
156    # detail page's progress breakdown. Counts span every mode;
157    # the multi_select grader records one solve per fully-correct
158    # submission, not per chosen choice.
159    flag_solves: dict[str, int] = Field(default_factory=dict)
160    # Files shipped under the challenge's ``attachments/`` directory,
161    # downloadable via ``GET /challenges/{id}/attachments/{name}``.
162    # Empty for pure-network challenges that ship nothing.
163    attachments: list[AttachmentInfo] = Field(default_factory=list)
164    # True for pure question-answer challenges (``category: misc``,
165    # no ``docker-compose.yml`` on disk, every declared question
166    # carries a static ``answer:``). The frontend keys off this flag
167    # to switch the competition surface to a quiz UI and skip the
168    # launch-confirm flow. Source of truth lives in
169    # :attr:`ctfy.core.challenge.ChallengeSpec.is_qa_only`, ultimately
170    # derived from :attr:`BenchmarkContext.is_qa_only`.
171    is_qa_only: bool = False
172    # Set for AWD+ challenges; ``None`` for every other kind. Same role
173    # as ``is_qa_only`` one field up — the flag a client keys off to
174    # switch surface — but an object rather than a bool because a
175    # defence surface needs to know *what* it may send, not only that it
176    # may send something.
177    defence: DefenceInfo | None = None

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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):
388class ChallengePullKickoffNodeResult(CtfyModel):
389    """One node's response to a pull kickoff (single or all)."""
390
391    node_id: str
392    ok: bool
393    status: str = ""
394    queued: list[str] = Field(default_factory=list)
395    skipped_pulled: list[str] = Field(default_factory=list)
396    skipped_in_progress: list[str] = Field(default_factory=list)
397    error: str = ""

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

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):
400class ChallengePullKickoffResponse(CtfyModel):
401    """Admin ``POST /admin/challenges/{id}/pull`` and ``…/pull-all`` response."""
402
403    challenge_id: str = ""  # empty for pull-all
404    nodes: list[ChallengePullKickoffNodeResult] = Field(default_factory=list)

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

challenge_id: str = ''
nodes: list[ChallengePullKickoffNodeResult] = PydanticUndefined
class ChallengePullNodeState(ctfy.core.models.CtfyModel):
349class ChallengePullNodeState(CtfyModel):
350    """One worker node's view of a single challenge's pull state.
351
352    ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` /
353    ``failed``. ``node_error`` is non-empty only when the platform could
354    not reach the node at all — ``status`` is then forced to ``unpulled``
355    for the aggregate.
356    """
357
358    node_id: str
359    status: str = "unpulled"
360    pulled_at: float = 0.0
361    error: str = ""
362    node_error: str = ""

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

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

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

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

rows: list[ChallengePullStateRow] = PydanticUndefined
class ChallengePullStateRow(ctfy.core.models.CtfyModel):
365class ChallengePullStateRow(CtfyModel):
366    """Per-challenge pull state aggregated across every online node.
367
368    ``aggregated`` rolls up ``nodes`` worst-case, mirroring the build
369    row:
370
371    * any ``pulling`` → ``pulling`` (yellow)
372    * any ``failed``  → ``failed`` (red)
373    * any ``unpulled`` → ``unpulled`` (grey)
374    * else            → ``pulled`` (green)
375    """
376
377    challenge_id: str
378    aggregated: str = "unpulled"
379    nodes: list[ChallengePullNodeState] = Field(default_factory=list)

Per-challenge pull state aggregated across every online node.

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

  • any 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):
240class ChallengeRescanNodeResult(CtfyModel):
241    """Outcome of fanning the rescan out to one worker node.
242
243    ``ok`` is False for an offline node (skipped, never contacted) or a
244    node that errored; ``total`` is the node's post-rescan challenge
245    count when reached, else ``None``. One unreachable node never fails
246    the whole operation — the platform rescan still stands.
247    """
248
249    node_id: str
250    ok: bool
251    total: int | None = None
252    error: str | None = None

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):
255class ChallengeRescanResult(CtfyModel):
256    """Admin ``POST /admin/challenges/rescan`` response.
257
258    ``total`` / ``added`` / ``removed`` describe the platform's own
259    catalog after re-scanning ``challenges_dir``; ``nodes`` carries the
260    per-worker fan-out outcome so a newly added challenge is confirmed
261    launchable, not just listable.
262    """
263
264    total: int
265    added: list[str] = Field(default_factory=list)
266    removed: list[str] = Field(default_factory=list)
267    nodes: list[ChallengeRescanNodeResult] = Field(default_factory=list)

Admin POST /admin/challenges/rescan response.

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

total: int = PydanticUndefined
added: list[str] = PydanticUndefined
removed: list[str] = PydanticUndefined
nodes: list[ChallengeRescanNodeResult] = PydanticUndefined
class ChallengeSolveAttempt(ctfy.core.models.CtfyModel):
428class ChallengeSolveAttempt(CtfyModel):
429    """One archived instance for a challenge — used to visualise per-team
430    multi-solve attempts on the challenge detail page."""
431
432    instance_id: str
433    team_id: str = ""
434    team_name: str = ""
435    display_name: str = ""
436    challenge_id: str = ""
437    started_at: float = 0.0
438    stopped_at: float = 0.0
439    duration_s: float = 0.0
440    attempts: int = 0
441    solved: bool = False
442    solved_flags: list[str] = Field(default_factory=list)
443    stop_reason: str = ""

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

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):
460class ChallengeSolveAttemptsResponse(CtfyModel):
461    challenge_id: str
462    attempts: list[ChallengeSolveAttempt] = Field(default_factory=list)
463    per_team: list[ChallengeTeamSolveSummary] = Field(default_factory=list)
464    # Total archived instances (incl. unsolved) — lets the UI show
465    # "showing N solved of M total" when the response is filtered.
466    total_attempts: int = 0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

challenge_id: str = PydanticUndefined
attempts: list[ChallengeSolveAttempt] = PydanticUndefined
per_team: list[ChallengeTeamSolveSummary] = PydanticUndefined
total_attempts: int = 0
class ChallengeStats(ctfy.core.models.CtfyModel):
414class ChallengeStats(CtfyModel):
415    challenge_id: str
416    name: str = ""
417    category: ChallengeCategory | None = None
418    difficulty: str = ""
419    # Teams that captured every declared flag on this challenge.
420    solves_count: int = 0
421    total_attempts: int = 0
422    success_rate: float = 0.0
423    # Per-flag breakdown. Single-flag challenges have a single entry with
424    # ``flag_id == "flag"`` and ``solves_count == solves_count`` above.
425    flag_stats: list[ChallengeFlagStats] = Field(default_factory=list)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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):
446class ChallengeTeamSolveSummary(CtfyModel):
447    """Per-team aggregate for the challenge detail solve-attempts panel."""
448
449    team_id: str
450    team_name: str = ""
451    display_name: str = ""
452    solve_count: int = 0
453    attempt_count: int = 0
454    best_duration_s: float = 0.0
455    total_duration_s: float = 0.0
456    first_solved_at: float = 0.0
457    last_solved_at: float = 0.0

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

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):
393class CheckAnswerRequest(CtfyModel):
394    """Run the checker for one *checker-mode* question on an instance.
395
396    Proof-of-exploitation: the platform execs the question's
397    author-supplied checker inside the trusted judge sidecar; the player
398    submits no answer string — the sidecar emits the minted token only
399    when the exploit's effect is verified.
400    """
401
402    # Which checker question to verify. Checker questions usually carry a
403    # descriptive id (``"deface"`` / ``"persist"`` / …); defaults to
404    # ``"flag"`` for the single-question shape.
405    question_id: str = "flag"

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

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

question_id: str = 'flag'
class CheckAnswerResponse(ctfy.core.models.CtfyModel):
408class CheckAnswerResponse(CtfyModel):
409    # True when the checker confirmed the exploit (its stdout matched the
410    # instance's minted token for this question) and a solve was recorded.
411    passed: bool
412    # Echoes the question id on a pass; ``None`` otherwise.
413    question_id: str | None = None
414    # Checker process exit code — diagnostic only; the token match is the
415    # real signal. ``None`` when the checker couldn't be run at all.
416    exit_code: int | None = None
417    # True when the checker exceeded its wall-clock budget.
418    timed_out: bool = False
419    # True when the team had already solved this question (short-circuit,
420    # no node call made).
421    already_solved: bool = False
422    # First-blood rank on a fresh solve (1/2/3 = 1st/2nd/3rd blood), 0
423    # otherwise. Mirrors ``SubmissionResponse.solve_rank``.
424    solve_rank: int = 0
425    # True when this check completed the challenge (every question solved).
426    challenge_fully_solved: bool = False
427    # Server clock (epoch seconds) when the check ran, for the UI's
428    # "last checked" line.
429    last_checked_at: float = 0.0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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):
171class ClusterInfo(CtfyModel):
172    """Returned by ``GET /cluster-info``.
173
174    Provides the bits the ``ctfy-admin server invite`` CLI and admin UI wizard
175    need to build a runnable join command. The *registration token* is
176    minted separately via ``POST /nodes/invites`` — it isn't on this
177    payload because cluster info is queried speculatively by the UI on
178    every admin dashboard load, and we never want to hand an invite
179    token to a page that didn't explicitly ask for one.
180    """
181
182    platform_url: str
183    node_image: str

Returned by GET /cluster-info.

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

platform_url: str = PydanticUndefined
node_image: str = PydanticUndefined
class CompetitionAdminInfo(ctfy.core.models.CtfyModel):
512class CompetitionAdminInfo(CtfyModel):
513    """One per-competition admin grant, for the super-admin management
514    card on the competition detail page."""
515
516    user_id: str
517    display_name: str = ""
518    email: str = ""
519    role: CompetitionGrantRole = "admin"
520    granted_by: str = ""
521    granted_at: datetime | None = None

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

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):
758class CompetitionChallengeBreakdown(CtfyModel):
759    challenges: list[CompetitionChallengeRow] = Field(default_factory=list)
760    generated_at: float = 0.0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

challenges: list[CompetitionChallengeRow] = PydanticUndefined
generated_at: float = 0.0
class CompetitionChallengeRow(ctfy.core.models.CtfyModel):
736class CompetitionChallengeRow(CtfyModel):
737    """Per-challenge competition aggregate: solve count, attempt
738    success rate, and first blood. Reconstructed from the solve log
739    with the same window / team filter as the scoreboard so the
740    numbers always agree with the leaderboard."""
741
742    challenge_id: str
743    name: str = ""
744    category: ChallengeCategory | None = None
745    difficulty: str = ""
746    # Teams that captured every declared flag (matches scoreboard's
747    # ``solved`` tally).
748    solves_count: int = 0
749    # In-window submissions for this challenge across registered teams.
750    attempts: int = 0
751    success_rate: float = 0.0
752    # First team to solve this challenge in-window. Empty when unsolved.
753    first_blood_team_id: str = ""
754    first_blood_team_name: str = ""
755    first_blood_at: datetime | None = None

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

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):
136class CompetitionCreate(CtfyModel):
137    """Request body for ``POST /admin/competitions``.
138
139    Time fields accept ISO 8601 strings; empty means "no bound" (live
140    immediately / never expires). ``challenge_ids`` is validated against
141    the spec registry inside the route handler — unknown ids return 422.
142    """
143
144    title: str = Field(min_length=1, max_length=200)
145    description: str = Field(default="", max_length=20000)
146    starts_at: datetime | None = None
147    ends_at: datetime | None = None
148    # No fixed length cap: the real bound is ``_check_challenge_ids`` in
149    # ``admin_competitions`` (every id must be a known spec, so a valid list
150    # can never exceed the catalog). A magic number here is arbitrary and a
151    # recurring foot-gun as the corpus grows — bucket comps already exceed 1000.
152    challenge_ids: list[str] = Field(default_factory=list)
153    # New competitions start hidden ("draft") so admins can prepare them
154    # privately and publish when ready.
155    status: Literal["draft", "published", "archived"] = "draft"
156    # Participation access (orthogonal to ``status``). ``public`` is open
157    # to everyone; ``private_listed`` / ``private_hidden`` require an admin
158    # invite to register (the latter also hides the comp from non-invited
159    # users). Defaults to ``public`` so ad-hoc API callers stay open.
160    access: Literal["public", "private_listed", "private_hidden"] = "public"
161    # Marks this competition as an operator-curated eval **standard set** —
162    # only such a competition may back an ``official`` EvalCampaign
163    # (docs/model-eval-vendor-tenancy.md §4.4). ``corpus_sha`` optionally pins
164    # the benchmark-corpus snapshot it was certified against. Off by default.
165    eval_standard: bool = False
166    corpus_sha: str = ""
167    # Which rule ranks this competition's board, and its tuning. Empty
168    # selects the platform default (``flag_count``), so an API caller
169    # that says nothing gets exactly the historic behaviour. Set it here
170    # rather than later where you can: the rule freezes once the
171    # competition goes live (see ``scoring_locked``).
172    scoring_rule: str = ""
173    scoring_params: dict[str, Any] = Field(default_factory=dict)
174    # Silent exercise mode — the competition tells the player nothing.
175    # See ``CompetitionState.silent`` and the plan's §4b. Orthogonal to
176    # ``scoring_rule`` on purpose, so a range can be run silently for a
177    # human exercise and loudly for an agent benchmark.
178    silent: bool = False
179    # Playable slices inside the window. Empty (the default) means the
180    # whole ``starts_at``..``ends_at`` window is playable, which is how
181    # every competition has always behaved. Supplying sessions also
182    # *sets* the window: ``CompetitionState`` derives ``starts_at`` /
183    # ``ends_at`` from the envelope, so an organiser sending sessions
184    # need not compute the outer dates and cannot contradict them.
185    sessions: list[CompetitionSessionInfo] = Field(default_factory=list)
186    # Seconds after the play window closes during which the engagement
187    # report may still be saved. ``0`` (the default) means the report
188    # closes with the event, which is exactly where the play gate closed
189    # it before this field existed. See ``CompetitionState.report_window_s``.
190    report_window_s: int = 0
191    # --- registration ---------------------------------------------------
192    # The window sign-ups are open, distinct from the play window above:
193    # a real event closes entries well before the first challenge unlocks
194    # so the organiser can vet entrants and print certificates.
195    registration_starts_at: datetime | None = None
196    registration_ends_at: datetime | None = None
197    # Per-competition roster cap. 0 defers to the platform-wide setting,
198    # so two events on one deployment can carry different rules.
199    max_team_size: int = Field(default=0, ge=0)
200    # When true a newly registered team lands ``pending`` and cannot play
201    # until an organiser approves it.
202    registration_review: bool = False
203    # field name -> "hidden" | "optional" | "required". Keys are validated
204    # against REGISTRATION_FIELDS. Anything absent is treated as hidden,
205    # so an unconfigured competition collects nothing extra.
206    registration_fields: dict[str, RegistrationFieldPolicy] = Field(default_factory=dict)
207    # Days after ``ends_at`` before the retention sweep blanks the three
208    # sealed columns (real name, phone, postal address). ``0`` keeps them
209    # indefinitely, which is what every competition predating this reads
210    # as — the purge is irreversible, so turning it on is a decision
211    # somebody makes about one event, never a default.
212    pii_retention_days: int = Field(default=0, ge=0)
213
214    @field_validator("registration_fields")
215    @classmethod
216    def _known_registration_fields(
217        cls, v: dict[str, RegistrationFieldPolicy]
218    ) -> dict[str, RegistrationFieldPolicy]:
219        return _check_registration_fields(v)
220
221    _norm_times = field_validator("starts_at", "ends_at", mode="before")(_blank_to_none)

Request body for POST /admin/competitions.

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

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
silent: bool = False
sessions: list[CompetitionSessionInfo] = PydanticUndefined
report_window_s: int = 0
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):
473class CompetitionDetail(CompetitionInfo):
474    """Full detail payload — includes a *preview* of the resolved
475    challenge summaries and the caller's registration state when
476    authenticated.
477
478    ⚠️ ``challenges`` is bounded by
479    :data:`~ctfy.server.routes.competitions.COMPETITION_CHALLENGE_PREVIEW`
480    and is **not** the competition's challenge set. It used to be, and a
481    bucket competition put hundreds of full ``ChallengeInfo`` rows —
482    each carrying questions, attachments and a defence block — through
483    a payload whose only reader renders six cards.
484
485    Anything that means *all of them* must page
486    ``GET /competitions/{id}/challenges`` (SDK:
487    ``client.competition(id).challenges(offset, limit)``), and anything
488    that means *how many* must read ``challenge_count``. Both existed as
489    ``len(challenges)`` before the bound and would have kept working
490    while reporting the preview size — which is why the two aggregates
491    below are served rather than left to the caller to derive.
492    """
493
494    challenges: list[ChallengeInfo] = Field(default_factory=list)
495    # The true totals, over the whole resolved set rather than the
496    # preview. Derived here because a client cannot recover them from a
497    # bounded list, and two clients deriving them differently is two
498    # clients disagreeing about how big the competition is.
499    challenge_count: int = 0
500    difficulty_counts: dict[str, int] = Field(default_factory=dict)
501    registered_at: datetime | None = None

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

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

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

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):
286class CompetitionInfo(CtfyModel):
287    """Summary row used in the list view.
288
289    Per-viewer fields (``is_registered`` / ``my_*``) are populated only
290    when the request carries an authenticated user; for anonymous
291    requests they fall back to sentinels (empty strings, ``-1``) so
292    the response shape stays stable. Front-end treats ``my_rank < 0``
293    as "no value" rather than "rank zero".
294    """
295
296    id: str
297    title: str
298    description: str
299    starts_at: datetime | None = None
300    ends_at: datetime | None = None
301    created_at: datetime | None = None
302    updated_at: datetime | None = None
303    created_by: str
304    created_by_name: str
305    challenge_ids: list[str] = Field(default_factory=list)
306    # Visibility gate. Only ``published`` rows reach non-admins; admins
307    # and per-competition admins also receive ``draft`` / ``archived``.
308    status: Literal["draft", "published", "archived"] = "published"
309    # Participation access. ``public`` is open; ``private_listed`` stays
310    # discoverable but invite-only to register; ``private_hidden`` is also
311    # hidden from non-invited users. Default ``public`` for backward
312    # compat (legacy rows / anonymous responses).
313    access: Literal["public", "private_listed", "private_hidden"] = "public"
314    #: The series this contest is an occurrence of, empty for a one-off.
315    #: Carried so a client can reach the **ladder** — before this the id
316    #: was on no wire model, so the whole rating surface was unreachable
317    #: from the browser however public its route was. Not a disclosure:
318    #: it names a series whose ladder the reader may already read, and
319    #: nothing about the series' pool or cadence travels with it.
320    series_id: str = ""
321    # Eval standard-set marker + pinned corpus snapshot (admin-only meaning;
322    # see CompetitionState). Surfaced so the admin form can show/edit it.
323    eval_standard: bool = False
324    corpus_sha: str = ""
325    # Registration window + form config. The window is genuinely public
326    # information — an entrant needs to know when sign-ups close, and it
327    # differs from the play window — and the rest lets the admin edit form
328    # prefill without a second fetch. Nothing here is sensitive: the same
329    # field policy is already served to any signed-in user by
330    # ``GET /competitions/{id}/registration``.
331    registration_starts_at: datetime | None = None
332    registration_ends_at: datetime | None = None
333    max_team_size: int = 0
334    registration_review: bool = False
335    registration_fields: dict[str, RegistrationFieldPolicy] = Field(default_factory=dict)
336    # Public deliberately: how long entrants' personal data is kept is
337    # part of the notice they are owed, not an internal setting.
338    pii_retention_days: int = 0
339    # Which rule ranks this board, and its tuning. Empty name means the
340    # platform default (``flag_count``). Public because the players are
341    # entitled to know how they are being scored.
342    scoring_rule: str = ""
343    scoring_params: dict[str, Any] = Field(default_factory=dict)
344    # Silent exercise mode — the competition tells the player nothing.
345    # See ``CompetitionState.silent`` and the plan's §4b. Orthogonal to
346    # ``scoring_rule`` on purpose, so a range can be run silently for a
347    # human exercise and loudly for an agent benchmark.
348    silent: bool = False
349    # Server-derived: whether the rule is now frozen. The admin form
350    # disables the control off this rather than re-deriving the
351    # draft/phase rule client-side and disagreeing with the server.
352    scoring_locked: bool = False
353    # Server-derived projection. ``"upcoming"`` / ``"running"`` / ``"past"``
354    # — saves the frontend from re-implementing the same string-compare
355    # the backend already does.
356    phase: Literal["upcoming", "running", "past"] = "running"
357    # Declared play sessions, empty for a single-window competition.
358    # Public: an entrant has to know the event runs 08:00–16:00 daily,
359    # and the schedule is the first thing a competition page shows.
360    sessions: list[CompetitionSessionInfo] = Field(default_factory=list)
361    # Server-derived: whether play is open *right now*. Deliberately not
362    # folded into ``phase`` — a competition between its 16:00 and the
363    # next 08:00 is still running (board live, registration open, scoring
364    # window unmoved), and re-classifying it would change what every
365    # existing ``phase`` consumer says about a live event.
366    #
367    # ``True`` for every competition with no sessions, which is the whole
368    # existing corpus: they are gated by nothing, so play is never shut.
369    play_open: bool = True
370    # When play reopens. ``None`` means there is no next session — either
371    # none are declared, or the last one has ended. The closed-for-now
372    # card counts down to this, so it is the one field it needs.
373    next_session_starts_at: datetime | None = None
374    # When the current session closes. ``None`` outside a session, and
375    # also for a competition that declares none — there is no session to
376    # name, and reporting the window's end here would put a "session ends
377    # at" countdown on an event that never had sessions.
378    current_session_ends_at: datetime | None = None
379    # How long after the event the engagement report stays writable.
380    # Public for the same reason ``sessions`` is: a team writing one has
381    # to know when it locks. ``0`` means it closes with the event.
382    report_window_s: int = 0
383    # Server-derived: when the report window shuts. ``None`` for an
384    # open-ended competition — there is no instant to count from, and a
385    # fabricated deadline is worse than none.
386    #
387    # ⚠️ Deliberately *not* a ``report_open`` bool. That answer moves with
388    # the clock, so a cached page would assert a state that has since
389    # changed; an instant stays true and the client counts down from it.
390    report_closes_at: datetime | None = None
391    # Whether this competition runs a classic AWD tick loop. Server-derived
392    # from the ``awd_matches`` side table through ``core.awd.is_awd_match``,
393    # never re-derived per client: it decides which board a player is sent
394    # to (``/awd`` vs ``/scoreboard``) and whether a gamebox page exists at
395    # all, so two clients answering it differently is two clients
396    # disagreeing about how the event is played.
397    #
398    # A bool rather than a ``format`` enum on purpose. ``docs/awd-plan.md``
399    # §14.1 refuses a ``GameMode`` taxonomy at N=2 — a format is a point on
400    # six orthogonal axes, not a subclass — and this field claims only the
401    # fact the side table already stores. ``False`` is every competition
402    # that predates AWD, which is the whole existing corpus.
403    is_awd_match: bool = False
404    # Whether every team owes this competition a written engagement
405    # report — ``core.competitions.expects_report``, the same verdict
406    # ``CompetitionMembershipInfo.expects_report`` already carries for
407    # the sidebar.
408    #
409    # ⚠️ **Denormalized rather than composed by the client**, even though
410    # both halves of the disjunction (``silent`` and ``scoring_rule``)
411    # are already on this model. That is exactly what makes a `||` in a
412    # component tempting, and it would be the *third* place the rule is
413    # spelled — after which two clients disagree about whether an event
414    # has a deliverable. Same argument that put ``is_awd_match`` and
415    # ``can_replay`` here rather than in a page.
416    #
417    # ⚠️ **Needed on the *public* model, not just the membership one**,
418    # because the sidebar entry it already feeds is membership-gated —
419    # correctly, since that block is the registered player's nav. So a
420    # reader deciding whether to enter had no surface anywhere saying
421    # this event is graded on a written report, which is the one thing
422    # about the exercise format they most need before they sign up.
423    #
424    # Defaults ``False``: reading a missing field as true hangs a report
425    # card on every Jeopardy competition, which is the claim the
426    # player-side link waited to be able to make.
427    expects_report: bool = False
428    # The competition this one is a *virtual sitting* of, empty for a
429    # real one.
430    #
431    # On the wire because the marker is a rendering decision: the row's
432    # ``title`` is the source's verbatim, deliberately, so a "(virtual)"
433    # suffix baked into the database would be untranslatable English in
434    # every locale's listing. A client reads this and labels it itself.
435    virtual_of: str = ""
436    # Whether this contest can be replayed on your own clock — the fact
437    # a client renders its "Replay" button from, so it does not have to
438    # POST and read a 400 to find out.
439    #
440    # Derived server-side from ``core.virtual.can_replay``, never per
441    # client, for the same reason ``is_awd_match`` is: a client working
442    # the refusal set out itself is a second implementation of
443    # ``plan_virtual_run``, and it would drift once, silently, into a
444    # button that fails the moment it is pressed.
445    #
446    # ⚠️ Deliberately **not** viewer-scoped, unlike ``can_participate``.
447    # "Am I signed in" the client already knows, and "have I already
448    # started one" is answered by the route being idempotent — asking it
449    # here would put a per-user allowlist read inside a projection that
450    # runs once per row of the listing, which is the N+1 shape three
451    # other fields on this model have already had to have removed.
452    # Defaults ``False``: a button that always 400s is worse than none.
453    can_replay: bool = False
454    # Total registered teams. Surfaced on cards so users can gauge
455    # popularity at a glance.
456    registered_count: int = 0
457    # Viewer-scoped fields — see class docstring.
458    is_registered: bool = False
459    # Whether the viewer may register for this comp. Always ``True`` for
460    # ``public`` comps; for private comps it's ``True`` only when the
461    # viewer is invited or can administer it. Lets the frontend render a
462    # "private — invitation required" locked card without a probe POST.
463    # Defaults ``True`` so public / anonymous-on-public responses are open.
464    can_participate: bool = True
465    my_team_id: str = ""
466    my_team_name: str = ""
467    my_role: Literal["captain", "member", ""] = ""
468    my_rank: int = -1
469    my_score: int = -1
470    my_solves: int = -1

Summary row used in the list view.

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

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
silent: bool = False
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
report_window_s: int = 0
report_closes_at: datetime.datetime | None = None
is_awd_match: bool = False
expects_report: 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):
538class CompetitionInviteInfo(CtfyModel):
539    """One participation-allowlist entry, for the admin "Invitations"
540    card on a private competition. The body for POST is the shared
541    ``GrantCompetitionAdminRequest`` (resolve by id or email)."""
542
543    user_id: str
544    display_name: str = ""
545    email: str = ""
546    invited_by: str = ""
547    invited_at: datetime | None = None
548    # True while the invitation is still waiting for its person: the
549    # address was invited before anyone signed up with it, so there is no
550    # account to name yet. ``user_id`` is empty in that state, which is
551    # why the card keys its rows on ``email``.
552    pending_signup: bool = False

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

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    # Whether this competition runs silent (§4b) — the same fact
 63    # ``CompetitionInfo.silent`` carries, denormalized here for the same
 64    # reader and the same reason ``is_awd_match`` is: ``Sidebar`` is
 65    # mounted above the routes, so it cannot see the competition
 66    # ``CompetitionLayout`` already loaded, and it *can* see this through
 67    # ``AuthContext`` with no request of its own.
 68    #
 69    # ⚠️ Without it the per-competition nav offers a Scoreboard entry
 70    # for the whole of a silent engagement, and every standing route
 71    # behind it refuses — a link that cannot work, for the one format
 72    # where "how am I doing" is the question the platform exists to
 73    # decline.
 74    silent: bool = False
 75    # Whether this competition expects an engagement report — the
 76    # ``core.competitions.expects_report`` verdict, denormalized here for
 77    # the same reader and the same reason the two flags above are.
 78    #
 79    # ⚠️ **Not the same question as ``silent``**, and reusing that one is
 80    # the tempting mistake in both directions: a *loud* exercise
 81    # competition grades reports and would get no link, while the
 82    # disjunction is what a nav entry is entitled to claim. It is derived
 83    # server-side rather than ``||``-ed by each client for the reason
 84    # ``is_awd_match`` is: two clients composing it differently is two
 85    # clients disagreeing about whether this event has a deliverable.
 86    expects_report: bool = False
 87    # Rail-enrichment fields (denormalized so the Slack-style rail can
 88    # render instance count / rank / a time-progress ring from one /me
 89    # call, no per-tile fetch fan-out).
 90    #
 91    # Live instances for THIS team in THIS comp. Mirrors
 92    # InstanceCountContext semantics (all non-terminal InstanceState
 93    # rows for the team — terminal ones already left the live store as
 94    # InstanceRecord); 0 when none.
 95    running_instances: int = 0
 96    # Competition window, copied straight off the CompetitionState the
 97    # /me loop already fetches. "" start = live immediately, "" end =
 98    # never ends (same convention as CompetitionState / phase()).
 99    starts_at: datetime | None = None
100    ends_at: datetime | None = None
101    # Caller's current rank in this comp. -1 sentinel = "not computed"
102    # (upcoming phase, or the per-request rank budget was exhausted) —
103    # matches the existing my_rank<0 frontend convention.
104    my_rank: int = -1
105    # Registered-team count, the denominator the UI shows as "#3 / 40".
106    competition_team_count: int = 0

One row on MeResponse.competition_teams.

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

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

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
silent: bool = False
expects_report: 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):
504class CompetitionRegistrationInfo(CtfyModel):
505    """One row in the admin "who's registered" table."""
506
507    team_id: str
508    team_name: str = ""
509    registered_at: datetime | None = None

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

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):
770class CompetitionScoreDistribution(CtfyModel):
771    """Aggregate companion to the (now server-paginated) competition
772    scoreboard: the score-distribution histogram + the true team count.
773
774    The standings table pages server-side, so the histogram / "N teams"
775    figure can't be re-derived from one page in the browser — this
776    bins every registered team's ``flags_solved`` with the same
777    adaptive scheme the frontend used to do client-side.
778    """
779
780    total_teams: int = 0
781    bins: list[ScoreBucket] = Field(default_factory=list)

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

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

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

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

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):
129class CompetitionSessionInfo(CtfyModel):
130    """One playable slice of a competition's window, on the wire."""
131
132    starts_at: datetime
133    ends_at: datetime

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

starts_at: datetime.datetime = PydanticUndefined
ends_at: datetime.datetime = PydanticUndefined
class CompetitionUpdate(ctfy.core.models.CtfyModel):
224class CompetitionUpdate(CtfyModel):
225    """PATCH body — every field optional, ``None`` means "leave alone"."""
226
227    title: str | None = Field(default=None, min_length=1, max_length=200)
228    description: str | None = Field(default=None, max_length=20000)
229    starts_at: datetime | None = None
230    ends_at: datetime | None = None
231    challenge_ids: list[str] | None = Field(default=None)  # no cap — see CompetitionCreate
232    status: Literal["draft", "published", "archived"] | None = None
233    access: Literal["public", "private_listed", "private_hidden"] | None = None
234    eval_standard: bool | None = None
235    corpus_sha: str | None = None
236    # Rejected with 409 once the competition is live — re-submitting the
237    # value it already holds is a no-op, so the edit form can keep
238    # sending every field when the admin saves any of them.
239    scoring_rule: str | None = None
240    scoring_params: dict[str, Any] | None = None
241    # ⚠️ Optional like every field here: ``None`` means untouched.
242    # Flipping silence mid-event is a real organiser action (a range run
243    # loud for a practice round, then silent for the graded one), so it
244    # is deliberately *not* frozen the way ``scoring_rule`` is — silence
245    # changes what the player is told from here on, it does not re-rank a
246    # board they have already seen.
247    silent: bool | None = None
248    #: ``None`` leaves the schedule alone; ``[]`` clears every session and
249    #: returns the competition to a single continuous window.
250    sessions: list[CompetitionSessionInfo] | None = None
251    # ⚠️ Optional like every field here: ``None`` means untouched. Not
252    # frozen the way ``scoring_rule`` is — extending the report period on
253    # the day is ordinary event work, and it re-ranks nothing.
254    report_window_s: int | None = None
255    # --- registration ---------------------------------------------------
256    # The window sign-ups are open, distinct from the play window above:
257    # a real event closes entries well before the first challenge unlocks
258    # so the organiser can vet entrants and print certificates.
259    registration_starts_at: datetime | None = None
260    registration_ends_at: datetime | None = None
261    # Per-competition roster cap. 0 defers to the platform-wide setting,
262    # so two events on one deployment can carry different rules.
263    max_team_size: int | None = Field(default=None, ge=0)
264    # When true a newly registered team lands ``pending`` and cannot play
265    # until an organiser approves it.
266    registration_review: bool | None = None
267    # field name -> "hidden" | "optional" | "required". Keys are validated
268    # against REGISTRATION_FIELDS. Anything absent is treated as hidden,
269    # so an unconfigured competition collects nothing extra.
270    registration_fields: dict[str, RegistrationFieldPolicy] | None = None
271    #: ``None`` leaves it alone, same as every other field here.
272    pii_retention_days: int | None = Field(default=None, ge=0)
273
274    _norm_times = field_validator(
275        "starts_at", "ends_at", "registration_starts_at", "registration_ends_at", mode="before"
276    )(_blank_to_none)
277
278    @field_validator("registration_fields")
279    @classmethod
280    def _known_registration_fields(
281        cls, v: dict[str, RegistrationFieldPolicy] | None
282    ) -> dict[str, RegistrationFieldPolicy] | None:
283        return None if v is None else _check_registration_fields(v)

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

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
silent: bool | None = None
sessions: list[CompetitionSessionInfo] | None = None
report_window_s: int | 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):
133class CreateInviteRequest(CtfyModel):
134    """Body for ``POST /api/v1/nodes/invites``.
135
136    ``ttl_seconds`` controls how long the minted registration token stays
137    valid — it cannot be renewed, only re-created.
138    """
139
140    ttl_seconds: int = Field(default=3600, ge=60, le=7 * 24 * 3600)
141    # Optional vendor binding: a node registered with this invite is stamped
142    # as that vendor's bring-your-own-node (docs/model-eval-vendor-tenancy.md
143    # §6.3). "" = a platform-shared node (the default). Validated to an
144    # existing vendor at mint time.
145    owner_vendor_id: str = ""

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

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

ttl_seconds: int = 3600
owner_vendor_id: str = ''
class CreateInviteResponse(ctfy.core.models.CtfyModel):
148class CreateInviteResponse(CtfyModel):
149    """Plaintext registration token is returned **exactly once** here.
150
151    Subsequent ``GET /nodes/invites`` lookups only expose metadata; the
152    plaintext is never persisted — only ``token_hash`` is stored.
153    """
154
155    id: str
156    registration_token: str
157    expires_at: datetime | None = None

Plaintext registration token is returned exactly once here.

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

id: str = PydanticUndefined
registration_token: str = PydanticUndefined
expires_at: datetime.datetime | None = None
class DeceptionStats(ctfy.core.models.CtfyModel):
699class DeceptionStats(CtfyModel):
700    """Three states, of which only two are counted.
701
702    ``contacted`` and ``complied`` are each a stored count of crossings;
703    ``resisted`` is the difference. There is no third counter because
704    there is no moment at which a refusal happens for something to
705    count — resistance is the absence of compliance after contact, and
706    an absence cannot be recorded at the time it fails to occur.
707    """
708
709    contacted: int = 0
710    complied: int = 0
711    resisted: int = 0
712    #: ``complied / contacted``. Absent rather than zero when nothing has
713    #: been contacted: 0/0 is "no data", and rendering it as a 0% rate
714    #: would assert that no agent was fooled, which is a claim the
715    #: platform has not earned.
716    comply_rate: float | None = None
717    #: ⚠️ Rides on the wire so a reader cannot get the number without it.
718    caveat: str = (
719        "resisted is an upper bound: the probe runs inside a container the "
720        "player can compromise, so a missing compliance record is not "
721        "evidence of restraint."
722    )

Three states, of which only two are counted.

contacted and complied are each a stored count of crossings; resisted is the difference. There is no third counter because there is no moment at which a refusal happens for something to count — resistance is the absence of compliance after contact, and an absence cannot be recorded at the time it fails to occur.

contacted: int = 0
complied: int = 0
resisted: int = 0
comply_rate: float | None = None
caveat: str = 'resisted is an upper bound: the probe runs inside a container the player can compromise, so a missing compliance record is not evidence of restraint.'
class DefenceInfo(ctfy.core.models.CtfyModel):
109class DefenceInfo(CtfyModel):
110    """What a defender may do on this challenge — AWD+ only.
111
112    Present iff the challenge declares a ``patch:`` block. ``None``
113    everywhere else, which is what lets a client decide whether to render
114    a defence surface at all: without this the only way to find out was
115    to POST a submission and read the refusal, so the web UI had no way
116    to know AWD+ existed.
117
118    ``targets`` is deliberately public. It is a whitelist, not a secret —
119    a defender is *given* this source (they can read it in their own
120    box), the refusal message already names a rejected path, and a UI
121    that cannot say which files it will accept sends players guessing.
122    """
123
124    #: Challenge-relative paths a submission may replace. Anything else
125    #: is refused at submit time rather than at judge time.
126    targets: list[str] = Field(default_factory=list)
127    #: Whether ``patch.live`` declares a defender's shell, i.e. whether
128    #: ``ctfy patch shell`` / the terminal button will work here. False
129    #: means uploads are the only channel — a real and supported shape,
130    #: not a degraded one, so the UI says so rather than hiding.
131    live_shell: bool = False

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

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

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

targets: list[str] = PydanticUndefined
live_shell: bool = False
class DefenceSource(ctfy.core.models.CtfyModel):
 79class DefenceSource(CtfyModel):
 80    """The patchable source of an AWD+ challenge, as the defender gets it.
 81
 82    AWD+ hands every team the same vulnerable service **and its source**
 83    (§15.1) — you cannot patch what you cannot read. Until this there
 84    was no way to get it: the only channel was `patch.live`, i.e. ssh
 85    into your own box, which is the *classic AWD* shape and leaves
 86    virtual participation and upsolve impossible, since after the event
 87    there is no box to enter. That is the feature §15.5 calls decisive
 88    for a weekly series.
 89
 90    ⚠️ **Exactly `patch.targets`, never the tree.** A benchmark
 91    directory also holds `solution/exploit.py` and the checker, and for
 92    some challenges the groundtruth — serving the directory would hand a
 93    player the reference exploit and the thing that grades them. The
 94    whitelist is already the format's one real security boundary (see
 95    `routes/patches.py`), so the download reuses it rather than
 96    inventing a second, weaker one.
 97
 98    ``files`` is keyed by challenge-relative path with **base64**
 99    values, which is deliberately the shape `POST /patches` accepts: a
100    defender — or an agent — round-trips download → edit → upload with
101    no transformation, and a binary target does not need a second code
102    path.
103    """
104
105    challenge_id: str
106    files: dict[str, str] = Field(default_factory=dict)

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

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

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

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

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

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

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):
178class DifficultyStat(CtfyModel):
179    difficulty: str = ""  # easy | medium | hard | ...
180    solved: int = 0
181    total: int = 0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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):
524class GrantCompetitionAdminRequest(CtfyModel):
525    """Body for ``PUT /admin/competitions/{id}/admins`` — resolve the
526    target user by id or (case-insensitive) email.
527
528    ``role`` picks the grant tier: ``admin`` (full organiser control of
529    this event) or ``reviewer`` (roster read + eligibility verdicts +
530    export, nothing that removes a team). Defaults to ``admin`` so an
531    older client that omits it keeps the prior behaviour."""
532
533    user_id: str = ""
534    email: str = ""
535    role: CompetitionGrantRole = "admin"

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

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

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):
 72class InstanceInfo(CtfyModel):
 73    id: str
 74    challenge_id: str = ""
 75    team_id: str = ""
 76    # Competition the instance is scoped to. Stamped from the resolved
 77    # per-comp team at start time; empty for legacy rows or unscoped
 78    # admin instances. Frontend keys per-comp instance pages off this
 79    # field so a user registered for two comps doesn't see comp A's
 80    # instances in comp B's tab.
 81    competition_id: str = ""
 82    # Worker node currently hosting this instance. Empty only for
 83    # records created before nodes were tracked (legacy).
 84    node_id: str = ""
 85    # Display names for the two ids above, so an admin table can render
 86    # what it links to rather than a truncated uuid. Resolved in bulk at
 87    # the route from a pre-loaded map; ``""`` means "not resolved", which
 88    # is what a caller that passes no map gets and what the client
 89    # already renders today (the id, truncated).
 90    #
 91    # ⚠️ Empty is never the id. `EntityCell` branches on name-vs-id to
 92    # decide whether it has something human to show, so echoing the id
 93    # here would make "we know the name" indistinguishable from "we
 94    # don't" — and every row would render as though it had been resolved.
 95    team_name: str = ""
 96    node_name: str = ""
 97    name: str = ""
 98    category: ChallengeCategory | None = None
 99    difficulty: str = ""
100    status: str = InstanceStatus.STARTING
101    started_at: float = 0.0
102    ttl: int = 0
103    expires_at: float = 0.0
104    services: list[ServiceEndpoint] = Field(default_factory=list)
105    description: str = ""
106    # Per-instance question view: same shape as ``ChallengeInfo.questions``
107    # but additionally carries the agent-visible state. ``unlocked`` is
108    # ``False`` when the question's ``requires:`` predecessors haven't
109    # all been answered correctly yet; the UI hides the prompt for locked
110    # questions to avoid leaking route hints. ``answered_correctly`` is
111    # ``True`` once this team has captured this question on this instance.
112    questions: list[InstanceQuestionInfo] = Field(default_factory=list)
113    # Mirrored from ``ChallengeInfo.attachments`` so players who jump
114    # straight to the instance page (e.g. via a launch link) see the
115    # download list without re-fetching the catalog row.
116    attachments: list[AttachmentInfo] = Field(default_factory=list)
117    # How the platform exposes this instance to the player. Always
118    # surfaced (defaults to ``"simple"``) so the agent can branch on
119    # `if info.network_topology == "engagement": ...` without dealing
120    # with a missing field.
121    network_topology: Literal["simple", "engagement"] = "simple"
122    # Which VPN an engagement instance runs. Meaningless in simple
123    # mode, where it stays at the default. Surfaced so the UI can name
124    # the right client command and an agent can branch without
125    # downloading the config to find out what it is.
126    vpn_backend: Literal["openvpn", "wireguard"] = "openvpn"
127    # Set only when ``network_topology == "engagement"``. Carries the
128    # tunnel endpoint host:port + the URL the client downloads the
129    # config body from. ``None`` in simple mode.
130    vpn_endpoint: VpnEndpoint | None = None
131    # Landing-page hints declared in the challenge's ``metadata.yaml``
132    # (``entry_urls:`` field, validated by META013). Surfaced verbatim
133    # to the frontend so the VPN / services panel can render a
134    # "start here" list without the player having to read the
135    # description for the canonical first URL.
136    entry_urls: list[str] = Field(default_factory=list)
137    # Which supply this box came from. ``on_demand`` is a player's own
138    # launch and behaves the way every instance always has; ``match`` is
139    # an arena gamebox an organiser provisioned, and it is a genuinely
140    # different object to its owner — it never expires, stopping it is
141    # not theirs to do, and it is the one the round loop rotates flags
142    # into. The client cannot tell them apart from `challenge_id` alone,
143    # because a player may also launch the gamebox on demand.
144    lease: Literal["on_demand", "match"] = "on_demand"

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

id: str = PydanticUndefined
challenge_id: str = ''
team_id: str = ''
competition_id: str = ''
node_id: str = ''
team_name: str = ''
node_name: 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_backend: Literal['openvpn', 'wireguard'] = 'openvpn'
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.
31
32    ⚠️ **A question the calling team can neither see nor has captured is
33    absent from the list entirely**, rather than present with a blanked
34    ``prompt`` — see ``instances._project_instance_questions`` for why
35    the id, the ``requires`` edges, ``choices`` and the row *count* are
36    each disclosure in their own right. So ``unlocked`` is ``False`` on
37    exactly one shape: a question the team has already captured whose
38    predecessor they never formally answered (a report-credited
39    sentinel), where the prompt stays empty and the row stays because it
40    is a fact about their own work.
41    """
42
43    id: str
44    prompt: str
45    mode: str | None = None
46    choices: list[str] | None = None
47    requires: list[str] = Field(default_factory=list)
48    # ``True`` once every id in ``requires`` has been captured by the
49    # calling team on this instance. ``False`` keeps the prompt empty
50    # to avoid leaking the question text early.
51    unlocked: bool = True
52    # ``True`` once the calling team has submitted a correct answer
53    # for this question against this instance. Drives the UI checkmark.
54    answered_correctly: bool = False
55    # Wrong-attempt budget remaining for the calling team on this
56    # specific question. ``None`` when the question's mode is uncapped
57    # (dynamic free-form, or any mode the operator opted out of via
58    # ``question_attempt_caps``). The UI renders an ``X/N attempts``
59    # badge from this on first page load and decrements it locally on
60    # each wrong submit (the submission response also carries it).
61    attempts_remaining: int | None = None
62    # The cap that ``attempts_remaining`` is being measured against —
63    # echoed so the UI can render ``X/N`` without recomputing N from
64    # the question's mode + current platform settings.
65    attempts_cap: int | None = None
66    # ``True`` for a checker question whose ``check.poll`` is on: the
67    # platform auto-verifies it in the background, so the UI shows an
68    # "auto-verify on" affordance. Never exposes the checker command.
69    auto_check: bool = False

One question, projected for a specific running instance.

Mirrors QuestionPublicInfo plus per-instance state.

⚠️ A question the calling team can neither see nor has captured is absent from the list entirely, rather than present with a blanked prompt — see instances._project_instance_questions for why the id, the requires edges, choices and the row count are each disclosure in their own right. So unlocked is False on exactly one shape: a question the team has already captured whose predecessor they never formally answered (a report-credited sentinel), where the prompt stays empty and the row stays because it is a fact about their own work.

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):
197class InstanceRecordArtifacts(CtfyModel):
198    """Per-artifact presence flags + counts for an archived instance.
199
200    Returned alongside the full :class:`InstanceRecord` from
201    ``GET /admin/instance-records/{id}`` so the admin UI can show
202    ``Manifest / Events / Submissions / Container log`` tabs at a glance
203    without a round-trip per tab.
204    """
205
206    has_manifest: bool = False
207    has_container_log: bool = False
208    events_count: int = 0
209    submissions_count: int = 0
210    traffic_count: int = 0
211    # Bytes on disk for the archived ``traffic.pcap``. 0 when no capture
212    # was persisted (sidecar disabled, archive disabled, or fetch
213    # failed). The admin UI uses this both to gate the download button
214    # and to render an "X MB" hint next to it.
215    pcap_bytes: int = 0

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

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

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):
218class InstanceRecordDetail(CtfyModel):
219    record: InstanceRecordInfoDetail
220    artifacts: InstanceRecordArtifacts = Field(default_factory=InstanceRecordArtifacts)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

record: InstanceRecordInfoDetail = PydanticUndefined
artifacts: InstanceRecordArtifacts = PydanticUndefined
class InstanceRecordInfo(ctfy.core.models.CtfyModel):
147class InstanceRecordInfo(CtfyModel):
148    """Archived-instance summary for the admin history list.
149
150    Mirrors :class:`ctfy.core.state.models.InstanceRecord` minus the heavy
151    fields (``spec``, ``surface``). Used by ``GET /admin/instance-records``.
152    """
153
154    id: str
155    team_id: str = ""
156    challenge_id: str = ""
157    node_id: str = ""
158    # Same contract as ``InstanceInfo.team_name`` / ``node_name`` — see
159    # there. Declared again rather than inherited because this is a
160    # sibling class, not a subclass, so a field added to one is genuinely
161    # absent from the other. The archive needs them at least as much as
162    # the live table: it is read after the event, when the team it names
163    # is likeliest to have been deleted.
164    team_name: str = ""
165    node_name: str = ""
166    name: str = ""
167    category: ChallengeCategory | None = None
168    difficulty: str = ""
169    status: str = ""
170    stop_reason: str = ""
171    error: str = ""
172    # Full node diagnostic (e.g. ``docker compose up`` stderr tail).
173    # Admin-only: this model backs ``/admin/instance-records*`` only;
174    # the player-facing ``InstanceInfo`` / ``InstanceStatusResponse``
175    # deliberately omit it.
176    error_detail: str = ""
177    started_at: float = 0.0
178    stopped_at: float = 0.0
179    duration_s: float = 0.0
180    ttl: int = 0
181    solved: bool = False
182    attempts: int = 0
183    # Total HTTP requests captured by the mitmproxy sidecar during the
184    # instance's lifetime. 0 when the archive sink was disabled or no
185    # traffic was captured.
186    request_count: int = 0

Archived-instance summary for the admin history list.

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

id: str = PydanticUndefined
team_id: str = ''
challenge_id: str = ''
node_id: str = ''
team_name: str = ''
node_name: 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):
189class InstanceRecordInfoDetail(InstanceRecordInfo):
190    """Full record — includes spec + surface, returned by detail endpoint."""
191
192    spec: dict[str, Any] = Field(default_factory=dict)
193    surface: AttackSurface | None = None
194    artifact_dir: str = ""

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

spec: dict[str, typing.Any] = PydanticUndefined
surface: ctfy.core.target.AttackSurface | None = None
artifact_dir: str = ''
class InstanceStatusResponse(ctfy.core.models.CtfyModel):
280class InstanceStatusResponse(CtfyModel):
281    id: str
282    status: str = InstanceStatus.STARTING
283    attack_surface: AttackSurface | None = None
284    error: str = ""
285    # CA volume for the per-instance mitmproxy. Operators trust this CA
286    # if they want to MITM HTTPS; HTTP traffic is captured transparently
287    # without it.
288    cert_volume: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

id: str = PydanticUndefined
status: str = <InstanceStatus.STARTING: 'starting'>
attack_surface: ctfy.core.target.AttackSurface | None = None
error: str = ''
cert_volume: str = ''
class InstanceTrafficPage(ctfy.core.models.CtfyModel):
303class InstanceTrafficPage(CtfyModel):
304    """One poll's worth of captured flows, plus where to resume.
305
306    ``cursor`` is opaque: it is this platform's private encoding of how
307    far each capture file has been read, and nothing outside
308    ``ctfy.server.traffic_tail`` may parse it. A client stores whatever
309    it was handed and sends it back.
310
311    ⚠️ **An empty cursor means "start over", so a client must not invent
312    one.** Sending `""` re-reads the whole capture — which is exactly
313    the cost the tail read exists to remove, arriving through the client
314    rather than through the server.
315    """
316
317    flows: FlowRecords = Field(default_factory=list)
318    cursor: str = ""
319    # A bound was hit and more is already on disk. A client catching up
320    # should poll again immediately rather than waiting out its
321    # interval — the alternative is a defender watching an attack
322    # through a window that fills at one page per interval.
323    truncated: bool = False
324    #: ``""`` / ``"on"`` / ``"off"`` — whether a proxy sidecar exists for
325    #: this instance at all.
326    #:
327    #: ⚠️ **Tri-state, and ``""`` is the default, because the other two
328    #: values are claims.** An empty page means "nothing yet" when a
329    #: sidecar is running and "nothing, ever" when none is, and those
330    #: render as the same empty table — which reads as the feature being
331    #: broken. But a node that never answered has told us neither, and a
332    #: boolean has nowhere to put that. Same rule as
333    #: ``ScoreboardEntry.review_status`` defaulting to ``""`` rather
334    #: than to ``approved``.
335    capture: str = ""

One poll's worth of captured flows, plus where to resume.

cursor is opaque: it is this platform's private encoding of how far each capture file has been read, and nothing outside ctfy.server.traffic_tail may parse it. A client stores whatever it was handed and sends it back.

⚠️ An empty cursor means "start over", so a client must not invent one. Sending "" re-reads the whole capture — which is exactly the cost the tail read exists to remove, arriving through the client rather than through the server.

flows: list[dict[str, typing.Any]] = PydanticUndefined
cursor: str = ''
truncated: bool = False
capture: 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):
580class LlmBudgetPage(CtfyModel):
581    """The organiser's LLM-spend roster.
582
583    ``gateway_configured`` is on the envelope for the reason
584    ``webhook_configured`` is on the email-suppression list: an empty
585    table otherwise reads as "nobody has spent anything" when it means
586    "this deployment enforces no budgets at all", and those call for
587    opposite actions.
588    """
589
590    items: list[LlmBudgetRow]
591    total: int
592    gateway_configured: bool

The organiser's LLM-spend roster.

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

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):
537class LlmBudgetRow(CtfyModel):
538    """One team's LLM spend on one challenge.
539
540    ⚠️ ``budget_tokens`` is the cap **as configured right now**, not the
541    cap that applied when the spend happened: it is one deployment-wide
542    setting the operator can retune mid-event. So a row can read
543    ``spent 90k of 200k`` for a team that really was refused at 50k, and
544    the honest place to see what happened *then* is the
545    ``llm_quota_exhausted`` activity row, which carries the cap it
546    crossed. Reporting a per-row historical cap would mean storing one,
547    which is a column that exists only to be believed.
548
549    ``exhausted`` is derived from the pair rather than stored, for the
550    same reason: it is a statement about the two numbers on this row and
551    must not disagree with them.
552    """
553
554    team_id: str
555    team_name: str = ""
556    competition_id: str
557    challenge_id: str
558    spent_tokens: int
559    budget_tokens: int
560    #: Whether this team still holds a live grant for this challenge.
561    #: A budget outlives the box that spent it — the key is
562    #: ``(team, competition, challenge)`` precisely so a fresh instance
563    #: does not refill — so a row with no live grant is the normal
564    #: shape after a box stops, not an orphan.
565    live: bool = False
566
567    @computed_field
568    @property
569    def exhausted(self) -> bool:
570        """⚠️ A **`computed_field`**, unlike ``TeamState.is_disqualified``
571        which is deliberately a plain ``@property``. Opposite reasons,
572        and the distinction is the model's kind: that one is *state*, so
573        ``model_dump`` feeds ``to_orm`` and every dumped key has to be a
574        column; this is a *wire* model whose whole purpose is to reach a
575        client, and a plain property simply never leaves the process.
576        """
577        return self.budget_tokens > 0 and self.spent_tokens >= self.budget_tokens

One team's LLM spend on one challenge.

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

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

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
567    @computed_field
568    @property
569    def exhausted(self) -> bool:
570        """⚠️ A **`computed_field`**, unlike ``TeamState.is_disqualified``
571        which is deliberately a plain ``@property``. Opposite reasons,
572        and the distinction is the model's kind: that one is *state*, so
573        ``model_dump`` feeds ``to_orm`` and every dumped key has to be a
574        column; this is a *wire* model whose whole purpose is to reach a
575        client, and a plain property simply never leaves the process.
576        """
577        return self.budget_tokens > 0 and self.spent_tokens >= self.budget_tokens

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

class LlmProviderCreate(ctfy.core.models.CtfyModel):
507class LlmProviderCreate(CtfyModel):
508    """A provider as an operator types it.
509
510    ⚠️ Validated **here**, not only at the gateway. ``POST /keys/sync``
511    reports a ``rejected`` list whose whole meaning is "the platform and
512    the gateway have drifted, somebody look" — which is only true if a
513    row the gateway would refuse could not have been stored. Without
514    this, a refusal there would be the ordinary result of a typo and
515    nobody would read it.
516    """
517
518    key_id: str = Field(min_length=1, max_length=64)
519    api_key: str = Field(min_length=1, max_length=512)
520    flavor: str
521    base_url: str = ""
522    model: str = ""
523
524    @field_validator("flavor")
525    @classmethod
526    def _known_flavor(cls, value: str) -> str:
527        # Imported here rather than at module scope: this is a wire model
528        # and `keys.py` sits in the gateway package, so a top-level
529        # import would drag the gateway into every model importer.
530        from ctfy.server.llm_gateway.keys import FLAVORS
531
532        if value not in FLAVORS:
533            raise ValueError(f"unknown LLM flavor {value!r}; expected one of {', '.join(FLAVORS)}")
534        return value

A provider as an operator types it.

⚠️ Validated here, not only at the gateway. POST /keys/sync reports a rejected list whose whole meaning is "the platform and the gateway have drifted, somebody look" — which is only true if a row the gateway would refuse could not have been stored. Without this, a refusal there would be the ordinary result of a typo and nobody would read it.

key_id: str = PydanticUndefined
api_key: str = PydanticUndefined
flavor: str = PydanticUndefined
base_url: str = ''
model: str = ''
class LlmProviderInfo(ctfy.core.models.CtfyModel):
413class LlmProviderInfo(CtfyModel):
414    """One upstream credential as an operator may see it.
415
416    ⚠️ **No ``api_key`` field, and that absence is the model.** The
417    column is sealed precisely so the secret cannot be read back, and a
418    wire model carrying it would undo that for every caller at once. A
419    last-four ``hint`` is included because an operator rotating one of
420    several keys has to tell them apart, and four characters of a
421    100-bit secret identify without reconstructing — the alternative is
422    a table of opaque ids where picking the wrong row revokes the wrong
423    vendor.
424    """
425
426    key_id: str
427    flavor: str
428    base_url: str = ""
429    model: str = ""
430    #: Last four characters of the secret, or empty for one too short to
431    #: hint at without giving away a meaningful share of it.
432    hint: str = ""
433    added_by: str = ""
434    added_at: str = ""
435    #: Whether the platform's last key push carried this row to the
436    #: gateway: ``synced`` / ``pending`` / ``rejected``, or ``""`` when
437    #: there is no gateway to push to.
438    #:
439    #: ⚠️ **A record of the last push, never a live reading**, and the
440    #: page's copy has to say so. The gateway persists nothing, so it can
441    #: restart and lose the whole fleet a second after acknowledging it;
442    #: the platform hears on the next tick. Deriving it any other way
443    #: would mean asking the gateway from a browser — a second source for
444    #: a fact only the pushing party knows the outcome of.
445    #:
446    #: ⚠️ Defaults to ``""``, the *not-a-new-claim* direction. Defaulting
447    #: to ``synced`` would make a producer that forgets assert every
448    #: credential is live; same rule as ``ScoreboardEntry.review_status``
449    #: and ``SubmissionResponse.ranked``.
450    sync_state: str = ""
451    #: Whether this secret is also an ``CTFY_EVAL_*`` key — one upstream
452    #: account serving both the players' gateway and the eval harness,
453    #: which spends **directly**. The gateway's ledger then calibrates
454    #: against capacity somebody else is consuming: it under-counts, the
455    #: limiter over-admits, and the first observable is a 429 storm or a
456    #: bill.
457    #:
458    #: ⚠️ Derived on the read, never stored — a column would need
459    #: clearing when either side is rotated, and a missed write reads
460    #: exactly like "these are different accounts". Defaults ``False``
461    #: for the usual reason: the default must not be a new claim.
462    shares_eval_account: bool = False

One upstream credential as an operator may see it.

⚠️ No api_key field, and that absence is the model. The column is sealed precisely so the secret cannot be read back, and a wire model carrying it would undo that for every caller at once. A last-four hint is included because an operator rotating one of several keys has to tell them apart, and four characters of a 100-bit secret identify without reconstructing — the alternative is a table of opaque ids where picking the wrong row revokes the wrong vendor.

key_id: str = PydanticUndefined
flavor: str = PydanticUndefined
base_url: str = ''
model: str = ''
hint: str = ''
added_by: str = ''
added_at: str = ''
sync_state: str = ''
shares_eval_account: bool = False
class LlmProviderPage(ctfy.core.models.CtfyModel):
465class LlmProviderPage(CtfyModel):
466    items: list[LlmProviderInfo] = Field(default_factory=list)
467    total: int = 0
468    #: Whether this deployment has a gateway at all. ⚠️ An empty table
469    #: is ambiguous and must say which emptiness it is: "no providers
470    #: configured" and "this platform pushes to nothing" render
471    #: identically and call for opposite actions. Same argument as
472    #: ``webhook_configured`` on the email-suppression list.
473    gateway_configured: bool = False

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

items: list[LlmProviderInfo] = PydanticUndefined
total: int = 0
gateway_configured: bool = False
class LlmProviderProbe(ctfy.core.models.CtfyModel):
476class LlmProviderProbe(CtfyModel):
477    """What one probe of one credential established.
478
479    ⚠️ **A code, never a boolean.** Four refusals mean four different
480    fields to go and fix — the key, the address, the model, the account —
481    and one red cross reports them identically. The page renders the code
482    and shows ``detail`` beside it, which is the only thing separating
483    the two ambiguous causes of ``bad_request``.
484    """
485
486    key_id: str = ""
487    ok: bool = False
488    #: ``ok`` · ``auth_failed`` · ``quota_exceeded`` · ``unreachable`` ·
489    #: ``bad_request`` — the provider's verdict; plus ``no_gateway`` ·
490    #: ``gateway_unreachable`` · ``not_synced``, which are facts about
491    #: *this platform's* wiring rather than about the credential.
492    code: str = ""
493    #: The status the provider actually sent, kept verbatim: the code is
494    #: a summary and a bug report wants the original. 0 when nothing
495    #: answered.
496    status: int = 0
497    #: The provider's own message, bounded. Never our own paraphrase —
498    #: it is what names the wrong model or the wrong host.
499    detail: str = ""
500    #: ⚠️ The offline stub answers **ok**, so a green tick there would be
501    #: the dangerous result: an operator carries it into an event whose
502    #: every answer is invented. Defaults ``False`` for the usual reason
503    #: — the default must not be a new claim.
504    stub: bool = False

What one probe of one credential established.

⚠️ A code, never a boolean. Four refusals mean four different fields to go and fix — the key, the address, the model, the account — and one red cross reports them identically. The page renders the code and shows detail beside it, which is the only thing separating the two ambiguous causes of bad_request.

key_id: str = ''
ok: bool = False
code: str = ''
status: int = 0
detail: str = ''
stub: bool = False
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)
68    # True when this request asked to be served as an ordinary player
69    # (``X-Ctfy-View-As: player``). Transient — it describes the
70    # *request*, never the stored account, so ``role`` above already
71    # reads ``user`` while the DB row still says admin. The client
72    # needs it to render the "you are previewing" banner and the way
73    # back out; without it a narrowed session is indistinguishable
74    # from an ordinary one and nobody can leave.
75    viewing_as_player: bool = False

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

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

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

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

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
viewing_as_player: bool = False
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):
601class MilestoneProgress(CtfyModel):
602    """Per-challenge milestone progress for the calling user.
603
604    Returned by ``GET /me/milestone-progress``. One row per challenge
605    with at least one captured question, regardless of whether the
606    challenge is fully solved. The Challenges list page renders a
607    progress bar from ``len(solved_question_ids) / total_questions``
608    so a player can see "2/5 milestones" instead of an all-or-nothing
609    solved badge.
610
611    Like ``MySolveSummary`` this is user-scoped: ``solved_question_ids``
612    aggregates every question id the user captured for the challenge
613    across every team they have ever been on. The optional
614    ``competition_id`` query param narrows the aggregation to solves
615    stamped against the user's team in that comp.
616    """
617
618    challenge_id: str
619    solved_question_ids: list[str] = Field(default_factory=list)
620    total_questions: int = 0

Per-challenge milestone progress for the calling user.

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

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

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 MyReportRow(ctfy.core.models.CtfyModel):
 93class MyReportRow(CtfyModel):
 94    """One range this team has written up, as the team's own index reads it.
 95
 96    ⚠️ **Not a narrowed :class:`ReportRosterRow`.** That one answers "who
 97    has started" for an organiser and carries a team; this answers "which
 98    of my ranges have I written up" and every row is the caller's own, so
 99    a team column there would be the same value repeated down the page.
100    """
101
102    challenge_id: str = ""
103    version: int = 0
104    length: int = 0
105    saved_at: datetime | None = None

One range this team has written up, as the team's own index reads it.

⚠️ Not a narrowed ReportRosterRow. That one answers "who has started" for an organiser and carries a team; this answers "which of my ranges have I written up" and every row is the caller's own, so a team column there would be the same value repeated down the page.

challenge_id: str = ''
version: int = 0
length: int = 0
saved_at: datetime.datetime | None = None
class MyReportsResponse(ctfy.core.models.CtfyModel):
108class MyReportsResponse(CtfyModel):
109    """This team's report index for one competition.
110
111    ⚠️ **Only ranges with a saved report appear.** The ranges the team
112    has *not* written up are the competition's challenge list minus
113    these, which the client already holds — and listing them here would
114    mean this route deciding what "the competition's ranges" are, a
115    second answer to a question ``effective_challenge_ids`` already owns.
116    """
117
118    competition_id: str = ""
119    rows: list[MyReportRow] = Field(default_factory=list)

This team's report index for one competition.

⚠️ Only ranges with a saved report appear. The ranges the team has not written up are the competition's challenge list minus these, which the client already holds — and listing them here would mean this route deciding what "the competition's ranges" are, a second answer to a question effective_challenge_ids already owns.

competition_id: str = ''
rows: list[MyReportRow] = PydanticUndefined
class MySolveSummary(ctfy.core.models.CtfyModel):
573class MySolveSummary(CtfyModel):
574    """Per-challenge solve summary for the calling team.
575
576    Returned by ``GET /me/solves``. One row per challenge with at least
577    one captured flag. ``best_rank`` is the team's best (smallest) rank
578    across the flags they captured for the challenge — drives the
579    1血/2血/3血 badge on the challenge cards. Multi-flag challenges may
580    have different ranks per flag; reporting the best one gives players
581    credit for whichever piece they nailed first.
582    """
583
584    challenge_id: str
585    best_rank: int
586    solved_at: datetime | None = None
587    # Whether **every** capture the team holds on this challenge is on
588    # the competition's board.
589    #
590    # ⚠️ ``all``, not ``any``: the card renders this as "some of what you
591    # captured here does not count", and a partial split has to say so.
592    # ``any`` would stay quiet about two uncounted flags out of three,
593    # which is the same silence as before the field existed.
594    #
595    # Unscoped (``competition_id=""``) this is always ``True``: the
596    # cross-competition history applies no window, so no capture there
597    # is "not counted" — see ``/me/solves``.
598    ranked: bool = True

Per-challenge solve summary for the calling team.

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

challenge_id: str = PydanticUndefined
best_rank: int = PydanticUndefined
solved_at: datetime.datetime | None = None
ranked: bool = True
class NodeHeartbeat(ctfy.core.models.CtfyModel):
 76class NodeHeartbeat(CtfyModel):
 77    """Body for ``POST /nodes/heartbeat``.
 78
 79    Node reports its live counts + resource utilisation on every beat.
 80    The percentages are 0–100; 0 is a safe default for the first beat
 81    where psutil hasn't had a prior sample to diff against.
 82    """
 83
 84    id: str
 85    running: int = 0
 86    capacity: int = 50
 87    cpu_percent: float = 0.0
 88    memory_percent: float = 0.0
 89    # Physical RAM in MiB (not a percentage). Memory-aware admission
 90    # needs the absolute size; 0 = not reported, which older node builds
 91    # send and which admission reads as "unknown, fall back to slots".
 92    memory_total_mb: float = 0.0
 93    disk_percent: float = 0.0
 94    # Unix seconds when the node sampled the metrics. The server stores
 95    # ``received_at - sampled_at`` as one-way latency on the resulting
 96    # health sample. Default 0 means "not measured" — older node builds
 97    # that haven't been upgraded keep working.
 98    sampled_at: float = 0.0
 99    # The ctfy version the node itself is running. Empty on a node that
100    # predates the field, which reads as "unknown" and is never a skew —
101    # the same rule ``memory_total_mb``'s 0 already follows, so an
102    # un-upgraded fleet stays quiet instead of alarming on every beat.
103    version: str = ""
104    # Base64 X25519 public key for this node's half of an AWD match
105    # tunnel. Bounded here rather than validated: the *shape* is checked
106    # once, at plan time, where a refusal can name the node and the match
107    # it broke (`core/awd_fabric.py`); a second validator on the hottest
108    # node→platform path would be a second answer to one question. The
109    # length cap is not that validator — it stops an unbounded string
110    # reaching the column at all.
111    wireguard_public_key: str = Field(default="", max_length=64)

Body for POST /nodes/heartbeat.

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

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
version: str = ''
wireguard_public_key: str = ''
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 = ""
52    # The ctfy version this node last reported. "" = a node build that
53    # predates the field. Surfaced so the admin Nodes page can show a
54    # skew against the platform's own version — the node is the one
55    # boundary shipping structured data between two processes, and a
56    # mismatch there loses fields silently rather than erroring.
57    version: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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 = ''
version: str = ''
class NodeInviteInfo(ctfy.core.models.CtfyModel):
160class NodeInviteInfo(CtfyModel):
161    """Admin-facing summary (``GET /nodes/invites``). No token fields."""
162
163    id: str
164    created_at: datetime | None = None
165    expires_at: datetime | None = None
166    status: str  # "active" | "consumed" | "expired"
167    consumed_by_node_id: str = ""
168    owner_vendor_id: str = ""  # vendor binding, if any ("" = shared)

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

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):
60class NodePatch(CtfyModel):
61    """Body for ``PATCH /admin/nodes/{node_id}``.
62
63    Fields are optional — absent fields keep their current value.
64    display_name is the common case; labels allow adding/replacing the
65    full dict (partial label edits need a second round-trip).
66    ``owner_vendor_id`` re-assigns (or clears, with ``""``) the node's
67    vendor binding — an admin correction path complementing the
68    invite-time binding.
69    """
70
71    display_name: str | None = None
72    labels: dict[str, str] | None = None
73    owner_vendor_id: str | None = None

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

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

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):
114class NodeRegisterResponse(NodeInfo):
115    """Registration response carries the node's bearer token (plaintext).
116
117    Returned exactly once, at registration. The same token is used in
118    both directions:
119
120    * **node→platform** (heartbeat, deregister) — node presents it as
121      the Bearer; platform looks the row up by comparing plaintexts.
122    * **platform→node** (start/stop/status) — platform presents the
123      same plaintext on every call; node verifies against its in-memory
124      copy.
125
126    Re-registration rotates the token. The node never persists it on
127    disk; "restart = re-register = fresh credential".
128    """
129
130    token: str = ""

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

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

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

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

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):
806class PatchSubmissionCreate(CtfyModel):
807    """An AWD+ defence submission, addressed one of two ways.
808
809    ``instance_id`` is the handle when a box is running, exactly as on
810    :class:`SubmissionCreate`: the instance row carries the challenge,
811    the team and the competition, so the caller repeats none of them.
812
813    ⚠️ **But a defence submission does not need a box, and requiring one
814    made the format's natural order unplayable.** Judging builds from
815    the pristine tree plus the submitted bytes and never touches the
816    instance, ``GET /challenges/{id}/defence/source`` is already
817    challenge-scoped for exactly this reason, and upsolve, virtual
818    participation and a post-event fix all mean patching with nothing
819    running. So ``challenge_id`` (plus ``competition_id``, which scopes
820    which team is credited) addresses it directly.
821
822    Naming neither is refused rather than guessed at: falling back to
823    "the caller's only instance" would silently judge a challenge they
824    did not name. Naming an instance *and* a different challenge is
825    refused for the same reason.
826
827    **This is not a weaker authorisation than the instance form.** There
828    is no team on the wire either way — the credited team is resolved
829    from the caller's own membership, as ``GET /patches`` already does —
830    so the challenge form has no id to spoof at all.
831    """
832
833    instance_id: str = ""
834    #: The challenge to defend, when no box is running. Ignored (but
835    #: cross-checked) when ``instance_id`` is given.
836    challenge_id: str = ""
837    #: Which competition credits the patch. Empty means the caller's
838    #: unscoped team, the same convention ``GET /patches`` follows.
839    competition_id: str = ""
840    #: Where the patched bytes come from.
841    #:
842    #: ``upload`` carries them in ``files``. ``live`` reads them out of
843    #: the player's own running box — they SSH in, edit, and submit with
844    #: no payload at all, which is how an AWD player actually works.
845    #:
846    #: Explicit rather than inferred from an empty ``files``: an empty
847    #: map is also what a client bug produces, and silently switching
848    #: channel on a bug would grade something the player never meant to
849    #: send.
850    source: Literal["upload", "live"] = "upload"
851    #: ``{challenge-relative path: base64(bytes)}``. Base64 because a
852    #: patch target may legitimately be binary, and JSON has no way to
853    #: carry those bytes intact otherwise. Must be empty when
854    #: ``source="live"``.
855    files: dict[str, str] = Field(default_factory=dict)

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

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

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

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

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

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):
858class PatchSubmissionInfo(CtfyModel):
859    """A defence submission's status — deliberately without its code.
860
861    The stored row keeps the submitted bytes so a verdict can be
862    re-derived after a checker fix, but a *status* view is polled, and
863    shipping every patch body on every poll is bulk nobody asked for.
864    """
865
866    id: str
867    challenge_id: str
868    competition_id: str
869    submitted_at: datetime | None = None
870    #: The paths this submission replaced.
871    files: list[str] = Field(default_factory=list)
872    status: str = "pending"
873    #: Empty until judged, and empty forever on a submission the
874    #: platform failed to judge — our outage is not the player's loss.
875    verdict: str = ""
876    detail: str = ""
877    judged_at: datetime | None = None

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

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

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):
432class PlayerShellCreate(CtfyModel):
433    """Ask for a shell into your own AWD+ box.
434
435    Note what is *absent*: no container. The admin shell takes one
436    because a super-admin legitimately picks among an instance's
437    containers; accepting one here would hand the caller the choice the
438    endpoint exists to deny them. The platform resolves the target from
439    the challenge's own ``patch.live.service``.
440    """
441
442    #: ``bash`` or ``sh``. A tiny allow-list rather than free text —
443    #: the value becomes the exec'd command on the node.
444    shell: str = "bash"

Ask for a shell into your own AWD+ box.

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

shell: str = 'bash'
class PlayerShellTicket(ctfy.core.models.CtfyModel):
498class PlayerShellTicket(CtfyModel):
499    """A single-use ticket for one shell session.
500
501    Two-step because a browser cannot attach an ``Authorization`` header
502    to a WebSocket upgrade. Worth keeping for terminal clients too: it
503    means the long-lived bearer never rides in a URL.
504    """
505
506    session_id: str
507    #: Burns on first WebSocket use and is never returned again.
508    session_token: str
509    #: Platform-relative; the client makes it absolute against its own
510    #: base URL, since only the client knows the scheme it reached us on.
511    ws_url: str
512    #: The resolved compose container. Empty for the browser, which
513    #: never needs it; the bastion does, because it opens the node
514    #: WebSocket itself rather than going through the platform page.
515    container: str = ""
516    #: Seconds the unburned ticket stays valid.
517    expires_in: int
518    #: The compose service the session will land in. Echoed back so a
519    #: player knows which container they are in without reading the
520    #: challenge's metadata — the platform resolved it, so it says so.
521    service: str

A single-use ticket for one shell session.

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

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

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

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

Aggregate per-team analytics surfaced on the public profile.

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

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):
547class QaChallengeView(CtfyModel):
548    """One QA challenge as seen by a team on the quiz surface.
549
550    Carries the full question / choices / description data alongside
551    per-team solve and attempt state, so the frontend can render the
552    quiz list with progress badges in a single round-trip.
553    """
554
555    id: str
556    name: str
557    # Provenance bucket prefix — ``SECQA`` / ``MMLU-CS`` / ``CTI-MCQ``
558    # / etc — derived from the challenge id (everything before the
559    # trailing numeric suffix). Lets the frontend group / filter
560    # without splitting the id again client-side.
561    bucket: str
562    description: str
563    difficulty: str = ""
564    tags: list[str] = Field(default_factory=list)
565    questions: list[QuestionPublicInfo] = Field(default_factory=list)
566    # Question ids the calling team has already captured.
567    solved_question_ids: list[str] = Field(default_factory=list)
568    # Question ids the team has submitted at least one wrong answer
569    # against but never solved. Drives the "Try again" amber badge.
570    attempted_wrong_question_ids: list[str] = Field(default_factory=list)

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

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

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):
530class QaSubmissionCreate(CtfyModel):
531    """Request body for ``POST /qa/submissions`` — the instance-free
532    submission path for pure question-answer challenges.
533
534    No ``instance_id``: QA challenges have no Docker infra to launch,
535    so there's no per-team instance row to read context off. The
536    caller passes ``challenge_id`` + ``competition_id`` explicitly,
537    and the server resolves the submitter's team for the named
538    competition via the standard auth helper.
539    """
540
541    challenge_id: str
542    competition_id: str
543    question_id: str = "answer"
544    answer: str | list[str]

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

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

challenge_id: str = PydanticUndefined
competition_id: str = PydanticUndefined
question_id: str = 'answer'
answer: str | list[str] = PydanticUndefined
class QuestionAttemptResetInfo(ctfy.core.models.CtfyModel):
595class QuestionAttemptResetInfo(CtfyModel):
596    """Response from the admin reset endpoint. Echoes the new baseline
597    row so the UI can update its local state without re-fetching the
598    instance.
599    """
600
601    team_id: str
602    challenge_id: str
603    question_id: str
604    reset_at: datetime
605    reset_by_user_id: str
606    reason: str

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

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. Both public
40    # surfaces gate on this list: the *instance* projection
41    # (``instances._to_info``) redacts a locked prompt, and the *catalog*
42    # projection (``projections.question_infos``) omits the entry
43    # entirely until it unlocks.
44    # ⚠️ This comment used to claim that gating while the catalog
45    # published every gated prompt, id, ``requires`` edge and the total
46    # count — a false statement about disclosure, written on the model
47    # that was doing the disclosing. Recorded rather than quietly edited
48    # because it read as a guarantee and was believed.
49    requires: list[str] = Field(default_factory=list)

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

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

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):
365class RenewResponse(CtfyModel):
366    status: str = "renewed"
367    id: str
368    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 ReportAttachmentInfo(ctfy.core.models.CtfyModel):
122class ReportAttachmentInfo(CtfyModel):
123    """One evidence file in a team's report appendix.
124
125    ⚠️ **No bytes and no URL.** The bytes are fetched from
126    ``GET .../report/attachments/{id}`` — proxied through the platform
127    like a team logo, so the bucket needs no public-read policy and an
128    unpublished competition's evidence is not enumerable by anyone who
129    guesses a key. Embedding the object key here would publish exactly
130    that guess.
131    """
132
133    id: str = ""
134    #: The name the uploader gave, sanitised for display. ⚠️ Display
135    #: only — the object key is derived from ``id``, so this string
136    #: addresses nothing.
137    filename: str = ""
138    #: **Sniffed from the bytes.** A real image type means a client may
139    #: render a thumbnail; ``application/octet-stream`` means the file is
140    #: a download and nothing more.
141    content_type: str = ""
142    size_bytes: int = 0
143    #: Who uploaded it — the team owns the evidence, the user is the
144    #: audit, the same split ``ReportInfo.user_id`` makes.
145    user_id: str = ""
146    uploaded_at: datetime | None = None

One evidence file in a team's report appendix.

⚠️ No bytes and no URL. The bytes are fetched from GET .../report/attachments/{id} — proxied through the platform like a team logo, so the bucket needs no public-read policy and an unpublished competition's evidence is not enumerable by anyone who guesses a key. Embedding the object key here would publish exactly that guess.

id: str = ''
filename: str = ''
content_type: str = ''
size_bytes: int = 0
user_id: str = ''
uploaded_at: datetime.datetime | None = None
class ReportAttachmentsResponse(ctfy.core.models.CtfyModel):
149class ReportAttachmentsResponse(CtfyModel):
150    """One engagement's appendix, oldest first.
151
152    Oldest first because a reference in the prose counts from the order
153    the team built the appendix in, and a newest-first list silently
154    renumbers it on every upload.
155    """
156
157    competition_id: str = ""
158    team_id: str = ""
159    challenge_id: str = ""
160    attachments: list[ReportAttachmentInfo] = Field(default_factory=list)
161    #: Whether this deployment can accept an upload at all.
162    #:
163    #: ⚠️ **An empty appendix has to say which emptiness it is.** With no
164    #: object storage configured the list route still answers (reading
165    #: back what you submitted is not a submission), so an empty array
166    #: reads as "this team uploaded nothing" when it means "nobody can" —
167    #: and the player only discovers the difference by trying. Same
168    #: argument as ``webhook_configured`` on the email-suppression list
169    #: and ``gateway_configured`` on the LLM budget roster.
170    #:
171    #: Defaults ``False`` because that is the answer that is *not* a new
172    #: claim: an older server sending nothing must not make a client
173    #: promise an upload the deployment cannot perform.
174    uploads_enabled: bool = False
175    #: How many more this engagement may take.
176    #:
177    #: Served rather than derived from ``len(attachments)`` against a
178    #: constant the client restates: a ceiling spelled in two places is
179    #: one a client eventually disagrees with, and the direction it
180    #: disagrees in is a disabled upload button on an engagement that had
181    #: room. Zero means the appendix is full — a real state, distinct
182    #: from an appendix that is merely empty.
183    remaining: int = 0

One engagement's appendix, oldest first.

Oldest first because a reference in the prose counts from the order the team built the appendix in, and a newest-first list silently renumbers it on every upload.

competition_id: str = ''
team_id: str = ''
challenge_id: str = ''
attachments: list[ReportAttachmentInfo] = PydanticUndefined
uploads_enabled: bool = False
remaining: int = 0
class ReportInfo(ctfy.core.models.CtfyModel):
31class ReportInfo(CtfyModel):
32    """One saved version, as its own team reads it back.
33
34    ⚠️ ``content`` is attacker-authored text — every byte is written by
35    whoever is attacking the range. It is carried and stored as plain
36    markdown, never rendered as HTML by anything that receives it, and
37    when the grading half lands it reaches a model only through the
38    bounded matching prompt described in the plan's §5.4.
39    """
40
41    competition_id: str = ""
42    team_id: str = ""
43    #: The range this report is about. ⚠️ Empty on a **pre-split** row —
44    #: one written before the report was per challenge, when it covered
45    #: the whole competition. Those are read back unchanged; nothing
46    #: writes an empty one any more.
47    challenge_id: str = ""
48    #: Who pressed save on *this* version. The team is scored; the user
49    #: is the audit.
50    user_id: str = ""
51    #: 1-based, monotonic within (team, competition, challenge). The
52    #: current report is the highest; nothing renumbers.
53    version: int = 0
54    content: str = ""
55    saved_at: datetime | None = None

One saved version, as its own team reads it back.

⚠️ content is attacker-authored text — every byte is written by whoever is attacking the range. It is carried and stored as plain markdown, never rendered as HTML by anything that receives it, and when the grading half lands it reaches a model only through the bounded matching prompt described in the plan's §5.4.

competition_id: str = ''
team_id: str = ''
challenge_id: str = ''
user_id: str = ''
version: int = 0
content: str = ''
saved_at: datetime.datetime | None = None
class ReportRosterResponse(ctfy.core.models.CtfyModel):
211class ReportRosterResponse(CtfyModel):
212    competition_id: str = ""
213    rows: list[ReportRosterRow] = Field(default_factory=list)
214    #: Teams registered for this competition that have saved nothing **at
215    #: all**, in any range. Served rather than derived: a client would
216    #: have to fetch the full team list to subtract, and two clients
217    #: doing that arithmetic differently is two organisers disagreeing
218    #: about who is missing.
219    #:
220    #: ⚠️ Deliberately *not* "unwritten ``(team, range)`` pairs". That
221    #: number is ``teams × ranges`` for an event nobody has started and
222    #: grows with a bucket competition's whole catalog, so it answers a
223    #: question no organiser asked while looking like the one they did.
224    missing_team_count: int = 0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

competition_id: str = ''
rows: list[ReportRosterRow] = PydanticUndefined
missing_team_count: int = 0
class ReportRosterRow(ctfy.core.models.CtfyModel):
186class ReportRosterRow(CtfyModel):
187    """One team's report *status* on the organiser's roster.
188
189    ⚠️ **No body.** An event accumulates reports all week and a table of
190    "who has written one" must not read every one of them out of SQLite
191    to render a row count — the same argument that made
192    ``list_patch_submissions_admin`` name its columns rather than select
193    the entity. The body is one route further in, one team at a time.
194    """
195
196    team_id: str = ""
197    team_name: str = ""
198    #: The range. ⚠️ Carried rather than named: a ``challenge_id`` is
199    #: already human-readable (``GAUNTLET-FIN-001``), unlike a team's
200    #: uuid, so a second lookup would buy nothing — the same reading
201    #: ``EntityCell``'s own note records.
202    challenge_id: str = ""
203    version: int = 0
204    #: Length of the current version, in characters. What an organiser
205    #: scans the roster for: who has written nothing, who has written a
206    #: paragraph, who has written a report.
207    length: int = 0
208    saved_at: datetime | None = None

One team's report status on the organiser's roster.

⚠️ No body. An event accumulates reports all week and a table of "who has written one" must not read every one of them out of SQLite to render a row count — the same argument that made list_patch_submissions_admin name its columns rather than select the entity. The body is one route further in, one team at a time.

team_id: str = ''
team_name: str = ''
challenge_id: str = ''
version: int = 0
length: int = 0
saved_at: datetime.datetime | None = None
class ReportSaveRequest(ctfy.core.models.CtfyModel):
18class ReportSaveRequest(CtfyModel):
19    """A new version of the caller's team report.
20
21    ⚠️ Deliberately **not** a patch/append shape. A report is one
22    document the team owns; sending the whole thing each time is what
23    makes "the current report" unambiguous, and what lets the server
24    keep every version without having to replay a chain of edits to
25    reconstruct any of them.
26    """
27
28    content: str

A new version of the caller's team report.

⚠️ Deliberately not a patch/append shape. A report is one document the team owns; sending the whole thing each time is what makes "the current report" unambiguous, and what lets the server keep every version without having to replay a chain of edits to reconstruct any of them.

content: str = PydanticUndefined
class ReportSaveResponse(ctfy.core.models.CtfyModel):
58class ReportSaveResponse(CtfyModel):
59    """What one save did — the version it wrote, and what it credited.
60
61    ⚠️ **Its own model rather than a field on :class:`ReportInfo`.**
62    "How many findings did this save credit" is a fact about *this
63    write*; on a read, or on a row in the version history, the same field
64    would have to default to zero, which reads as "nothing here was worth
65    anything" about versions that may have been worth a great deal.
66    """
67
68    report: ReportInfo
69    #: Sentinels this save credited that the team did not already hold.
70    #:
71    #: This is the whole feedback channel the exercise format allows: a
72    #: player learns the last edit was worth something, and never learns
73    #: how many sentinels exist or how many remain
74    #: (``docs/gauntlet-exercise-plan.md`` §4).
75    newly_credited: int = 0

What one save did — the version it wrote, and what it credited.

⚠️ Its own model rather than a field on ReportInfo. "How many findings did this save credit" is a fact about this write; on a read, or on a row in the version history, the same field would have to default to zero, which reads as "nothing here was worth anything" about versions that may have been worth a great deal.

report: ReportInfo = PydanticUndefined
newly_credited: int = 0
class ReportVersionsResponse(ctfy.core.models.CtfyModel):
78class ReportVersionsResponse(CtfyModel):
79    """Every version a team has saved, newest first.
80
81    ⚠️ Carries the bodies. That is right for one team's own history and
82    for an organiser reading one team, and wrong for a competition-wide
83    roster — which is why :class:`ReportRosterRow` exists rather than
84    this being reused there.
85    """
86
87    competition_id: str = ""
88    team_id: str = ""
89    challenge_id: str = ""
90    versions: list[ReportInfo] = Field(default_factory=list)

Every version a team has saved, newest first.

⚠️ Carries the bodies. That is right for one team's own history and for an organiser reading one team, and wrong for a competition-wide roster — which is why ReportRosterRow exists rather than this being reused there.

competition_id: str = ''
team_id: str = ''
challenge_id: str = ''
versions: list[ReportInfo] = 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):
673class ScheduledJobInfo(CtfyModel):
674    """One recurring job on ``GET /admin/scheduled-jobs``: a projection of
675    ``ScheduledJobState`` plus the code-side ``default_interval_s`` so the
676    UI can show "(default: 30s)" next to a tuned value."""
677
678    name: str = ""
679    enabled: bool = True
680    interval_s: int = 0
681    default_interval_s: int = 0
682    last_run_at_ts: float = 0.0
683    last_status: str = ""
684    last_summary: str = ""
685    last_error: str = ""
686    last_duration_s: float = 0.0
687    run_count: int = 0
688    updated_by_user_id: str = ""

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

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):
691class ScheduledJobPatch(CtfyModel):
692    """Body of ``PATCH /admin/scheduled-jobs/{name}``. Either field may be
693    omitted; ``interval_s`` is clamped to ``[5, 86400]`` by the route."""
694
695    enabled: bool | None = None
696    interval_s: int | None = None

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

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):
763class ScoreBucket(CtfyModel):
764    """One bucket of the scoreboard score-distribution histogram."""
765
766    label: str
767    count: int = 0

One bucket of the scoreboard score-distribution histogram.

label: str = PydanticUndefined
count: int = 0
class ScoreHistoryPoint(ctfy.core.models.CtfyModel):
709class ScoreHistoryPoint(CtfyModel):
710    """One sample on a team's score/rank-over-time curve.
711
712    Both metrics travel together so the frontend can toggle
713    Score ⇄ Rank without a refetch.
714    """
715
716    ts: float = 0.0  # Unix seconds (solve time of the driving event)
717    score: int = 0  # Cumulative flags captured up to ``ts``
718    rank: int = 0  # 1-based rank among scoring teams at ``ts``

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

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

ts: float = 0.0
score: int = 0
rank: int = 0
class ScoreHistorySeries(ctfy.core.models.CtfyModel):
721class ScoreHistorySeries(CtfyModel):
722    team_id: str
723    team_name: str
724    points: list[ScoreHistoryPoint] = Field(default_factory=list)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
points: list[ScoreHistoryPoint] = PydanticUndefined
class ScoreboardEntry(ctfy.core.models.CtfyModel):
628class ScoreboardEntry(CtfyModel):
629    rank: int = 0
630    team_id: str
631    team_name: str
632    # Challenges where the team captured every declared flag. Preserved
633    # for parity with the legacy single-flag scoreboard.
634    solved: int = 0
635    # Total individual flags captured across all challenges. Primary
636    # scoreboard metric now that multi-flag challenges exist — rewards
637    # partial progress (e.g. foothold without root).
638    flags_solved: int = 0
639    # The number this board is ranked by, as decided by the
640    # competition's scoring rule (``ctfy/server/scoring/``). Under the
641    # default ``flag_count`` rule it equals ``flags_solved``; under a
642    # points rule it is the point total. Kept separate from
643    # ``flags_solved`` so the UI can show both "how much progress" and
644    # "how many points" without knowing which rule produced the row.
645    score: int = 0
646    attempts: int = 0
647    last_solve_at: datetime | None = None
648    # Most recent activity of any kind. Distinct from ``last_solve_at`` —
649    # a team may be actively attempting without a solve, and the UI
650    # wants to show that they're not idle.
651    last_active_at: datetime | None = None
652    # OAuth provider avatar URL for the team's earliest identity that
653    # supplies one. Empty for password-only teams; the frontend falls
654    # back to a generated initials avatar in that case.
655    avatar_url: str = ""
656    # The AWD+ split of ``score``. Zero on every other format, where the
657    # board renders one number and these columns stay hidden.
658    #
659    # Both are carried even though ``attack + defence == score``, because
660    # the point of the split *is* the breakdown: a single total never
661    # explains why a team that solved less is ranked higher. Deriving one
662    # from the other in the client would put the format's arithmetic in
663    # two places.
664    attack_score: int = 0
665    defence_score: int = 0
666    # Challenges this team holds a ``fixed`` defence verdict on. A count,
667    # not the ids — the board is a ranking, and the ids are one click
668    # away on the team's own page.
669    defended: int = 0
670    # The exercise-format split of ``score``. Zero on every other format,
671    # for the same reason and with the same shape as the AWD+ pair above.
672    #
673    # ⚠️ Deliberately **not** a reuse of ``attack_score`` /
674    # ``defence_score``. The numbers happen to compose the same way and
675    # they are different facts: reusing the AWD names would put "how well
676    # this team wrote it up" in a field every reader has been told means
677    # "patches that survived the exploit". A wire field is a claim about
678    # what the number *is*.
679    #
680    # ``reporting_score`` is the graded half weighted onto the capability
681    # scale, so it is what the board ranked by — the grader's own
682    # unweighted total stays on ``ExerciseReportGradeState.points``,
683    # which is the record an appeal argues from.
684    capability_score: int = 0
685    reporting_score: int = 0
686    # The team's eligibility verdict, and **only when the viewer is
687    # entitled to it** — a competition admin or reviewer. Everywhere else
688    # this is ``""``.
689    #
690    # ``""`` means "not disclosed", never "approved". Defaulting it to
691    # ``approved`` would have been the natural-looking choice and is the
692    # bug: a public board would then assert that every team passed
693    # review, which is a stronger claim than the platform is entitled to
694    # make and is false during the review backlog.
695    #
696    # Not public because no other surface publishes it either — the
697    # verdict reaches the team's own members and the organisers, and
698    # nobody else. Putting "rejected" next to a team name on a page the
699    # whole event reads is a different act from recording it on the
700    # review queue.
701    #
702    # It never affects ``rank``: review is an eligibility marker, not a
703    # gate, and an unapproved team plays and places exactly as it would
704    # have. ``TestReviewIsNotAGate`` pins that; this field is what makes
705    # the fact *visible* to the organiser without changing it.
706    review_status: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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
capability_score: int = 0
reporting_score: 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):
467class SshCredential(CtfyModel):
468    """Everything a defender needs to `ssh` into their own box, once.
469
470    Model (b): the platform mints the keypair rather than a player
471    registering one, so both halves are here. The private key is
472    returned exactly once and never stored — there is nothing to leak
473    later and nothing to revoke, because it dies with the certificate's
474    five-minute window.
475
476    ⚠️ **The username is not cosmetic.** The bastion reads the subject
477    back off the login name, and asyncssh refuses a certificate whose
478    signed principal list omits it — so a client that connects under any
479    other name is rejected at the handshake with nothing in the response
480    explaining why.
481    """
482
483    #: OpenSSH private key. Returned once; the platform keeps no copy.
484    private_key: str
485    #: The certificate over it, signed by the platform's SSH CA.
486    certificate: str
487    #: ``user_id:instance_id`` — what the certificate's principal says.
488    username: str
489    #: Where to present it. Only the operator knows how the bastion is
490    #: published, so the platform reports rather than derives it.
491    host: str
492    port: int
493    #: Seconds the certificate stays valid. It only has to cover
494    #: *connecting*; an established session outlives it.
495    expires_in: int

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

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

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

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):
447class SshExchangeRequest(CtfyModel):
448    """The subject a bastion has already authenticated by certificate.
449
450    ⚠️ **Not the certificate.** That was the first shape and it cannot
451    be built: asyncssh discards the client certificate once it has
452    validated it, so the bastion has nothing to forward. What survives
453    the handshake is the login name, which the CA signs — a certificate
454    whose principal list omits the requested username is refused — so
455    the bastion knows the subject but cannot re-prove it with the
456    credential. The bearer on this request is what makes the claim
457    trustworthy; see ``routes/ssh_exchange.py``.
458    """
459
460    #: Who the certificate named. Authorisation still runs against them.
461    user_id: str
462    #: Which box. Re-resolved here — it can stop or change hands between
463    #: the certificate mint and the connect.
464    instance_id: str

The subject a bastion has already authenticated by certificate.

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

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):
338class StartRequest(CtfyModel):
339    challenge_id: str
340    proxy_output_dir: str | None = None
341    # Per-instance TTL override in seconds. ``None`` (the default)
342    # defers to the platform's ``default_instance_ttl_s`` setting —
343    # admin-tunable, currently 24h. Explicit values are clamped to
344    # ``max_instance_ttl_s`` so a misbehaving SDK / agent can't pin
345    # a node by requesting an arbitrarily large TTL.
346    ttl: int | None = None
347    # Which competition this instance is for. The platform looks up
348    # the caller's per-comp team via ``(user_id, competition_id)`` and
349    # stamps the instance with that team. Optional only when the
350    # caller is on exactly one team — multi-comp users always pass
351    # it explicitly.
352    competition_id: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

challenge_id: str = PydanticUndefined
proxy_output_dir: str | None = None
ttl: int | None = None
competition_id: str = ''
class StartResponse(ctfy.core.models.CtfyModel):
355class StartResponse(CtfyModel):
356    id: str
357    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):
360class StopResponse(CtfyModel):
361    status: str = InstanceStatus.STOPPED
362    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):
474class SubmissionCreate(CtfyModel):
475    # ``instance_id`` is the canonical handle since 0.2: the server
476    # reads challenge_id and competition_id off the instance row, so
477    # callers don't need to repeat them and N instances per
478    # (team, challenge) are unambiguous on submit.
479    instance_id: str
480    # Which question on the challenge the agent is answering. Defaults
481    # to ``"flag"`` so single-question challenges stay one-liner submits;
482    # multi-question challenges (see ``ChallengeInfo.questions``) must
483    # specify the id explicitly.
484    question_id: str = "flag"
485    # The agent's submission. String for ``dynamic`` / ``static`` /
486    # ``single_select`` questions; list of strings for ``multi_select``
487    # (e.g. ``["idor", "ssrf"]``). The server picks the grader by
488    # consulting ``spec.questions[i].mode`` on the live instance.
489    answer: str | list[str]

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

instance_id: str = PydanticUndefined
question_id: str = 'flag'
answer: str | list[str] = PydanticUndefined
class SubmissionResponse(ctfy.core.models.CtfyModel):
492class SubmissionResponse(CtfyModel):
493    id: str
494    correct: bool
495    solve_time_s: float = 0  # seconds from instance start to correct submission
496    # 1 = first blood, 2 = second, 3 = third, 4+ = regular solve.
497    # 0 when incorrect or when the team already had a prior correct solve.
498    # Ranks within the specific question — independent of the
499    # full-challenge solve rank.
500    solve_rank: int = 0
501    # The question id this submission targeted. Echoed back to the
502    # caller so simple submission UIs don't have to thread the id
503    # separately. Equal to the request's ``question_id``.
504    question_id: str | None = None
505    # True iff this team has now captured every question declared on
506    # the challenge. Single-question challenges: always equals ``correct``.
507    challenge_fully_solved: bool = False
508    # Remaining wrong-attempt budget for the question this submission
509    # targeted, AFTER this attempt is counted (the cap is on wrongs;
510    # correct submissions don't consume budget). ``None`` when the
511    # question's mode is uncapped (dynamic free-form, or any mode the
512    # operator opted out of via ``question_attempt_caps``). The UI
513    # uses this to decrement its remaining-attempts badge without
514    # re-fetching the instance — see ``ctfy.server.submission_policy``.
515    attempts_remaining: int | None = None
516    # Whether the team's capture of this question is on the
517    # competition's board. Only meaningful when ``correct``.
518    #
519    # ⚠️ **Defaults to False on purpose**, which is the loud direction.
520    # A competition accepts submissions outside its play window and
521    # simply does not count them (only a competition declaring
522    # ``sessions`` refuses), so before this field the player got
523    # ``correct: true`` and scored nothing, with nothing anywhere
524    # saying why. A producer that forgets to set this makes every solve
525    # claim it did not count — visible in a minute; the other default
526    # restores exactly the silence this exists to end.
527    ranked: bool = False

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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):
165class TagStat(CtfyModel):
166    """One axis on the per-tag strength radar.
167
168    ``solved`` is the team's solve count for challenges carrying this
169    tag; ``total`` is the platform-wide challenge count for the tag,
170    so the frontend can render "team / total" or normalize to a 0–1
171    ratio for the radar polygon."""
172
173    tag: str = ""
174    solved: int = 0
175    total: int = 0

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):
184class TrendPoint(CtfyModel):
185    """One UTC-day bucket on the solve-trend line."""
186
187    date: str = ""  # YYYY-MM-DD, UTC
188    solves: int = 0

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

date: str = ''
solves: int = 0
class UpdateUserRoleRequest(ctfy.core.models.CtfyModel):
 98class UpdateUserRoleRequest(CtfyModel):
 99    """Body for ``PATCH /admin/users/{user_id}/role``.
100
101    Only ``"admin"`` and ``"user"`` are accepted — granting
102    ``"super_admin"`` is reserved for the env allowlist
103    (``CTFY_SUPER_ADMIN_EMAILS``) so the trust anchor stays out-of-band.
104    """
105
106    role: Literal["admin", "user"]

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

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

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):
784class UserScoreboardEntry(CtfyModel):
785    """One row on the user-ranked global scoreboard.
786
787    Aggregates a single user's solves across every team they've
788    ever been on (each ``SolveState`` carries both ``user_id`` and
789    ``team_id``, so per-user totals are a straight filter over the
790    global solve table). Excludes users with zero solves to keep
791    the public list compact.
792    """
793
794    rank: int = 0
795    user_id: str
796    display_name: str
797    avatar_url: str = ""
798    country: str = ""
799    # Total flags solved across all teams the user has ever been on.
800    flags_solved: int = 0
801    # Challenges fully solved (every declared flag captured).
802    solved: int = 0
803    last_solve_at: datetime | None = None

One row on the user-ranked global scoreboard.

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

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):
204class UserSolveTrend(CtfyModel):
205    """The calling user's daily solve count for the last ``days`` UTC days,
206    aggregated across every team they have ever been on (matched by
207    ``user_id`` on each :class:`SolveState`).
208
209    Powers the Dashboard's "personal solve trend" chart, which is
210    user-scoped — independent of which competition the user is currently
211    registered for. Multi-flag challenges count once per flag day, the
212    same way :class:`ProfileStats.solve_trend` does it.
213    """
214
215    solve_trend: list[TrendPoint] = Field(default_factory=list)

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

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

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):
371class VerifyAnswerRequest(CtfyModel):
372    """Oracle-mode answer check — same grader as /submissions but writes
373    no submission record. Carries the same shape as :class:`SubmissionCreate`
374    minus the audit trail.
375    """
376
377    # Which question on the challenge the agent is checking. Defaults
378    # to ``"flag"`` for single-question challenges.
379    question_id: str = "flag"
380    # The answer to verify (str for dynamic / static / single_select;
381    # list[str] for multi_select).
382    answer: str | list[str]

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

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

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

correct: bool = PydanticUndefined
question_id: str | None = None
class VirtualGhostBoard(ctfy.core.models.CtfyModel):
609class VirtualGhostBoard(CtfyModel):
610    """The source contest's standings at the instant this replay has
611    reached, with the replayer merged in and ranked among them.
612
613    ⚠️ Nothing here is stored. The board is
614    `compute_scoreboard_filtered` with an `until_iso`, which the
615    platform has always supported — the replay's own progress and the
616    source's progression are two reads of facts that already exist.
617    """
618
619    source_competition_id: str
620    #: How far into the replay the reader is. The client renders a clock
621    #: from this rather than recomputing it from `starts_at`, so the two
622    #: cannot disagree about which minute the ghosts are showing.
623    elapsed_seconds: int = 0
624    #: The instant in the *source* being shown. Bounded by its end.
625    cutoff: datetime | None = None
626    items: list[VirtualGhostRow] = Field(default_factory=list)

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

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

source_competition_id: str = PydanticUndefined
elapsed_seconds: int = 0
cutoff: datetime.datetime | None = None
items: list[VirtualGhostRow] = PydanticUndefined
class VirtualGhostRow(ctfy.server.models.ScoreboardEntry):
598class VirtualGhostRow(ScoreboardEntry):
599    """A standings row on a replay's ghost board.
600
601    `is_me` is the one thing a client cannot derive: the replayer's row
602    comes from a *different competition* than the ghosts', so there is no
603    team id it could compare against.
604    """
605
606    is_me: bool = False

A standings row on a replay's ghost board.

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

is_me: bool = False