ctfy.server.models

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

  1"""Public wire-model package — re-exports for back-compat."""
  2
  3from __future__ import annotations
  4
  5from ctfy.server.models.achievement import (
  6    AchievementCatalogEntry,
  7    AchievementProgress,
  8    AchievementSummary,
  9    EasterEggClaim,
 10    MyAchievementsResponse,
 11    RecentUnlock,
 12    StarGazerVerifyResponse,
 13    TeamAchievement,
 14)
 15from ctfy.server.models.activity import (
 16    Activity,
 17    ActivityTimeseries,
 18    ActivityTimeseriesBucket,
 19)
 20from ctfy.server.models.admin import (
 21    AdminChallengeLastError,
 22    AdminChallengeLatencyBucket,
 23    AdminChallengeStatsRow,
 24    AdminChallengeTimeseries,
 25    AdminChallengeTimeseriesBucket,
 26    AdminHealthFlags,
 27    AdminOverview,
 28    AdminOverviewCounts,
 29    AdminRecentError,
 30    AdminSilentChallenge,
 31    AdminSolveCell,
 32    AdminSolveMatrix,
 33    AdminSolveMatrixChallenge,
 34    AdminSolveMatrixTeam,
 35    AdminStuckInstance,
 36    AdminTaskInfo,
 37    AdminTaskListResponse,
 38    AdminTaskLogLine,
 39    AdminTaskLogsResponse,
 40    AdminTaskSubmitRequest,
 41    AdminTimeseries,
 42    AdminTimeseriesBucket,
 43    AdminTrafficInstanceRow,
 44    AdminTrafficSummary,
 45    AdminTrafficTeamRow,
 46    AdminUnhealthyNode,
 47    PlatformSettingInfo,
 48    PlatformSettingPatch,
 49    QuestionAttemptResetInfo,
 50    QuestionAttemptResetRequest,
 51    ScheduledJobInfo,
 52    ScheduledJobPatch,
 53    ShellRecordingInfo,
 54)
 55from ctfy.server.models.announcement import (
 56    AnnouncementCreate,
 57    AnnouncementInfo,
 58    AnnouncementUpdate,
 59)
 60from ctfy.server.models.auth import (
 61    AuthTokenResponse,
 62    CreateFineGrainedTokenRequest,
 63    CreateFineGrainedTokenResponse,
 64    DeviceApproveRequest,
 65    DeviceCodeResponse,
 66    DeviceInfoResponse,
 67    DeviceTokenRequest,
 68    DeviceTokenResponse,
 69    LinkedIdentity,
 70    LinkStartResponse,
 71    LoginRequest,
 72    OAuthProviderInfo,
 73    PasswordAuthInfo,
 74    ProvidersResponse,
 75    RegisterRequest,
 76    ScopeCatalogEntry,
 77    SetPasswordRequest,
 78    TokenInfo,
 79    TokenScopesResponse,
 80)
 81from ctfy.server.models.challenge import (
 82    AttachmentList,
 83    ChallengeBuildKickoffNodeResult,
 84    ChallengeBuildKickoffResponse,
 85    ChallengeBuildNodeState,
 86    ChallengeBuildStateResponse,
 87    ChallengeBuildStateRow,
 88    ChallengeFacetCount,
 89    ChallengeFacets,
 90    ChallengeFlagStats,
 91    ChallengeInfo,
 92    ChallengePullKickoffNodeResult,
 93    ChallengePullKickoffResponse,
 94    ChallengePullNodeState,
 95    ChallengePullStateResponse,
 96    ChallengePullStateRow,
 97    ChallengeRescanNodeResult,
 98    ChallengeRescanResult,
 99    ChallengeSolveAttempt,
100    ChallengeSolveAttemptsResponse,
101    ChallengeStats,
102    ChallengeTeamSolveSummary,
103    CompetitionChallengeBreakdown,
104    CompetitionChallengeRow,
105    CompetitionScoreDistribution,
106    CompetitionScoreHistory,
107    MilestoneProgress,
108    MySolveSummary,
109    QaChallengeView,
110    QaSubmissionCreate,
111    QuestionPublicInfo,
112    ScoreboardEntry,
113    ScoreBucket,
114    ScoreHistoryPoint,
115    ScoreHistorySeries,
116    SubmissionCreate,
117    SubmissionResponse,
118    UserScoreboardEntry,
119)
120from ctfy.server.models.competition import (
121    CompetitionAdminInfo,
122    CompetitionCreate,
123    CompetitionDetail,
124    CompetitionInfo,
125    CompetitionInviteInfo,
126    CompetitionMembershipInfo,
127    CompetitionRegistrationInfo,
128    CompetitionUpdate,
129    GrantCompetitionAdminRequest,
130)
131from ctfy.server.models.feedback import (
132    AdminFeedbackRow,
133    FeedbackStats,
134    MyReactionsResponse,
135)
136from ctfy.server.models.instance import (
137    InstanceInfo,
138    InstanceQuestionInfo,
139    InstanceRecordArtifacts,
140    InstanceRecordDetail,
141    InstanceRecordInfo,
142    InstanceRecordInfoDetail,
143    InstanceStatusResponse,
144    RenewResponse,
145    StartRequest,
146    StartResponse,
147    StopResponse,
148    VerifyAnswerRequest,
149    VerifyAnswerResponse,
150)
151from ctfy.server.models.meta import (
152    ErrorResponse,
153    HealthResponse,
154    MetaChallenges,
155    MetaPlatform,
156    MetaResponse,
157)
158from ctfy.server.models.node import (
159    ClusterInfo,
160    CreateInviteRequest,
161    CreateInviteResponse,
162    NodeHeartbeat,
163    NodeInfo,
164    NodeInviteInfo,
165    NodePatch,
166    NodeRegister,
167    NodeRegisterResponse,
168)
169from ctfy.server.models.pagination import BigLimitOffsetPage
170from ctfy.server.models.team import (
171    InboxAnnouncement,
172    InboxCaptainRequest,
173    InboxCompetitionInvite,
174    InboxIncomingInvite,
175    InboxOutgoingRequest,
176    InboxResponse,
177    TeamDetail,
178    TeamInfo,
179    TeamInviteInfo,
180    TeamMemberInfo,
181)
182from ctfy.server.models.user import (
183    PROFILE_VISIBILITY_KEYS,
184    AdminUserInfo,
185    CalendarBucket,
186    DeleteMeRequest,
187    DifficultyStat,
188    MeResponse,
189    ProfilePatchRequest,
190    ProfileStats,
191    TagStat,
192    TrendPoint,
193    UpdateUserRoleRequest,
194    UserInfo,
195    UserSolveTrend,
196)
197
198__all__ = [
199    "PROFILE_VISIBILITY_KEYS",
200    "AchievementCatalogEntry",
201    "AchievementProgress",
202    "AchievementSummary",
203    "Activity",
204    "ActivityTimeseries",
205    "ActivityTimeseriesBucket",
206    "AdminChallengeLastError",
207    "AdminChallengeLatencyBucket",
208    "AdminChallengeStatsRow",
209    "AdminChallengeTimeseries",
210    "AdminChallengeTimeseriesBucket",
211    "AdminFeedbackRow",
212    "AdminHealthFlags",
213    "AdminOverview",
214    "AdminOverviewCounts",
215    "AdminRecentError",
216    "AdminSilentChallenge",
217    "AdminSolveCell",
218    "AdminSolveMatrix",
219    "AdminSolveMatrixChallenge",
220    "AdminSolveMatrixTeam",
221    "AdminStuckInstance",
222    "AdminTaskInfo",
223    "AdminTaskListResponse",
224    "AdminTaskLogLine",
225    "AdminTaskLogsResponse",
226    "AdminTaskSubmitRequest",
227    "AdminTimeseries",
228    "AdminTimeseriesBucket",
229    "AdminTrafficInstanceRow",
230    "AdminTrafficSummary",
231    "AdminTrafficTeamRow",
232    "AdminUnhealthyNode",
233    "AdminUserInfo",
234    "AnnouncementCreate",
235    "AnnouncementInfo",
236    "AnnouncementUpdate",
237    "AttachmentList",
238    "AuthTokenResponse",
239    "BigLimitOffsetPage",
240    "CalendarBucket",
241    "ChallengeBuildKickoffNodeResult",
242    "ChallengeBuildKickoffResponse",
243    "ChallengeBuildNodeState",
244    "ChallengeBuildStateResponse",
245    "ChallengeBuildStateRow",
246    "ChallengeFacetCount",
247    "ChallengeFacets",
248    "ChallengeFlagStats",
249    "ChallengeInfo",
250    "ChallengePullKickoffNodeResult",
251    "ChallengePullKickoffResponse",
252    "ChallengePullNodeState",
253    "ChallengePullStateResponse",
254    "ChallengePullStateRow",
255    "ChallengeRescanNodeResult",
256    "ChallengeRescanResult",
257    "ChallengeSolveAttempt",
258    "ChallengeSolveAttemptsResponse",
259    "ChallengeStats",
260    "ChallengeTeamSolveSummary",
261    "ClusterInfo",
262    "CompetitionAdminInfo",
263    "CompetitionChallengeBreakdown",
264    "CompetitionChallengeRow",
265    "CompetitionCreate",
266    "CompetitionDetail",
267    "CompetitionInfo",
268    "CompetitionInviteInfo",
269    "CompetitionMembershipInfo",
270    "CompetitionRegistrationInfo",
271    "CompetitionScoreDistribution",
272    "CompetitionScoreHistory",
273    "CompetitionUpdate",
274    "CreateFineGrainedTokenRequest",
275    "CreateFineGrainedTokenResponse",
276    "CreateInviteRequest",
277    "CreateInviteResponse",
278    "DeleteMeRequest",
279    "DeviceApproveRequest",
280    "DeviceCodeResponse",
281    "DeviceInfoResponse",
282    "DeviceTokenRequest",
283    "DeviceTokenResponse",
284    "DifficultyStat",
285    "EasterEggClaim",
286    "ErrorResponse",
287    "FeedbackStats",
288    "GrantCompetitionAdminRequest",
289    "HealthResponse",
290    "InboxAnnouncement",
291    "InboxCaptainRequest",
292    "InboxCompetitionInvite",
293    "InboxIncomingInvite",
294    "InboxOutgoingRequest",
295    "InboxResponse",
296    "InstanceInfo",
297    "InstanceQuestionInfo",
298    "InstanceRecordArtifacts",
299    "InstanceRecordDetail",
300    "InstanceRecordInfo",
301    "InstanceRecordInfoDetail",
302    "InstanceStatusResponse",
303    "LinkStartResponse",
304    "LinkedIdentity",
305    "LoginRequest",
306    "MeResponse",
307    "MetaChallenges",
308    "MetaPlatform",
309    "MetaResponse",
310    "MilestoneProgress",
311    "MyAchievementsResponse",
312    "MyReactionsResponse",
313    "MySolveSummary",
314    "NodeHeartbeat",
315    "NodeInfo",
316    "NodeInviteInfo",
317    "NodePatch",
318    "NodeRegister",
319    "NodeRegisterResponse",
320    "OAuthProviderInfo",
321    "PasswordAuthInfo",
322    "PlatformSettingInfo",
323    "PlatformSettingPatch",
324    "ProfilePatchRequest",
325    "ProfileStats",
326    "ProvidersResponse",
327    "QaChallengeView",
328    "QaSubmissionCreate",
329    "QuestionAttemptResetInfo",
330    "QuestionAttemptResetRequest",
331    "QuestionPublicInfo",
332    "RecentUnlock",
333    "RegisterRequest",
334    "RenewResponse",
335    "ScheduledJobInfo",
336    "ScheduledJobPatch",
337    "ScopeCatalogEntry",
338    "ScoreBucket",
339    "ScoreHistoryPoint",
340    "ScoreHistorySeries",
341    "ScoreboardEntry",
342    "SetPasswordRequest",
343    "ShellRecordingInfo",
344    "StarGazerVerifyResponse",
345    "StartRequest",
346    "StartResponse",
347    "StopResponse",
348    "SubmissionCreate",
349    "SubmissionResponse",
350    "TagStat",
351    "TeamAchievement",
352    "TeamDetail",
353    "TeamInfo",
354    "TeamInviteInfo",
355    "TeamMemberInfo",
356    "TokenInfo",
357    "TokenScopesResponse",
358    "TrendPoint",
359    "UpdateUserRoleRequest",
360    "UserInfo",
361    "UserScoreboardEntry",
362    "UserSolveTrend",
363    "VerifyAnswerRequest",
364    "VerifyAnswerResponse",
365]
PROFILE_VISIBILITY_KEYS = frozenset({'achievements', 'country', 'solves_count', 'website_url', 'rank_history', 'difficulty_stats', 'activity_calendar', 'last_active_at', 'social_links', 'email', 'solve_trend', 'bio', 'attempts_count', 'tag_stats', '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 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):
239class AdminOverview(CtfyModel):
240    """Single aggregate payload for the admin landing page."""
241
242    nodes: AdminOverviewCounts = Field(default_factory=AdminOverviewCounts)
243    capacity: int = 0
244    running_instances: int = 0
245    teams_total: int = 0
246    solves_total: int = 0
247    solves_today: int = 0
248    # User-engagement counters — the "are people actually on the platform right
249    # now?" signal the infra/challenge/team metrics above don't answer.
250    # ``users_active_24h`` is distinct humans who triggered a write action
251    # (launch / submit / solve / join / answer) in the last 24h, not raw logins
252    # (pure browsing leaves no activity row). ``users_new_24h`` is registrations
253    # in the same window. All default to 0 so the field set stays additive.
254    users_total: int = 0
255    users_active_24h: int = 0
256    users_new_24h: int = 0

Single aggregate payload for the admin landing page.

nodes: AdminOverviewCounts = 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 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):
388class AdminTaskInfo(CtfyModel):
389    """One background task on ``GET /admin/tasks`` / detail. A projection
390    of ``TaskState`` onto the wire (these routes are admin-only, so the
391    full ``error_detail`` traceback tail is included)."""
392
393    id: str = ""
394    kind: str = ""
395    status: str = ""
396    params: dict[str, Any] = Field(default_factory=dict)
397    result: dict[str, Any] = Field(default_factory=dict)
398    progress: float = 0.0
399    progress_message: str = ""
400    error: str = ""
401    error_detail: str = ""
402    cancel_requested: bool = False
403    created_by_user_id: str = ""
404    created_by_name: str = ""
405    created_at_ts: float = 0.0
406    started_at_ts: float = 0.0
407    finished_at_ts: float = 0.0

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

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):
410class AdminTaskListResponse(CtfyModel):
411    """Paginated task list."""
412
413    items: list[AdminTaskInfo] = Field(default_factory=list)
414    total: int = 0
415    offset: int = 0
416    limit: int = 50

Paginated task list.

items: list[AdminTaskInfo] = PydanticUndefined
total: int = 0
offset: int = 0
limit: int = 50
class AdminTaskLogLine(ctfy.core.models.CtfyModel):
419class AdminTaskLogLine(CtfyModel):
420    """One verbose log line for a task (admin-only)."""
421
422    seq: int = 0
423    ts: float = 0.0
424    level: str = "info"
425    message: str = ""

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

seq: int = 0
ts: float = 0.0
level: str = 'info'
message: str = ''
class AdminTaskLogsResponse(ctfy.core.models.CtfyModel):
428class AdminTaskLogsResponse(CtfyModel):
429    """A cursor page of task logs. ``next_after`` is the ``seq`` to pass as
430    ``?after=`` on the next poll (the last item's seq, or the request's
431    ``after`` when the page is empty — so an empty poll never rewinds the
432    cursor)."""
433
434    items: list[AdminTaskLogLine] = Field(default_factory=list)
435    next_after: int = 0

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

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

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 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):
274class AdminTrafficInstanceRow(CtfyModel):
275    """One instance × team × challenge row for the admin traffic dashboard."""
276
277    instance_id: str
278    team_id: str
279    team_name: str = ""
280    challenge_id: str = ""
281    name: str = ""
282    status: str = ""
283    request_count: int = 0
284    started_at: float = 0.0
285    stopped_at: float = 0.0
286    # ``True`` if the instance is still running (request_count is approximate
287    # — refreshed only when the dashboard re-fetches).
288    live: bool = False

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

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):
291class AdminTrafficSummary(CtfyModel):
292    """Top-level response for ``GET /admin/traffic/summary``."""
293
294    teams: list[AdminTrafficTeamRow] = Field(default_factory=list)
295    instances: list[AdminTrafficInstanceRow] = Field(default_factory=list)
296    total_requests: int = 0

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

teams: list[AdminTrafficTeamRow] = PydanticUndefined
instances: list[AdminTrafficInstanceRow] = PydanticUndefined
total_requests: int = 0
class AdminTrafficTeamRow(ctfy.core.models.CtfyModel):
259class AdminTrafficTeamRow(CtfyModel):
260    """Per-team aggregate row for the admin traffic dashboard.
261
262    Sums HTTP request counts across every archived instance the team has
263    ever launched, plus the live count for any instances the team is
264    still running.
265    """
266
267    team_id: str
268    team_name: str = ""
269    instance_count: int = 0
270    request_count: int = 0
271    last_activity_at: float = 0.0

Per-team aggregate row for the admin traffic dashboard.

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

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):
72class AdminUserInfo(UserInfo):
73    """Admin-only view of a user. Carries the privilege tier and the
74    most recent role-change audit fields."""
75
76    email: str = ""
77    role: Literal["user", "admin", "super_admin"] = "user"
78    promoted_by: str | None = None
79    promoted_at: datetime | None = None
80    # OAuth providers the user has bound (e.g. ``["github", "google"]``),
81    # so the admin user list can surface which SSO accounts are linked.
82    providers: list[str] = Field(default_factory=list)

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

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 AttachmentList(ctfy.core.models.CtfyModel):
85class AttachmentList(CtfyModel):
86    """Response payload for ``GET /challenges/{id}/attachments``.
87
88    Same data as ``ChallengeInfo.attachments`` but addressable directly
89    so pre-launch UI / agent tooling can fetch it without paging through
90    the catalog. Order matches the on-disk sort.
91    """
92
93    files: list[AttachmentInfo] = Field(default_factory=list)

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

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

files: list[ctfy.core.target.AttachmentInfo] = PydanticUndefined
class AuthTokenResponse(ctfy.core.models.CtfyModel):
72class AuthTokenResponse(CtfyModel):
73    """Returned by ``POST /auth/register`` and ``POST /auth/login``.
74
75    The plaintext user token is returned directly (not via fragment) since
76    these endpoints are called by first-party XHR, not redirects.
77    """
78
79    token: str
80    redirect_to: str = "/"

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

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

token: str = PydanticUndefined
redirect_to: str = '/'
BigLimitOffsetPage = <class 'ctfy.server.models.pagination.LimitOffsetPageCustomized'>
class CalendarBucket(ctfy.core.models.CtfyModel):
122class CalendarBucket(CtfyModel):
123    """One UTC-day cell on the contribution calendar.
124
125    ``count`` aggregates correct submissions + first-time solves on that
126    day; effectively "did anything productive happen". The series is
127    dense — every day in the requested window is present, zero-filled."""
128
129    date: str = ""  # YYYY-MM-DD, UTC
130    count: int = 0

One UTC-day cell on the contribution calendar.

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

date: str = ''
count: int = 0
class ChallengeBuildKickoffNodeResult(ctfy.core.models.CtfyModel):
215class ChallengeBuildKickoffNodeResult(CtfyModel):
216    """One node's response to a build kickoff (single or all).
217
218    ``queued`` is populated only on the ``build-all`` fan-out; for the
219    single-challenge variant the platform inspects ``status`` to learn
220    what the node accepted.
221    """
222
223    node_id: str
224    ok: bool
225    status: str = ""
226    queued: list[str] = Field(default_factory=list)
227    skipped_built: list[str] = Field(default_factory=list)
228    skipped_in_progress: list[str] = Field(default_factory=list)
229    error: str = ""

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

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

challenge_id: str = ''
nodes: list[ChallengeBuildKickoffNodeResult] = PydanticUndefined
class ChallengeBuildNodeState(ctfy.core.models.CtfyModel):
172class ChallengeBuildNodeState(CtfyModel):
173    """One worker node's view of a single challenge's build state.
174
175    ``status`` is one of ``unbuilt`` / ``building`` / ``built`` /
176    ``failed``. ``node_error`` is non-empty only when the platform
177    could not reach the node at all (heartbeat stale / 5xx) — in that
178    case ``status`` is forced to ``unbuilt`` for the aggregate.
179    """
180
181    node_id: str
182    status: str = "unbuilt"
183    built_at: float = 0.0
184    error: str = ""
185    node_error: str = ""

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

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

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

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

rows: list[ChallengeBuildStateRow] = PydanticUndefined
class ChallengeBuildStateRow(ctfy.core.models.CtfyModel):
188class ChallengeBuildStateRow(CtfyModel):
189    """Per-challenge build state aggregated across every online node.
190
191    ``aggregated`` rolls up ``nodes`` using a worst-case rule so the
192    table's single status column behaves intuitively:
193
194    * any ``building`` → ``building`` (yellow)
195    * any ``failed``  → ``failed`` (red)
196    * any ``unbuilt`` → ``unbuilt`` (grey)
197    * else            → ``built`` (green)
198
199    A node that couldn't be reached at all contributes ``unbuilt`` so
200    the aggregate stays conservative — the admin still sees a
201    "missing" pip and can drill into the modal to find out why.
202    """
203
204    challenge_id: str
205    aggregated: str = "unbuilt"
206    nodes: list[ChallengeBuildNodeState] = Field(default_factory=list)

Per-challenge build state aggregated across every online node.

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

  • any 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):
 96class ChallengeFacetCount(CtfyModel):
 97    """One bucket on the challenge catalog facet summary.
 98
 99    ``value`` is the difficulty / tag string; ``total`` is how many
100    challenges in scope carry it. ``solved`` is the *calling* user's
101    solved count in that bucket (across every team they have been on);
102    it is 0 for anonymous callers and for tag buckets (the UI only
103    renders a solved/total ratio for difficulty).
104    """
105
106    value: str
107    total: int = 0
108    solved: int = 0

One bucket on the challenge catalog facet summary.

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

value: str = PydanticUndefined
total: int = 0
solved: int = 0
class ChallengeFacets(ctfy.core.models.CtfyModel):
111class ChallengeFacets(CtfyModel):
112    """Catalog aggregates for the Challenges page side panels + filter
113    pill counts — served by ``GET /challenges/facets`` so the page no
114    longer derives them from a bulk fetch of the whole catalog.
115
116    Scoped by ``competition_id`` (when given) exactly like the list
117    endpoint, so the pills/charts reflect the same subset the paged
118    list pages through.
119    """
120
121    total: int = 0
122    difficulty: list[ChallengeFacetCount] = Field(default_factory=list)
123    # Per-category counts (only categories with ≥1 challenge in scope
124    # show up). Ordered by the canonical category sequence so the
125    # filter pills always render web → pwn → reverse → crypto → misc.
126    category: list[ChallengeFacetCount] = Field(default_factory=list)
127    tags: list[ChallengeFacetCount] = Field(default_factory=list)
128    # Per-status counts for the calling caller (``solved`` / ``unsolved``
129    # / ``running``). Drives the count chip on each status filter pill
130    # so the page doesn't N+1 a status-filtered list per pill. Buckets
131    # are always present (in canonical order) for layout stability; on
132    # anonymous callers every count is 0 — never leaking another
133    # team's state.
134    status: list[ChallengeFacetCount] = Field(default_factory=list)

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

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

total: int = 0
difficulty: list[ChallengeFacetCount] = PydanticUndefined
category: list[ChallengeFacetCount] = PydanticUndefined
tags: list[ChallengeFacetCount] = PydanticUndefined
status: list[ChallengeFacetCount] = PydanticUndefined
class ChallengeFlagStats(ctfy.core.models.CtfyModel):
304class ChallengeFlagStats(CtfyModel):
305    """Per-flag aggregate for one challenge (for the detail page)."""
306
307    flag_id: str
308    solves_count: int = 0

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

flag_id: str = PydanticUndefined
solves_count: int = 0
class ChallengeInfo(ctfy.core.models.CtfyModel):
45class ChallengeInfo(CtfyModel):
46    id: str
47    name: str
48    category: ChallengeCategory = ChallengeCategory.WEB
49    difficulty: str = ""
50    description: str = ""
51    # Full-challenge solves: teams that captured every declared
52    # question (across all modes — dynamic + static + select).
53    solves_count: int = 0
54    # Times this challenge has been instantiated, ever — one per
55    # archived InstanceRecord (every terminal path writes one). A
56    # "how much has this been run" signal for the challenge card;
57    # platform-wide, not scoped to the viewing team or competition.
58    launch_count: int = 0
59    tags: list[str] = Field(default_factory=list)
60    # Every question declared on this challenge, in author-declared
61    # order. Single-question challenges have a single entry with
62    # id ``"flag"``. ``QuestionPublicInfo`` exposes prompt/mode/choices
63    # but never the answer.
64    questions: list[QuestionPublicInfo] = Field(default_factory=list)
65    # Per-question solve counts (how many teams captured each
66    # question). Keyed by question id. Useful for the challenge
67    # detail page's progress breakdown. Counts span every mode;
68    # the multi_select grader records one solve per fully-correct
69    # submission, not per chosen choice.
70    flag_solves: dict[str, int] = Field(default_factory=dict)
71    # Files shipped under the challenge's ``attachments/`` directory,
72    # downloadable via ``GET /challenges/{id}/attachments/{name}``.
73    # Empty for pure-network challenges that ship nothing.
74    attachments: list[AttachmentInfo] = Field(default_factory=list)
75    # True for pure question-answer challenges (``category: misc``,
76    # no ``docker-compose.yml`` on disk, every declared question
77    # carries a static ``answer:``). The frontend keys off this flag
78    # to switch the competition surface to a quiz UI and skip the
79    # launch-confirm flow. Source of truth lives in
80    # :attr:`ctfy.core.challenge.ChallengeSpec.is_qa_only`, ultimately
81    # derived from :attr:`BenchmarkContext.is_qa_only`.
82    is_qa_only: bool = False

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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
class ChallengePullKickoffNodeResult(ctfy.core.models.CtfyModel):
285class ChallengePullKickoffNodeResult(CtfyModel):
286    """One node's response to a pull kickoff (single or all)."""
287
288    node_id: str
289    ok: bool
290    status: str = ""
291    queued: list[str] = Field(default_factory=list)
292    skipped_pulled: list[str] = Field(default_factory=list)
293    skipped_in_progress: list[str] = Field(default_factory=list)
294    error: str = ""

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

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

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

challenge_id: str = ''
nodes: list[ChallengePullKickoffNodeResult] = PydanticUndefined
class ChallengePullNodeState(ctfy.core.models.CtfyModel):
246class ChallengePullNodeState(CtfyModel):
247    """One worker node's view of a single challenge's pull state.
248
249    ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` /
250    ``failed``. ``node_error`` is non-empty only when the platform could
251    not reach the node at all — ``status`` is then forced to ``unpulled``
252    for the aggregate.
253    """
254
255    node_id: str
256    status: str = "unpulled"
257    pulled_at: float = 0.0
258    error: str = ""
259    node_error: str = ""

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

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

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

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

rows: list[ChallengePullStateRow] = PydanticUndefined
class ChallengePullStateRow(ctfy.core.models.CtfyModel):
262class ChallengePullStateRow(CtfyModel):
263    """Per-challenge pull state aggregated across every online node.
264
265    ``aggregated`` rolls up ``nodes`` worst-case, mirroring the build
266    row:
267
268    * any ``pulling`` → ``pulling`` (yellow)
269    * any ``failed``  → ``failed`` (red)
270    * any ``unpulled`` → ``unpulled`` (grey)
271    * else            → ``pulled`` (green)
272    """
273
274    challenge_id: str
275    aggregated: str = "unpulled"
276    nodes: list[ChallengePullNodeState] = Field(default_factory=list)

Per-challenge pull state aggregated across every online node.

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

  • any 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):
137class ChallengeRescanNodeResult(CtfyModel):
138    """Outcome of fanning the rescan out to one worker node.
139
140    ``ok`` is False for an offline node (skipped, never contacted) or a
141    node that errored; ``total`` is the node's post-rescan challenge
142    count when reached, else ``None``. One unreachable node never fails
143    the whole operation — the platform rescan still stands.
144    """
145
146    node_id: str
147    ok: bool
148    total: int | None = None
149    error: str | None = None

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):
152class ChallengeRescanResult(CtfyModel):
153    """Admin ``POST /admin/challenges/rescan`` response.
154
155    ``total`` / ``added`` / ``removed`` describe the platform's own
156    catalog after re-scanning ``challenges_dir``; ``nodes`` carries the
157    per-worker fan-out outcome so a newly added challenge is confirmed
158    launchable, not just listable.
159    """
160
161    total: int
162    added: list[str] = Field(default_factory=list)
163    removed: list[str] = Field(default_factory=list)
164    nodes: list[ChallengeRescanNodeResult] = Field(default_factory=list)

Admin POST /admin/challenges/rescan response.

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

total: int = PydanticUndefined
added: list[str] = PydanticUndefined
removed: list[str] = PydanticUndefined
nodes: list[ChallengeRescanNodeResult] = PydanticUndefined
class ChallengeSolveAttempt(ctfy.core.models.CtfyModel):
325class ChallengeSolveAttempt(CtfyModel):
326    """One archived instance for a challenge — used to visualise per-team
327    multi-solve attempts on the challenge detail page."""
328
329    instance_id: str
330    team_id: str = ""
331    team_name: str = ""
332    display_name: str = ""
333    challenge_id: str = ""
334    started_at: float = 0.0
335    stopped_at: float = 0.0
336    duration_s: float = 0.0
337    attempts: int = 0
338    solved: bool = False
339    solved_flags: list[str] = Field(default_factory=list)
340    stop_reason: str = ""

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

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):
357class ChallengeSolveAttemptsResponse(CtfyModel):
358    challenge_id: str
359    attempts: list[ChallengeSolveAttempt] = Field(default_factory=list)
360    per_team: list[ChallengeTeamSolveSummary] = Field(default_factory=list)
361    # Total archived instances (incl. unsolved) — lets the UI show
362    # "showing N solved of M total" when the response is filtered.
363    total_attempts: int = 0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

challenge_id: str = PydanticUndefined
attempts: list[ChallengeSolveAttempt] = PydanticUndefined
per_team: list[ChallengeTeamSolveSummary] = PydanticUndefined
total_attempts: int = 0
class ChallengeStats(ctfy.core.models.CtfyModel):
311class ChallengeStats(CtfyModel):
312    challenge_id: str
313    name: str = ""
314    category: ChallengeCategory | None = None
315    difficulty: str = ""
316    # Teams that captured every declared flag on this challenge.
317    solves_count: int = 0
318    total_attempts: int = 0
319    success_rate: float = 0.0
320    # Per-flag breakdown. Single-flag challenges have a single entry with
321    # ``flag_id == "flag"`` and ``solves_count == solves_count`` above.
322    flag_stats: list[ChallengeFlagStats] = Field(default_factory=list)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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):
343class ChallengeTeamSolveSummary(CtfyModel):
344    """Per-team aggregate for the challenge detail solve-attempts panel."""
345
346    team_id: str
347    team_name: str = ""
348    display_name: str = ""
349    solve_count: int = 0
350    attempt_count: int = 0
351    best_duration_s: float = 0.0
352    total_duration_s: float = 0.0
353    first_solved_at: float = 0.0
354    last_solved_at: float = 0.0

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

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 ClusterInfo(ctfy.core.models.CtfyModel):
133class ClusterInfo(CtfyModel):
134    """Returned by ``GET /cluster-info``.
135
136    Provides the bits the ``ctfy server invite`` CLI and admin UI wizard
137    need to build a runnable join command. The *registration token* is
138    minted separately via ``POST /nodes/invites`` — it isn't on this
139    payload because cluster info is queried speculatively by the UI on
140    every admin dashboard load, and we never want to hand an invite
141    token to a page that didn't explicitly ask for one.
142    """
143
144    platform_url: str
145    node_image: str

Returned by GET /cluster-info.

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

platform_url: str = PydanticUndefined
node_image: str = PydanticUndefined
class CompetitionAdminInfo(ctfy.core.models.CtfyModel):
185class CompetitionAdminInfo(CtfyModel):
186    """One per-competition admin grant, for the super-admin management
187    card on the competition detail page."""
188
189    user_id: str
190    display_name: str = ""
191    email: str = ""
192    granted_by: str = ""
193    granted_at: datetime | None = None

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

user_id: str = PydanticUndefined
display_name: str = ''
email: str = ''
granted_by: str = ''
granted_at: datetime.datetime | None = None
class CompetitionChallengeBreakdown(ctfy.core.models.CtfyModel):
573class CompetitionChallengeBreakdown(CtfyModel):
574    challenges: list[CompetitionChallengeRow] = Field(default_factory=list)
575    generated_at: float = 0.0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

challenges: list[CompetitionChallengeRow] = PydanticUndefined
generated_at: float = 0.0
class CompetitionChallengeRow(ctfy.core.models.CtfyModel):
551class CompetitionChallengeRow(CtfyModel):
552    """Per-challenge competition aggregate: solve count, attempt
553    success rate, and first blood. Reconstructed from the solve log
554    with the same window / team filter as the scoreboard so the
555    numbers always agree with the leaderboard."""
556
557    challenge_id: str
558    name: str = ""
559    category: ChallengeCategory | None = None
560    difficulty: str = ""
561    # Teams that captured every declared flag (matches scoreboard's
562    # ``solved`` tally).
563    solves_count: int = 0
564    # In-window submissions for this challenge across registered teams.
565    attempts: int = 0
566    success_rate: float = 0.0
567    # First team to solve this challenge in-window. Empty when unsolved.
568    first_blood_team_id: str = ""
569    first_blood_team_name: str = ""
570    first_blood_at: datetime | None = None

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

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):
 74class CompetitionCreate(CtfyModel):
 75    """Request body for ``POST /admin/competitions``.
 76
 77    Time fields accept ISO 8601 strings; empty means "no bound" (live
 78    immediately / never expires). ``challenge_ids`` is validated against
 79    the spec registry inside the route handler — unknown ids return 422.
 80    """
 81
 82    title: str = Field(min_length=1, max_length=200)
 83    description: str = Field(default="", max_length=20000)
 84    starts_at: datetime | None = None
 85    ends_at: datetime | None = None
 86    # No fixed length cap: the real bound is ``_check_challenge_ids`` in
 87    # ``admin_competitions`` (every id must be a known spec, so a valid list
 88    # can never exceed the catalog). A magic number here is arbitrary and a
 89    # recurring foot-gun as the corpus grows — bucket comps already exceed 1000.
 90    challenge_ids: list[str] = Field(default_factory=list)
 91    # New competitions start hidden ("draft") so admins can prepare them
 92    # privately and publish when ready.
 93    status: Literal["draft", "published", "archived"] = "draft"
 94    # Participation access (orthogonal to ``status``). ``public`` is open
 95    # to everyone; ``private_listed`` / ``private_hidden`` require an admin
 96    # invite to register (the latter also hides the comp from non-invited
 97    # users). Defaults to ``public`` so ad-hoc API callers stay open.
 98    access: Literal["public", "private_listed", "private_hidden"] = "public"
 99
100    _norm_times = field_validator("starts_at", "ends_at", mode="before")(_blank_to_none)

Request body for POST /admin/competitions.

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

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'
class CompetitionDetail(ctfy.server.models.CompetitionInfo):
168class CompetitionDetail(CompetitionInfo):
169    """Full detail payload — includes resolved challenge summaries and
170    the caller's registration state when authenticated.
171    """
172
173    challenges: list[ChallengeInfo] = Field(default_factory=list)
174    registered_at: datetime | None = None

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

challenges: list[ChallengeInfo] = PydanticUndefined
registered_at: datetime.datetime | None = None
class CompetitionInfo(ctfy.core.models.CtfyModel):
117class CompetitionInfo(CtfyModel):
118    """Summary row used in the list view.
119
120    Per-viewer fields (``is_registered`` / ``my_*``) are populated only
121    when the request carries an authenticated user; for anonymous
122    requests they fall back to sentinels (empty strings, ``-1``) so
123    the response shape stays stable. Front-end treats ``my_rank < 0``
124    as "no value" rather than "rank zero".
125    """
126
127    id: str
128    title: str
129    description: str
130    starts_at: datetime | None = None
131    ends_at: datetime | None = None
132    created_at: datetime | None = None
133    updated_at: datetime | None = None
134    created_by: str
135    created_by_name: str
136    challenge_ids: list[str] = Field(default_factory=list)
137    # Visibility gate. Only ``published`` rows reach non-admins; admins
138    # and per-competition admins also receive ``draft`` / ``archived``.
139    status: Literal["draft", "published", "archived"] = "published"
140    # Participation access. ``public`` is open; ``private_listed`` stays
141    # discoverable but invite-only to register; ``private_hidden`` is also
142    # hidden from non-invited users. Default ``public`` for backward
143    # compat (legacy rows / anonymous responses).
144    access: Literal["public", "private_listed", "private_hidden"] = "public"
145    # Server-derived projection. ``"upcoming"`` / ``"running"`` / ``"past"``
146    # — saves the frontend from re-implementing the same string-compare
147    # the backend already does.
148    phase: Literal["upcoming", "running", "past"] = "running"
149    # Total registered teams. Surfaced on cards so users can gauge
150    # popularity at a glance.
151    registered_count: int = 0
152    # Viewer-scoped fields — see class docstring.
153    is_registered: bool = False
154    # Whether the viewer may register for this comp. Always ``True`` for
155    # ``public`` comps; for private comps it's ``True`` only when the
156    # viewer is invited or can administer it. Lets the frontend render a
157    # "private — invitation required" locked card without a probe POST.
158    # Defaults ``True`` so public / anonymous-on-public responses are open.
159    can_participate: bool = True
160    my_team_id: str = ""
161    my_team_name: str = ""
162    my_role: Literal["captain", "member", ""] = ""
163    my_rank: int = -1
164    my_score: int = -1
165    my_solves: int = -1

Summary row used in the list view.

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

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'
phase: Literal['upcoming', 'running', 'past'] = 'running'
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):
204class CompetitionInviteInfo(CtfyModel):
205    """One participation-allowlist entry, for the admin "Invitations"
206    card on a private competition. The body for POST is the shared
207    ``GrantCompetitionAdminRequest`` (resolve by id or email)."""
208
209    user_id: str
210    display_name: str = ""
211    email: str = ""
212    invited_by: str = ""
213    invited_at: datetime | None = None

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

user_id: str = PydanticUndefined
display_name: str = ''
email: str = ''
invited_by: str = ''
invited_at: datetime.datetime | None = None
class CompetitionMembershipInfo(ctfy.core.models.CtfyModel):
25class CompetitionMembershipInfo(CtfyModel):
26    """One row on ``MeResponse.competition_teams``.
27
28    Lets the frontend hydrate a per-comp team picker / "My
29    competitions" hub from a single ``/me`` call without N+1
30    fetches. Captain status surfaces in the UI for buttons gated
31    on ``role == "captain"`` (mint invite / kick / rename).
32
33    ``competition_title`` / ``competition_phase`` are denormalized
34    from the competition the membership points at so the switcher
35    and the dashboard can label rows by the *competition* (the
36    thing the user is choosing between) rather than the team name —
37    every user is auto-joined to an identically named personal team
38    per comp, so team name alone makes the rows indistinguishable.
39    """
40
41    competition_id: str
42    competition_title: str
43    competition_phase: Literal["upcoming", "running", "past"]
44    team_id: str
45    team_name: str
46    role: Literal["captain", "member"]
47    # Rail-enrichment fields (denormalized so the Slack-style rail can
48    # render instance count / rank / a time-progress ring from one /me
49    # call, no per-tile fetch fan-out).
50    #
51    # Live instances for THIS team in THIS comp. Mirrors
52    # InstanceCountContext semantics (all non-terminal InstanceState
53    # rows for the team — terminal ones already left the live store as
54    # InstanceRecord); 0 when none.
55    running_instances: int = 0
56    # Competition window, copied straight off the CompetitionState the
57    # /me loop already fetches. "" start = live immediately, "" end =
58    # never ends (same convention as CompetitionState / phase()).
59    starts_at: datetime | None = None
60    ends_at: datetime | None = None
61    # Caller's current rank in this comp. -1 sentinel = "not computed"
62    # (upcoming phase, or the per-request rank budget was exhausted) —
63    # matches the existing my_rank<0 frontend convention.
64    my_rank: int = -1
65    # Registered-team count, the denominator the UI shows as "#3 / 40".
66    competition_team_count: int = 0

One row on MeResponse.competition_teams.

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

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

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
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):
177class CompetitionRegistrationInfo(CtfyModel):
178    """One row in the admin "who's registered" table."""
179
180    team_id: str
181    team_name: str = ""
182    registered_at: datetime | None = None

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

team_id: str = PydanticUndefined
team_name: str = ''
registered_at: datetime.datetime | None = None
class CompetitionScoreDistribution(ctfy.core.models.CtfyModel):
585class CompetitionScoreDistribution(CtfyModel):
586    """Aggregate companion to the (now server-paginated) competition
587    scoreboard: the score-distribution histogram + the true team count.
588
589    The standings table pages server-side, so the histogram / "N teams"
590    figure can't be re-derived from one page in the browser — this
591    bins every registered team's ``flags_solved`` with the same
592    adaptive scheme the frontend used to do client-side.
593    """
594
595    total_teams: int = 0
596    bins: list[ScoreBucket] = Field(default_factory=list)

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

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

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

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

series: list[ScoreHistorySeries] = PydanticUndefined
generated_at: float = 0.0
class CompetitionUpdate(ctfy.core.models.CtfyModel):
103class CompetitionUpdate(CtfyModel):
104    """PATCH body — every field optional, ``None`` means "leave alone"."""
105
106    title: str | None = Field(default=None, min_length=1, max_length=200)
107    description: str | None = Field(default=None, max_length=20000)
108    starts_at: datetime | None = None
109    ends_at: datetime | None = None
110    challenge_ids: list[str] | None = Field(default=None)  # no cap — see CompetitionCreate
111    status: Literal["draft", "published", "archived"] | None = None
112    access: Literal["public", "private_listed", "private_hidden"] | None = None
113
114    _norm_times = field_validator("starts_at", "ends_at", mode="before")(_blank_to_none)

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

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
class CreateFineGrainedTokenRequest(ctfy.core.models.CtfyModel):
152class CreateFineGrainedTokenRequest(CtfyModel):
153    """Body for ``POST /auth/tokens`` (mints a fine-grained token).
154
155    Back-compat: when ``competition_access`` / ``permissions`` are
156    omitted the server mints the broad legacy profile (every
157    competition, full participate access) so existing
158    ``mint_token(label)`` callers — SDK, CLI, MCP — keep getting a
159    token equivalent to the old agent token.
160    """
161
162    label: str = Field(default="", max_length=64)
163    # Lifetime in days. ``None`` uses the server default (30); ``0`` means
164    # "never expires". The server caps this at ~5 years to reject absurd
165    # values.
166    expires_in_days: int | None = Field(default=None, ge=0, le=365 * 5)
167    # ``None`` → legacy-broad default. Otherwise: none | all | selected.
168    competition_access: str | None = None
169    competition_ids: list[str] = Field(default_factory=list)
170    # Category → "none" | "read" | "write". Clamped server-side to each
171    # category's max level. ``None`` → legacy-broad default.
172    permissions: dict[str, str] | None = None

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

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

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):
175class CreateFineGrainedTokenResponse(TokenInfo):
176    """Includes the plaintext ``token`` — returned once, never persisted."""
177
178    token: str

Includes the plaintext token — returned once, never persisted.

token: str = PydanticUndefined
class CreateInviteRequest(ctfy.core.models.CtfyModel):
101class CreateInviteRequest(CtfyModel):
102    """Body for ``POST /api/v1/nodes/invites``.
103
104    ``ttl_seconds`` controls how long the minted registration token stays
105    valid — it cannot be renewed, only re-created.
106    """
107
108    ttl_seconds: int = Field(default=3600, ge=60, le=7 * 24 * 3600)

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

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

ttl_seconds: int = 3600
class CreateInviteResponse(ctfy.core.models.CtfyModel):
111class CreateInviteResponse(CtfyModel):
112    """Plaintext registration token is returned **exactly once** here.
113
114    Subsequent ``GET /nodes/invites`` lookups only expose metadata; the
115    plaintext is never persisted — only ``token_hash`` is stored.
116    """
117
118    id: str
119    registration_token: str
120    expires_at: datetime | None = None

Plaintext registration token is returned exactly once here.

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

id: str = PydanticUndefined
registration_token: str = PydanticUndefined
expires_at: datetime.datetime | None = None
class DeleteMeRequest(ctfy.core.models.CtfyModel):
112class DeleteMeRequest(CtfyModel):
113    """Body for ``DELETE /me``. ``confirm_display_name`` must match the
114    caller's user display name exactly (case-sensitive) — the typo
115    gate that stops a fat-fingered click from cascading the account's
116    data away. (Falls back to email when display_name is empty.)
117    """
118
119    confirm_display_name: str = Field(min_length=1, max_length=120)

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

confirm_display_name: str = PydanticUndefined
class DeviceApproveRequest(ctfy.core.models.CtfyModel):
109class DeviceApproveRequest(CtfyModel):
110    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):
83class DeviceCodeResponse(CtfyModel):
84    """RFC 8628-style response to ``POST /auth/device/code`` — the CLI
85    shows ``user_code`` + ``verification_uri`` and polls with
86    ``device_code``."""
87
88    device_code: str
89    user_code: str
90    verification_uri: str
91    verification_uri_complete: str
92    expires_in: int
93    interval: int

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

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):
113class DeviceInfoResponse(CtfyModel):
114    """What the approval page shows about a pending device request."""
115
116    status: str
117    requested_at: datetime | None = None
118    requester_ip: str = ""
119    requester_user_agent: str = ""

What the approval page shows about a pending device request.

status: str = PydanticUndefined
requested_at: datetime.datetime | None = None
requester_ip: str = ''
requester_user_agent: str = ''
class DeviceTokenRequest(ctfy.core.models.CtfyModel):
96class DeviceTokenRequest(CtfyModel):
97    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):
100class DeviceTokenResponse(CtfyModel):
101    """Poll result for ``POST /auth/device/token``. ``status`` is one of
102    ``pending`` / ``approved`` / ``denied`` / ``expired``; ``token`` is
103    set only when ``approved``."""
104
105    status: str
106    token: str = ""

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):
146class DifficultyStat(CtfyModel):
147    difficulty: str = ""  # easy | medium | hard | ...
148    solved: int = 0
149    total: int = 0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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 ErrorResponse(ctfy.core.models.CtfyModel):
 95class ErrorResponse(CtfyModel):
 96    """Unified shape for every HTTP 4xx/5xx body produced by platform routes.
 97
 98    ``detail`` mirrors the pre-existing field (so SDK / CLI / frontend code
 99    that reads ``data.detail`` keeps working); ``code`` adds a stable
100    machine-readable identifier derived from the raising exception class,
101    and ``timestamp`` is server-side UTC in ISO 8601 — useful when
102    correlating client-side and server-side logs.
103
104    ``detail`` is a union because three sources feed the envelope:
105      * a plain string from legacy ``HTTPException`` / ``raise_error`` calls;
106      * a structured dict (``{"message": "...", "competition_id": ...}``
107        or the legacy ``{"error": "..."}``) when the route wants to attach
108        machine-readable context the frontend can branch on;
109      * a list of FastAPI ``RequestValidationError`` issues, wrapped under
110        ``{"message", "issues"}`` by ``_handle_validation_error``.
111    """
112
113    code: str
114    detail: str | dict[str, Any] | list[dict[str, Any]]
115    timestamp: datetime | None = None

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

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

detail is a union because three sources feed the envelope:

  • a plain string from legacy HTTPException / raise_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 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):
196class GrantCompetitionAdminRequest(CtfyModel):
197    """Body for ``PUT /admin/competitions/{id}/admins`` — resolve the
198    target user by id or (case-insensitive) email."""
199
200    user_id: str = ""
201    email: str = ""

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

user_id: str = ''
email: str = ''
class HealthResponse(ctfy.core.models.CtfyModel):
18class HealthResponse(CtfyModel):
19    status: str = "ok"
20    hostname: str = ""
21    running_instances: int = 0
22    capacity: int = 0
23    # Server's installed ``ctfy`` version. Lets the SDK/CLI flag a
24    # client/server skew off the probe they already make — no extra
25    # round trip. Defaults to "" so older servers (and the model's own
26    # default construction) stay valid.
27    version: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

status: str = 'ok'
hostname: str = ''
running_instances: int = 0
capacity: int = 0
version: str = ''
class InboxAnnouncement(ctfy.core.models.CtfyModel):
127class InboxAnnouncement(CtfyModel):
128    """Announcement projected onto the Inbox surface.
129
130    Carries the same fields as :class:`AnnouncementInfo` plus a
131    per-user ``is_read`` flag derived from the
132    ``announcement_reads`` table. The frontend renders unread rows
133    prominently and lets the user mark them read via
134    ``POST /me/announcements/{id}/read``.
135    """
136
137    id: str
138    title: str
139    body: str
140    severity: AnnouncementSeverity
141    starts_at: datetime | None = None
142    ends_at: datetime | None = None
143    created_at: datetime | None = None
144    updated_at: datetime | None = None
145    created_by: str
146    created_by_name: str
147    is_read: bool = False

Announcement projected onto the Inbox surface.

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

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):
 92class InboxCaptainRequest(CtfyModel):
 93    """Pending join request against any team the calling user
 94    captains. Frontend renders an Approve / Reject pair pointing at
 95    ``/competitions/{competition_id}/invites/{invite_id}/approve|reject``.
 96    """
 97
 98    id: str
 99    competition_id: str
100    competition_title: str
101    team_id: str
102    team_name: str
103    requester_user_id: str
104    requester_display_name: str
105    created_at: datetime | None = None

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

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):
108class InboxCompetitionInvite(CtfyModel):
109    """A standing invitation to participate in a PRIVATE competition the
110    calling user has been granted but not yet joined.
111
112    Unlike the team invites above this isn't accept/decline — the
113    allowlist row is a standing permission, so the frontend renders a
114    "Register" CTA linking to ``/competitions/{competition_id}/team``.
115    Drops off the inbox once the user registers (or the comp ends / is
116    flipped back to public).
117    """
118
119    competition_id: str
120    competition_title: str
121    competition_phase: Literal["upcoming", "running", "past"]
122    invited_by_user_id: str = ""
123    invited_by_display_name: str = ""
124    invited_at: datetime | None = None

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

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

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):
59class InboxIncomingInvite(CtfyModel):
60    """Pending direct invite where the calling user is the named target.
61
62    The frontend renders an Accept / Decline pair pointing at
63    ``/competitions/{competition_id}/invites/{invite_id}/accept|decline``.
64    """
65
66    id: str
67    competition_id: str
68    competition_title: str
69    team_id: str
70    team_name: str
71    captain_user_id: str
72    captain_display_name: str
73    created_at: datetime | None = None

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

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

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):
76class InboxOutgoingRequest(CtfyModel):
77    """Pending join request the calling user opened — surfaces so the
78    requester can see "did the captain see this yet?" without
79    refreshing the team page.
80    """
81
82    id: str
83    competition_id: str
84    competition_title: str
85    team_id: str
86    team_name: str
87    captain_user_id: str
88    captain_display_name: str
89    created_at: datetime | None = None

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

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):
150class InboxResponse(CtfyModel):
151    """``GET /me/inbox`` payload — pending invites/requests +
152    actionable announcements grouped by the action the calling
153    user can take.
154    """
155
156    incoming_invites: list[InboxIncomingInvite] = Field(default_factory=list)
157    outgoing_requests: list[InboxOutgoingRequest] = Field(default_factory=list)
158    captain_requests: list[InboxCaptainRequest] = Field(default_factory=list)
159    # Standing participation invites to private competitions the user
160    # hasn't joined yet (rendered with a "Register" CTA, not accept/decline).
161    competition_invites: list[InboxCompetitionInvite] = Field(default_factory=list)
162    # Site-wide announcements relevant right now (currently live + a
163    # short tail of recently-expired) with the user's read state.
164    announcements: list[InboxAnnouncement] = Field(default_factory=list)
165    # Convenience count so the sidebar can render an unread badge
166    # without re-iterating ``announcements``.
167    unread_announcement_count: int = 0

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

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):
 61class InstanceInfo(CtfyModel):
 62    id: str
 63    challenge_id: str = ""
 64    team_id: str = ""
 65    # Competition the instance is scoped to. Stamped from the resolved
 66    # per-comp team at start time; empty for legacy rows or unscoped
 67    # admin instances. Frontend keys per-comp instance pages off this
 68    # field so a user registered for two comps doesn't see comp A's
 69    # instances in comp B's tab.
 70    competition_id: str = ""
 71    # Worker node currently hosting this instance. Empty only for
 72    # records created before nodes were tracked (legacy).
 73    node_id: str = ""
 74    name: str = ""
 75    category: ChallengeCategory | None = None
 76    difficulty: str = ""
 77    status: str = InstanceStatus.STARTING
 78    started_at: float = 0.0
 79    ttl: int = 0
 80    expires_at: float = 0.0
 81    services: list[ServiceEndpoint] = Field(default_factory=list)
 82    description: str = ""
 83    # Per-instance question view: same shape as ``ChallengeInfo.questions``
 84    # but additionally carries the agent-visible state. ``unlocked`` is
 85    # ``False`` when the question's ``requires:`` predecessors haven't
 86    # all been answered correctly yet; the UI hides the prompt for locked
 87    # questions to avoid leaking route hints. ``answered_correctly`` is
 88    # ``True`` once this team has captured this question on this instance.
 89    questions: list[InstanceQuestionInfo] = Field(default_factory=list)
 90    # Mirrored from ``ChallengeInfo.attachments`` so players who jump
 91    # straight to the instance page (e.g. via a launch link) see the
 92    # download list without re-fetching the catalog row.
 93    attachments: list[AttachmentInfo] = Field(default_factory=list)
 94    # How the platform exposes this instance to the player. Always
 95    # surfaced (defaults to ``"simple"``) so the agent can branch on
 96    # `if info.network_topology == "engagement": ...` without dealing
 97    # with a missing field.
 98    network_topology: Literal["simple", "engagement"] = "simple"
 99    # Set only when ``network_topology == "engagement"``. Carries the
100    # tunnel endpoint host:port + the URL the client downloads the
101    # `.ovpn` body from. ``None`` in simple mode.
102    vpn_endpoint: VpnEndpoint | None = None
103    # Landing-page hints declared in the challenge's ``metadata.yaml``
104    # (``entry_urls:`` field, validated by META013). Surfaced verbatim
105    # to the frontend so the VPN / services panel can render a
106    # "start here" list without the player having to read the
107    # description for the canonical first URL.
108    entry_urls: list[str] = Field(default_factory=list)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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

One question, projected for a specific running instance.

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

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

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

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

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):
174class InstanceRecordDetail(CtfyModel):
175    record: InstanceRecordInfoDetail
176    artifacts: InstanceRecordArtifacts = Field(default_factory=InstanceRecordArtifacts)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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

Archived-instance summary for the admin history list.

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

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

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

spec: dict[str, typing.Any] = PydanticUndefined
surface: ctfy.core.target.AttackSurface | None = None
artifact_dir: str = ''
class InstanceStatusResponse(ctfy.core.models.CtfyModel):
179class InstanceStatusResponse(CtfyModel):
180    id: str
181    status: str = InstanceStatus.STARTING
182    attack_surface: AttackSurface | None = None
183    error: str = ""
184    # CA volume for the per-instance mitmproxy. Operators trust this CA
185    # if they want to MITM HTTPS; HTTP traffic is captured transparently
186    # without it.
187    cert_volume: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

id: str = PydanticUndefined
status: str = <InstanceStatus.STARTING: 'starting'>
attack_surface: ctfy.core.target.AttackSurface | None = None
error: str = ''
cert_volume: str = ''
class LinkStartResponse(ctfy.core.models.CtfyModel):
198class LinkStartResponse(CtfyModel):
199    """Returned by ``POST /auth/identities/link/{provider}``.
200
201    The caller is already authenticated via XHR, so we don't 302 them;
202    instead we hand back the authorize URL for the frontend to navigate
203    the browser to.
204    """
205
206    authorize_url: str

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

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

authorize_url: str = PydanticUndefined
class LinkedIdentity(ctfy.core.models.CtfyModel):
34class LinkedIdentity(CtfyModel):
35    id: str
36    # "github" | "google" (OAuth) or "password" for a local credential.
37    provider: str
38    provider_email: str = ""
39    provider_display_name: str = ""
40    # Stable third-party account id (GitHub numeric id, Google `sub`);
41    # empty for the local password credential.
42    provider_user_id: str = ""
43    # GitHub `@handle`; empty for Google and password.
44    provider_login: str = ""
45    avatar_url: str = ""
46    created_at: datetime | None = None
47    last_used_at: datetime | None = None

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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 LoginRequest(ctfy.core.models.CtfyModel):
67class LoginRequest(CtfyModel):
68    email: EmailStr
69    password: str = Field(min_length=1, max_length=256)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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)

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
class MetaChallenges(ctfy.core.models.CtfyModel):
51class MetaChallenges(CtfyModel):
52    """Source-control identity of the challenges repository, plus the
53    public count of available challenges.
54
55    ``commit_sha`` is None when the challenges directory isn't a git
56    working tree (e.g. ``CTFY_CHALLENGES_REPO=""`` or a bare checkout).
57    ``total`` is visible to everyone — the list itself is browsable.
58    """
59
60    commit_sha: str | None = None
61    commit_url: str | None = None
62    repo_url: str = ""
63    total: int = 0

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

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

commit_sha: str | None = None
commit_url: str | None = None
repo_url: str = ''
total: int = 0
class MetaPlatform(ctfy.core.models.CtfyModel):
37class MetaPlatform(CtfyModel):
38    """Source-control identity of the running ctfy build.
39
40    ``commit_sha`` is None when the build couldn't determine its own
41    revision (no ``CTFY_GIT_COMMIT``, no usable git binary).
42    ``commit_url`` is built from ``repo_url`` + sha so the frontend
43    doesn't have to know the URL convention.
44    """
45
46    commit_sha: str | None = None
47    commit_url: str | None = None
48    repo_url: str = ""

Source-control identity of the running ctfy build.

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

commit_sha: str | None = None
commit_url: str | None = None
repo_url: str = ''
class MetaResponse(ctfy.core.models.CtfyModel):
66class MetaResponse(CtfyModel):
67    version: str = ""
68    platform: MetaPlatform = Field(default_factory=MetaPlatform)
69    challenges: MetaChallenges = Field(default_factory=MetaChallenges)
70    started_at_ts: float = 0.0
71    server_time_ts: float = 0.0
72    # Cluster-wide counters — admin-only. ``None`` for non-admin
73    # callers so the frontend can distinguish "not authorised" from
74    # "happens to be zero" and hide the segment entirely instead of
75    # rendering a misleading "0 nodes".
76    teams_total: int | None = None
77    users_total: int | None = None
78    nodes_total: int | None = None
79    nodes_healthy: int | None = None
80    running_instances: int | None = None
81    solves_total: int | None = None
82    # Public surface bit for the super-admin docker-exec feature. The
83    # frontend uses this to decide whether to render the Shell entry
84    # in the admin instance list — false means the routes aren't
85    # mounted, so any UI link would 404. Never carries the
86    # token/recording details — those stay server-side.
87    admin_shell_enabled: bool = False

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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
class MilestoneProgress(ctfy.core.models.CtfyModel):
474class MilestoneProgress(CtfyModel):
475    """Per-challenge milestone progress for the calling user.
476
477    Returned by ``GET /me/milestone-progress``. One row per challenge
478    with at least one captured question, regardless of whether the
479    challenge is fully solved. The Challenges list page renders a
480    progress bar from ``len(solved_question_ids) / total_questions``
481    so a player can see "2/5 milestones" instead of an all-or-nothing
482    solved badge.
483
484    Like ``MySolveSummary`` this is user-scoped: ``solved_question_ids``
485    aggregates every question id the user captured for the challenge
486    across every team they have ever been on. The optional
487    ``competition_id`` query param narrows the aggregation to solves
488    stamped against the user's team in that comp.
489    """
490
491    challenge_id: str
492    solved_question_ids: list[str] = Field(default_factory=list)
493    total_questions: int = 0

Per-challenge milestone progress for the calling user.

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

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

challenge_id: str = PydanticUndefined
solved_question_ids: list[str] = PydanticUndefined
total_questions: int = 0
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 MySolveSummary(ctfy.core.models.CtfyModel):
458class MySolveSummary(CtfyModel):
459    """Per-challenge solve summary for the calling team.
460
461    Returned by ``GET /me/solves``. One row per challenge with at least
462    one captured flag. ``best_rank`` is the team's best (smallest) rank
463    across the flags they captured for the challenge — drives the
464    1血/2血/3血 badge on the challenge cards. Multi-flag challenges may
465    have different ranks per flag; reporting the best one gives players
466    credit for whichever piece they nailed first.
467    """
468
469    challenge_id: str
470    best_rank: int
471    solved_at: datetime | None = None

Per-challenge solve summary for the calling team.

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

challenge_id: str = PydanticUndefined
best_rank: int = PydanticUndefined
solved_at: datetime.datetime | None = None
class NodeHeartbeat(ctfy.core.models.CtfyModel):
61class NodeHeartbeat(CtfyModel):
62    """Body for ``POST /nodes/heartbeat``.
63
64    Node reports its live counts + resource utilisation on every beat.
65    All metrics are 0–100; 0 is a safe default for the first beat where
66    psutil hasn't had a prior sample to diff against.
67    """
68
69    id: str
70    running: int = 0
71    capacity: int = 50
72    cpu_percent: float = 0.0
73    memory_percent: float = 0.0
74    disk_percent: float = 0.0
75    # Unix seconds when the node sampled the metrics. The server stores
76    # ``received_at - sampled_at`` as one-way latency on the resulting
77    # health sample. Default 0 means "not measured" — older node builds
78    # that haven't been upgraded keep working.
79    sampled_at: float = 0.0

Body for POST /nodes/heartbeat.

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

id: str = PydanticUndefined
running: int = 0
capacity: int = 50
cpu_percent: float = 0.0
memory_percent: float = 0.0
disk_percent: float = 0.0
sampled_at: float = 0.0
class NodeInfo(ctfy.core.models.CtfyModel):
29class NodeInfo(CtfyModel):
30    id: str
31    url: str
32    display_name: str = ""
33    capacity: int = 0
34    running: int = 0
35    is_healthy: bool = True
36    last_heartbeat: datetime | None = None
37    # First successful registration. ``None`` only for rows that
38    # pre-date the field; read-path code in
39    # ``ctfy/server/routes/nodes.py`` falls back to ``last_heartbeat``
40    # so the admin UI never shows "—" for established nodes.
41    registered_at: datetime | None = None
42    labels: dict[str, str] = Field(default_factory=dict)
43    # Latest resource sample reported on heartbeat (0–100).
44    cpu_percent: float = 0.0
45    memory_percent: float = 0.0
46    disk_percent: float = 0.0

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
class NodeInviteInfo(ctfy.core.models.CtfyModel):
123class NodeInviteInfo(CtfyModel):
124    """Admin-facing summary (``GET /nodes/invites``). No token fields."""
125
126    id: str
127    created_at: datetime | None = None
128    expires_at: datetime | None = None
129    status: str  # "active" | "consumed" | "expired"
130    consumed_by_node_id: str = ""

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

id: str = PydanticUndefined
created_at: datetime.datetime | None = None
expires_at: datetime.datetime | None = None
status: str = PydanticUndefined
consumed_by_node_id: str = ''
class NodePatch(ctfy.core.models.CtfyModel):
49class NodePatch(CtfyModel):
50    """Body for ``PATCH /admin/nodes/{node_id}``.
51
52    Fields are optional — absent fields keep their current value.
53    display_name is the common case; labels allow adding/replacing the
54    full dict (partial label edits need a second round-trip).
55    """
56
57    display_name: str | None = None
58    labels: dict[str, str] | None = None

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

Fields are optional — absent fields keep their current value. display_name is the common case; labels allow adding/replacing the full dict (partial label edits need a second round-trip).

display_name: str | None = None
labels: dict[str, 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):
82class NodeRegisterResponse(NodeInfo):
83    """Registration response carries the node's bearer token (plaintext).
84
85    Returned exactly once, at registration. The same token is used in
86    both directions:
87
88    * **node→platform** (heartbeat, deregister) — node presents it as
89      the Bearer; platform looks the row up by comparing plaintexts.
90    * **platform→node** (start/stop/status) — platform presents the
91      same plaintext on every call; node verifies against its in-memory
92      copy.
93
94    Re-registration rotates the token. The node never persists it on
95    disk; "restart = re-register = fresh credential".
96    """
97
98    token: str = ""

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

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

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

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

token: str = ''
class OAuthProviderInfo(ctfy.core.models.CtfyModel):
17class OAuthProviderInfo(CtfyModel):
18    name: str  # "github" | "google"
19    enabled: bool
20    authorize_path: str  # relative, e.g. "/api/v1/auth/login/github"

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

name: str = PydanticUndefined
enabled: bool = PydanticUndefined
authorize_path: str = PydanticUndefined
class PasswordAuthInfo(ctfy.core.models.CtfyModel):
23class PasswordAuthInfo(CtfyModel):
24    """Gating flag for the email+password sign-in form on the login page."""
25
26    enabled: bool = False

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

enabled: bool = False
class PlatformSettingInfo(ctfy.core.models.CtfyModel):
306class PlatformSettingInfo(CtfyModel):
307    """One row from ``GET /admin/platform-settings``.
308
309    ``value`` is the live resolved value (DB > env > default); the
310    other two fields show what env and the built-in default would
311    resolve to in isolation, so the UI can render a tooltip
312    explaining what a ``DELETE`` would land on. ``source`` is the
313    tier the current value came from.
314    """
315
316    key: str = ""
317    type: str = ""
318    description: str = ""
319    value: Any = None
320    source: str = ""  # "db" | "env" | "default"
321    env_value: Any = None
322    default_value: Any = None

One row from GET /admin/platform-settings.

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

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):
325class PlatformSettingPatch(CtfyModel):
326    """Body of ``PATCH /admin/platform-settings/{key}``.
327
328    ``value`` is JSON-typed; the resolver's per-key validator decides
329    whether it's accepted.
330    """
331
332    value: Any = None

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

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

value: Any = None
class ProfilePatchRequest(ctfy.core.models.CtfyModel):
 96class ProfilePatchRequest(CtfyModel):
 97    """Body for ``PATCH /me/profile``. Every field is optional so the
 98    UI can send only the diff; the route only writes the fields that
 99    were present in the JSON. ``None`` means "clear this field"."""
100
101    # ``Field(default=...)`` with a non-None sentinel is awkward in
102    # pydantic; we instead declare each field as Optional + default-None
103    # and rely on the request's raw dict (via ``model_fields_set``) to
104    # know which keys were actually present.
105    bio: str | None = Field(default=None, max_length=280)
106    country: str | None = None
107    website_url: str | None = Field(default=None, max_length=200)
108    timezone: str | None = Field(default=None, max_length=80)
109    social_links: dict[str, str] | None = None

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

bio: str | None = None
country: str | None = None
website_url: str | None = None
timezone: str | None = None
class ProfileStats(ctfy.core.models.CtfyModel):
159class ProfileStats(CtfyModel):
160    """Aggregate per-team analytics surfaced on the public profile.
161
162    Every section is independently visibility-gated; non-owner viewers
163    see ``[]`` for a section the team has marked private. Owner and
164    admin always see the full payload."""
165
166    calendar: list[CalendarBucket] = Field(default_factory=list)
167    by_tag: list[TagStat] = Field(default_factory=list)
168    by_difficulty: list[DifficultyStat] = Field(default_factory=list)
169    solve_trend: list[TrendPoint] = Field(default_factory=list)

Aggregate per-team analytics surfaced on the public profile.

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

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):
29class ProvidersResponse(CtfyModel):
30    providers: list[OAuthProviderInfo] = Field(default_factory=list)
31    password_auth: PasswordAuthInfo = Field(default_factory=PasswordAuthInfo)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

providers: list[OAuthProviderInfo] = PydanticUndefined
password_auth: PasswordAuthInfo = PydanticUndefined
class QaChallengeView(ctfy.core.models.CtfyModel):
432class QaChallengeView(CtfyModel):
433    """One QA challenge as seen by a team on the quiz surface.
434
435    Carries the full question / choices / description data alongside
436    per-team solve and attempt state, so the frontend can render the
437    quiz list with progress badges in a single round-trip.
438    """
439
440    id: str
441    name: str
442    # Provenance bucket prefix — ``SECQA`` / ``MMLU-CS`` / ``CTI-MCQ``
443    # / etc — derived from the challenge id (everything before the
444    # trailing numeric suffix). Lets the frontend group / filter
445    # without splitting the id again client-side.
446    bucket: str
447    description: str
448    difficulty: str = ""
449    tags: list[str] = Field(default_factory=list)
450    questions: list[QuestionPublicInfo] = Field(default_factory=list)
451    # Question ids the calling team has already captured.
452    solved_question_ids: list[str] = Field(default_factory=list)
453    # Question ids the team has submitted at least one wrong answer
454    # against but never solved. Drives the "Try again" amber badge.
455    attempted_wrong_question_ids: list[str] = Field(default_factory=list)

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

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

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):
415class QaSubmissionCreate(CtfyModel):
416    """Request body for ``POST /qa/submissions`` — the instance-free
417    submission path for pure question-answer challenges.
418
419    No ``instance_id``: QA challenges have no Docker infra to launch,
420    so there's no per-team instance row to read context off. The
421    caller passes ``challenge_id`` + ``competition_id`` explicitly,
422    and the server resolves the submitter's team for the named
423    competition via the standard auth helper.
424    """
425
426    challenge_id: str
427    competition_id: str
428    question_id: str = "answer"
429    answer: str | list[str]

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

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

challenge_id: str = PydanticUndefined
competition_id: str = PydanticUndefined
question_id: str = 'answer'
answer: str | list[str] = PydanticUndefined
class QuestionAttemptResetInfo(ctfy.core.models.CtfyModel):
369class QuestionAttemptResetInfo(CtfyModel):
370    """Response from the admin reset endpoint. Echoes the new baseline
371    row so the UI can update its local state without re-fetching the
372    instance.
373    """
374
375    team_id: str
376    challenge_id: str
377    question_id: str
378    reset_at: datetime
379    reset_by_user_id: str
380    reason: str

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

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):
358class QuestionAttemptResetRequest(CtfyModel):
359    """Request body for ``POST /admin/teams/.../reset-attempts``.
360
361    ``reason`` is free-form audit metadata recorded onto the activity
362    log row; capped at 500 chars so it round-trips through the SSE
363    payload without bloating the wire frame.
364    """
365
366    reason: str = Field(default="", max_length=500)

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

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

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

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

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

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):
55class RegisterRequest(CtfyModel):
56    email: EmailStr
57    # Length minimum mirrors ``MIN_PASSWORD_LENGTH`` in
58    # ``ctfy.server.password``. ``validate_password_strength`` runs the
59    # full check (length + common-password reject) inside the route
60    # handler so the rule lives in one place; this floor is a cheap
61    # client-side hint that catches obviously short input before it
62    # reaches argon2.
63    password: str = Field(min_length=12, max_length=256)
64    display_name: str = Field(default="", max_length=120)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

email: pydantic.networks.EmailStr = PydanticUndefined
password: str = PydanticUndefined
display_name: str = ''
class RenewResponse(ctfy.core.models.CtfyModel):
222class RenewResponse(CtfyModel):
223    status: str = "renewed"
224    id: str
225    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 ScheduledJobInfo(ctfy.core.models.CtfyModel):
447class ScheduledJobInfo(CtfyModel):
448    """One recurring job on ``GET /admin/scheduled-jobs``: a projection of
449    ``ScheduledJobState`` plus the code-side ``default_interval_s`` so the
450    UI can show "(default: 30s)" next to a tuned value."""
451
452    name: str = ""
453    enabled: bool = True
454    interval_s: int = 0
455    default_interval_s: int = 0
456    last_run_at_ts: float = 0.0
457    last_status: str = ""
458    last_summary: str = ""
459    last_error: str = ""
460    last_duration_s: float = 0.0
461    run_count: int = 0
462    updated_by_user_id: str = ""

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

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

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

enabled: bool | None = None
interval_s: int | None = None
class ScopeCatalogEntry(ctfy.core.models.CtfyModel):
181class ScopeCatalogEntry(CtfyModel):
182    """One assignable permission category, for the token-creation UI."""
183
184    key: str
185    label: str
186    description: str
187    competition_scoped: bool
188    max_level: str  # highest grantable level: "read" | "write"

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

key: str = PydanticUndefined
label: str = PydanticUndefined
description: str = PydanticUndefined
competition_scoped: bool = PydanticUndefined
max_level: str = PydanticUndefined
class ScoreBucket(ctfy.core.models.CtfyModel):
578class ScoreBucket(CtfyModel):
579    """One bucket of the scoreboard score-distribution histogram."""
580
581    label: str
582    count: int = 0

One bucket of the scoreboard score-distribution histogram.

label: str = PydanticUndefined
count: int = 0
class ScoreHistoryPoint(ctfy.core.models.CtfyModel):
524class ScoreHistoryPoint(CtfyModel):
525    """One sample on a team's score/rank-over-time curve.
526
527    Both metrics travel together so the frontend can toggle
528    Score ⇄ Rank without a refetch.
529    """
530
531    ts: float = 0.0  # Unix seconds (solve time of the driving event)
532    score: int = 0  # Cumulative flags captured up to ``ts``
533    rank: int = 0  # 1-based rank among scoring teams at ``ts``

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

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

ts: float = 0.0
score: int = 0
rank: int = 0
class ScoreHistorySeries(ctfy.core.models.CtfyModel):
536class ScoreHistorySeries(CtfyModel):
537    team_id: str
538    team_name: str
539    points: list[ScoreHistoryPoint] = Field(default_factory=list)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
points: list[ScoreHistoryPoint] = PydanticUndefined
class ScoreboardEntry(ctfy.core.models.CtfyModel):
501class ScoreboardEntry(CtfyModel):
502    rank: int = 0
503    team_id: str
504    team_name: str
505    # Challenges where the team captured every declared flag. Preserved
506    # for parity with the legacy single-flag scoreboard.
507    solved: int = 0
508    # Total individual flags captured across all challenges. Primary
509    # scoreboard metric now that multi-flag challenges exist — rewards
510    # partial progress (e.g. foothold without root).
511    flags_solved: int = 0
512    attempts: int = 0
513    last_solve_at: datetime | None = None
514    # Most recent activity of any kind. Distinct from ``last_solve_at`` —
515    # a team may be actively attempting without a solve, and the UI
516    # wants to show that they're not idle.
517    last_active_at: datetime | None = None
518    # OAuth provider avatar URL for the team's earliest identity that
519    # supplies one. Empty for password-only teams; the frontend falls
520    # back to a generated initials avatar in that case.
521    avatar_url: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

rank: int = 0
team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
solved: int = 0
flags_solved: int = 0
attempts: int = 0
last_solve_at: datetime.datetime | None = None
last_active_at: datetime.datetime | None = None
avatar_url: str = ''
class SetPasswordRequest(ctfy.core.models.CtfyModel):
122class SetPasswordRequest(CtfyModel):
123    # Required iff the caller already has a password identity — absent on
124    # the first-time "attach password to an OAuth account" flow.
125    current_password: str = Field(default="", max_length=256)
126    # See RegisterRequest.password for the length-minimum rationale.
127    new_password: str = Field(min_length=12, max_length=256)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

current_password: str = ''
new_password: str = PydanticUndefined
class ShellRecordingInfo(ctfy.core.models.CtfyModel):
340class ShellRecordingInfo(CtfyModel):
341    """One row from ``GET /admin/shell-recordings``: a per-session
342    asciinema v2 cast file the platform wrote when a super-admin
343    opened a docker-exec shell. The frontend renders a playable
344    inline player; the raw bytes are pulled via the per-session
345    download route (also super-admin gated, also audited)."""
346
347    session_id: str = ""
348    path: str = ""
349    size_bytes: int = 0
350    modified_at_ts: float = 0.0

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

session_id: str = ''
path: str = ''
size_bytes: int = 0
modified_at_ts: float = 0.0
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):
195class StartRequest(CtfyModel):
196    challenge_id: str
197    proxy_output_dir: str | None = None
198    # Per-instance TTL override in seconds. ``None`` (the default)
199    # defers to the platform's ``default_instance_ttl_s`` setting —
200    # admin-tunable, currently 24h. Explicit values are clamped to
201    # ``max_instance_ttl_s`` so a misbehaving SDK / agent can't pin
202    # a node by requesting an arbitrarily large TTL.
203    ttl: int | None = None
204    # Which competition this instance is for. The platform looks up
205    # the caller's per-comp team via ``(user_id, competition_id)`` and
206    # stamps the instance with that team. Optional only when the
207    # caller is on exactly one team — multi-comp users always pass
208    # it explicitly.
209    competition_id: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

challenge_id: str = PydanticUndefined
proxy_output_dir: str | None = None
ttl: int | None = None
competition_id: str = ''
class StartResponse(ctfy.core.models.CtfyModel):
212class StartResponse(CtfyModel):
213    id: str
214    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):
217class StopResponse(CtfyModel):
218    status: str = InstanceStatus.STOPPED
219    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):
371class SubmissionCreate(CtfyModel):
372    # ``instance_id`` is the canonical handle since 0.2: the server
373    # reads challenge_id and competition_id off the instance row, so
374    # callers don't need to repeat them and N instances per
375    # (team, challenge) are unambiguous on submit.
376    instance_id: str
377    # Which question on the challenge the agent is answering. Defaults
378    # to ``"flag"`` so single-question challenges stay one-liner submits;
379    # multi-question challenges (see ``ChallengeInfo.questions``) must
380    # specify the id explicitly.
381    question_id: str = "flag"
382    # The agent's submission. String for ``dynamic`` / ``static`` /
383    # ``single_select`` questions; list of strings for ``multi_select``
384    # (e.g. ``["idor", "ssrf"]``). The server picks the grader by
385    # consulting ``spec.questions[i].mode`` on the live instance.
386    answer: str | list[str]

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

instance_id: str = PydanticUndefined
question_id: str = 'flag'
answer: str | list[str] = PydanticUndefined
class SubmissionResponse(ctfy.core.models.CtfyModel):
389class SubmissionResponse(CtfyModel):
390    id: str
391    correct: bool
392    solve_time_s: float = 0  # seconds from instance start to correct submission
393    # 1 = first blood, 2 = second, 3 = third, 4+ = regular solve.
394    # 0 when incorrect or when the team already had a prior correct solve.
395    # Ranks within the specific question — independent of the
396    # full-challenge solve rank.
397    solve_rank: int = 0
398    # The question id this submission targeted. Echoed back to the
399    # caller so simple submission UIs don't have to thread the id
400    # separately. Equal to the request's ``question_id``.
401    question_id: str | None = None
402    # True iff this team has now captured every question declared on
403    # the challenge. Single-question challenges: always equals ``correct``.
404    challenge_fully_solved: bool = False
405    # Remaining wrong-attempt budget for the question this submission
406    # targeted, AFTER this attempt is counted (the cap is on wrongs;
407    # correct submissions don't consume budget). ``None`` when the
408    # question's mode is uncapped (dynamic free-form, or any mode the
409    # operator opted out of via ``question_attempt_caps``). The UI
410    # uses this to decrement its remaining-attempts badge without
411    # re-fetching the instance — see ``ctfy.server.submission_policy``.
412    attempts_remaining: int | None = None

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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
class TagStat(ctfy.core.models.CtfyModel):
133class TagStat(CtfyModel):
134    """One axis on the per-tag strength radar.
135
136    ``solved`` is the team's solve count for challenges carrying this
137    tag; ``total`` is the platform-wide challenge count for the tag,
138    so the frontend can render "team / total" or normalize to a 0–1
139    ratio for the radar polygon."""
140
141    tag: str = ""
142    solved: int = 0
143    total: int = 0

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 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 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):
170class TeamInviteInfo(CtfyModel):
171    """Public projection of a :class:`TeamInviteState`.
172
173    Surfaces on captain CRUD. ``active`` is server-derived from the
174    expires/use/revoked triple. ``kind`` discriminates code-style
175    invites from directed invites/requests; ``target_user_id`` is
176    the recipient for ``kind="direct"`` (set at mint time) and the
177    requester for ``kind="join_request"`` (set when a member opens
178    a request).
179    """
180
181    id: str
182    code: str
183    team_id: str
184    created_by_user_id: str
185    created_at: datetime | None = None
186    expires_at: datetime | None = None
187    max_uses: int
188    use_count: int
189    revoked_at: datetime | None = None
190    active: bool = True
191    competition_id: str = ""
192    kind: Literal["code", "direct", "join_request"] = "code"
193    target_user_id: str = ""

Public projection of a TeamInviteState.

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

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 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 TokenInfo(ctfy.core.models.CtfyModel):
130class TokenInfo(CtfyModel):
131    id: str
132    kind: str  # "user" | "fine_grained" (legacy dumps may still say "agent")
133    label: str = ""
134    created_at: datetime | None = None
135    last_used_at: datetime | None = None
136    # ISO-8601 UTC timestamp; empty means the token never expires.
137    expires_at: datetime | None = None
138    # Client metadata captured when the session was minted. Only populated
139    # for user-kind tokens (browser sessions).
140    ip_address: str = ""
141    user_agent: str = ""
142    # True for the session that made this request — lets the UI show "this
143    # session" and disable the Revoke button so a user can't accidentally
144    # log themselves out.
145    is_current: bool = False
146    # Fine-grained scope summary (empty/"none" for user-kind tokens).
147    competition_access: str = "none"  # none | all | selected
148    competition_ids: list[str] = Field(default_factory=list)
149    permissions: dict[str, str] = Field(default_factory=dict)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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):
191class TokenScopesResponse(CtfyModel):
192    """``GET /auth/tokens/scopes`` — the catalog the UI renders."""
193
194    categories: list[ScopeCatalogEntry] = Field(default_factory=list)
195    levels: list[str] = Field(default_factory=lambda: ["none", "read", "write"])

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

categories: list[ScopeCatalogEntry] = PydanticUndefined
levels: list[str] = PydanticUndefined
class TrendPoint(ctfy.core.models.CtfyModel):
152class TrendPoint(CtfyModel):
153    """One UTC-day bucket on the solve-trend line."""
154
155    date: str = ""  # YYYY-MM-DD, UTC
156    solves: int = 0

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

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

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

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

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):
599class UserScoreboardEntry(CtfyModel):
600    """One row on the user-ranked global scoreboard.
601
602    Aggregates a single user's solves across every team they've
603    ever been on (each ``SolveState`` carries both ``user_id`` and
604    ``team_id``, so per-user totals are a straight filter over the
605    global solve table). Excludes users with zero solves to keep
606    the public list compact.
607    """
608
609    rank: int = 0
610    user_id: str
611    display_name: str
612    avatar_url: str = ""
613    country: str = ""
614    # Total flags solved across all teams the user has ever been on.
615    flags_solved: int = 0
616    # Challenges fully solved (every declared flag captured).
617    solved: int = 0
618    last_solve_at: datetime | None = None

One row on the user-ranked global scoreboard.

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

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):
172class UserSolveTrend(CtfyModel):
173    """The calling user's daily solve count for the last ``days`` UTC days,
174    aggregated across every team they have ever been on (matched by
175    ``user_id`` on each :class:`SolveState`).
176
177    Powers the Dashboard's "personal solve trend" chart, which is
178    user-scoped — independent of which competition the user is currently
179    registered for. Multi-flag challenges count once per flag day, the
180    same way :class:`ProfileStats.solve_trend` does it.
181    """
182
183    solve_trend: list[TrendPoint] = Field(default_factory=list)

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

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

solve_trend: list[TrendPoint] = PydanticUndefined
class VerifyAnswerRequest(ctfy.core.models.CtfyModel):
228class VerifyAnswerRequest(CtfyModel):
229    """Oracle-mode answer check — same grader as /submissions but writes
230    no submission record. Carries the same shape as :class:`SubmissionCreate`
231    minus the audit trail.
232    """
233
234    # Which question on the challenge the agent is checking. Defaults
235    # to ``"flag"`` for single-question challenges.
236    question_id: str = "flag"
237    # The answer to verify (str for dynamic / static / single_select;
238    # list[str] for multi_select).
239    answer: str | list[str]

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

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

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

correct: bool = PydanticUndefined
question_id: str | None = None