ctfy.server.models

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

  1"""Public wire-model package — re-exports for back-compat."""
  2
  3from __future__ import annotations
  4
  5from ctfy.server.models.achievement import (
  6    AchievementCatalogEntry,
  7    AchievementProgress,
  8    AchievementSummary,
  9    EasterEggClaim,
 10    MyAchievementsResponse,
 11    RecentUnlock,
 12    StarGazerVerifyResponse,
 13    TeamAchievement,
 14)
 15from ctfy.server.models.activity import (
 16    Activity,
 17    ActivityTimeseries,
 18    ActivityTimeseriesBucket,
 19)
 20from ctfy.server.models.admin import (
 21    AdminChallengeLastError,
 22    AdminChallengeLatencyBucket,
 23    AdminChallengeStatsRow,
 24    AdminChallengeTimeseries,
 25    AdminChallengeTimeseriesBucket,
 26    AdminHealthFlags,
 27    AdminOverview,
 28    AdminOverviewCounts,
 29    AdminRecentError,
 30    AdminSilentChallenge,
 31    AdminSolveCell,
 32    AdminSolveMatrix,
 33    AdminSolveMatrixChallenge,
 34    AdminSolveMatrixTeam,
 35    AdminStuckInstance,
 36    AdminTimeseries,
 37    AdminTimeseriesBucket,
 38    AdminTrafficInstanceRow,
 39    AdminTrafficSummary,
 40    AdminTrafficTeamRow,
 41    AdminUnhealthyNode,
 42    PlatformSettingInfo,
 43    PlatformSettingPatch,
 44    QuestionAttemptResetInfo,
 45    QuestionAttemptResetRequest,
 46    ShellRecordingInfo,
 47)
 48from ctfy.server.models.announcement import (
 49    AnnouncementCreate,
 50    AnnouncementInfo,
 51    AnnouncementUpdate,
 52)
 53from ctfy.server.models.auth import (
 54    AuthTokenResponse,
 55    CreateFineGrainedTokenRequest,
 56    CreateFineGrainedTokenResponse,
 57    DeviceApproveRequest,
 58    DeviceCodeResponse,
 59    DeviceInfoResponse,
 60    DeviceTokenRequest,
 61    DeviceTokenResponse,
 62    LinkedIdentity,
 63    LinkStartResponse,
 64    LoginRequest,
 65    OAuthProviderInfo,
 66    PasswordAuthInfo,
 67    ProvidersResponse,
 68    RegisterRequest,
 69    ScopeCatalogEntry,
 70    SetPasswordRequest,
 71    TokenInfo,
 72    TokenScopesResponse,
 73)
 74from ctfy.server.models.challenge import (
 75    AttachmentList,
 76    ChallengeBuildKickoffNodeResult,
 77    ChallengeBuildKickoffResponse,
 78    ChallengeBuildNodeState,
 79    ChallengeBuildStateResponse,
 80    ChallengeBuildStateRow,
 81    ChallengeFacetCount,
 82    ChallengeFacets,
 83    ChallengeFlagStats,
 84    ChallengeInfo,
 85    ChallengeRescanNodeResult,
 86    ChallengeRescanResult,
 87    ChallengeSolveAttempt,
 88    ChallengeSolveAttemptsResponse,
 89    ChallengeStats,
 90    ChallengeTeamSolveSummary,
 91    CompetitionChallengeBreakdown,
 92    CompetitionChallengeRow,
 93    CompetitionScoreDistribution,
 94    CompetitionScoreHistory,
 95    MilestoneProgress,
 96    MySolveSummary,
 97    QaChallengeView,
 98    QaSubmissionCreate,
 99    QuestionPublicInfo,
100    ScoreboardEntry,
101    ScoreBucket,
102    ScoreHistoryPoint,
103    ScoreHistorySeries,
104    SubmissionCreate,
105    SubmissionResponse,
106    UserScoreboardEntry,
107)
108from ctfy.server.models.competition import (
109    CompetitionAdminInfo,
110    CompetitionCreate,
111    CompetitionDetail,
112    CompetitionInfo,
113    CompetitionMembershipInfo,
114    CompetitionRegistrationInfo,
115    CompetitionUpdate,
116    GrantCompetitionAdminRequest,
117)
118from ctfy.server.models.feedback import (
119    AdminFeedbackRow,
120    FeedbackStats,
121    MyReactionsResponse,
122)
123from ctfy.server.models.instance import (
124    InstanceInfo,
125    InstanceQuestionInfo,
126    InstanceRecordArtifacts,
127    InstanceRecordDetail,
128    InstanceRecordInfo,
129    InstanceRecordInfoDetail,
130    InstanceStatusResponse,
131    RenewResponse,
132    StartRequest,
133    StartResponse,
134    StopResponse,
135    VerifyAnswerRequest,
136    VerifyAnswerResponse,
137)
138from ctfy.server.models.meta import (
139    ErrorResponse,
140    HealthResponse,
141    MetaChallenges,
142    MetaPlatform,
143    MetaResponse,
144)
145from ctfy.server.models.node import (
146    ClusterInfo,
147    CreateInviteRequest,
148    CreateInviteResponse,
149    NodeHeartbeat,
150    NodeInfo,
151    NodeInviteInfo,
152    NodePatch,
153    NodeRegister,
154    NodeRegisterResponse,
155)
156from ctfy.server.models.pagination import BigLimitOffsetPage
157from ctfy.server.models.team import (
158    InboxAnnouncement,
159    InboxCaptainRequest,
160    InboxIncomingInvite,
161    InboxOutgoingRequest,
162    InboxResponse,
163    TeamDetail,
164    TeamInfo,
165    TeamInviteInfo,
166    TeamMemberInfo,
167)
168from ctfy.server.models.user import (
169    PROFILE_VISIBILITY_KEYS,
170    AdminUserInfo,
171    CalendarBucket,
172    DeleteMeRequest,
173    DifficultyStat,
174    MeResponse,
175    ProfilePatchRequest,
176    ProfileStats,
177    TagStat,
178    TrendPoint,
179    UpdateUserRoleRequest,
180    UserInfo,
181    UserSolveTrend,
182)
183
184__all__ = [
185    "PROFILE_VISIBILITY_KEYS",
186    "AchievementCatalogEntry",
187    "AchievementProgress",
188    "AchievementSummary",
189    "Activity",
190    "ActivityTimeseries",
191    "ActivityTimeseriesBucket",
192    "AdminChallengeLastError",
193    "AdminChallengeLatencyBucket",
194    "AdminChallengeStatsRow",
195    "AdminChallengeTimeseries",
196    "AdminChallengeTimeseriesBucket",
197    "AdminFeedbackRow",
198    "AdminHealthFlags",
199    "AdminOverview",
200    "AdminOverviewCounts",
201    "AdminRecentError",
202    "AdminSilentChallenge",
203    "AdminSolveCell",
204    "AdminSolveMatrix",
205    "AdminSolveMatrixChallenge",
206    "AdminSolveMatrixTeam",
207    "AdminStuckInstance",
208    "AdminTimeseries",
209    "AdminTimeseriesBucket",
210    "AdminTrafficInstanceRow",
211    "AdminTrafficSummary",
212    "AdminTrafficTeamRow",
213    "AdminUnhealthyNode",
214    "AdminUserInfo",
215    "AnnouncementCreate",
216    "AnnouncementInfo",
217    "AnnouncementUpdate",
218    "AttachmentList",
219    "AuthTokenResponse",
220    "BigLimitOffsetPage",
221    "CalendarBucket",
222    "ChallengeBuildKickoffNodeResult",
223    "ChallengeBuildKickoffResponse",
224    "ChallengeBuildNodeState",
225    "ChallengeBuildStateResponse",
226    "ChallengeBuildStateRow",
227    "ChallengeFacetCount",
228    "ChallengeFacets",
229    "ChallengeFlagStats",
230    "ChallengeInfo",
231    "ChallengeRescanNodeResult",
232    "ChallengeRescanResult",
233    "ChallengeSolveAttempt",
234    "ChallengeSolveAttemptsResponse",
235    "ChallengeStats",
236    "ChallengeTeamSolveSummary",
237    "ClusterInfo",
238    "CompetitionAdminInfo",
239    "CompetitionChallengeBreakdown",
240    "CompetitionChallengeRow",
241    "CompetitionCreate",
242    "CompetitionDetail",
243    "CompetitionInfo",
244    "CompetitionMembershipInfo",
245    "CompetitionRegistrationInfo",
246    "CompetitionScoreDistribution",
247    "CompetitionScoreHistory",
248    "CompetitionUpdate",
249    "CreateFineGrainedTokenRequest",
250    "CreateFineGrainedTokenResponse",
251    "CreateInviteRequest",
252    "CreateInviteResponse",
253    "DeleteMeRequest",
254    "DeviceApproveRequest",
255    "DeviceCodeResponse",
256    "DeviceInfoResponse",
257    "DeviceTokenRequest",
258    "DeviceTokenResponse",
259    "DifficultyStat",
260    "EasterEggClaim",
261    "ErrorResponse",
262    "FeedbackStats",
263    "GrantCompetitionAdminRequest",
264    "HealthResponse",
265    "InboxAnnouncement",
266    "InboxCaptainRequest",
267    "InboxIncomingInvite",
268    "InboxOutgoingRequest",
269    "InboxResponse",
270    "InstanceInfo",
271    "InstanceQuestionInfo",
272    "InstanceRecordArtifacts",
273    "InstanceRecordDetail",
274    "InstanceRecordInfo",
275    "InstanceRecordInfoDetail",
276    "InstanceStatusResponse",
277    "LinkStartResponse",
278    "LinkedIdentity",
279    "LoginRequest",
280    "MeResponse",
281    "MetaChallenges",
282    "MetaPlatform",
283    "MetaResponse",
284    "MilestoneProgress",
285    "MyAchievementsResponse",
286    "MyReactionsResponse",
287    "MySolveSummary",
288    "NodeHeartbeat",
289    "NodeInfo",
290    "NodeInviteInfo",
291    "NodePatch",
292    "NodeRegister",
293    "NodeRegisterResponse",
294    "OAuthProviderInfo",
295    "PasswordAuthInfo",
296    "PlatformSettingInfo",
297    "PlatformSettingPatch",
298    "ProfilePatchRequest",
299    "ProfileStats",
300    "ProvidersResponse",
301    "QaChallengeView",
302    "QaSubmissionCreate",
303    "QuestionAttemptResetInfo",
304    "QuestionAttemptResetRequest",
305    "QuestionPublicInfo",
306    "RecentUnlock",
307    "RegisterRequest",
308    "RenewResponse",
309    "ScopeCatalogEntry",
310    "ScoreBucket",
311    "ScoreHistoryPoint",
312    "ScoreHistorySeries",
313    "ScoreboardEntry",
314    "SetPasswordRequest",
315    "ShellRecordingInfo",
316    "StarGazerVerifyResponse",
317    "StartRequest",
318    "StartResponse",
319    "StopResponse",
320    "SubmissionCreate",
321    "SubmissionResponse",
322    "TagStat",
323    "TeamAchievement",
324    "TeamDetail",
325    "TeamInfo",
326    "TeamInviteInfo",
327    "TeamMemberInfo",
328    "TokenInfo",
329    "TokenScopesResponse",
330    "TrendPoint",
331    "UpdateUserRoleRequest",
332    "UserInfo",
333    "UserScoreboardEntry",
334    "UserSolveTrend",
335    "VerifyAnswerRequest",
336    "VerifyAnswerResponse",
337]
PROFILE_VISIBILITY_KEYS = frozenset({'bio', 'email', 'rank_history', 'social_links', 'solves_count', 'activity_calendar', 'solve_trend', 'difficulty_stats', 'country', 'achievements', 'attempts_count', 'tag_stats', 'website_url', 'timezone', 'last_active_at'})
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    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 = ''
detail: ctfy.core.activity.ActivityDetail = PydanticUndefined
class ActivityTimeseries(ctfy.core.models.CtfyModel):
51class ActivityTimeseries(CtfyModel):
52    """Bucketed activity counts for the per-team activity histogram.
53
54    Same ``ts`` semantics as ``AdminTimeseries`` — oldest-first, right
55    edge anchored to "now". ``events`` is the sorted union of event
56    types that appear in any bucket, so the frontend has a stable list
57    of stack segments to render even when individual buckets are empty.
58    """
59
60    window_s: int = 0
61    bucket_s: int = 0
62    events: list[str] = Field(default_factory=list)
63    buckets: list[ActivityTimeseriesBucket] = Field(default_factory=list)

Bucketed activity counts for the per-team activity histogram.

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

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

One bucket of the team's activity histogram.

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

See AdminTimeseriesBucket for the partial semantics.

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 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

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
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):
119class CalendarBucket(CtfyModel):
120    """One UTC-day cell on the contribution calendar.
121
122    ``count`` aggregates correct submissions + first-time solves on that
123    day; effectively "did anything productive happen". The series is
124    dense — every day in the requested window is present, zero-filled."""
125
126    date: str = ""  # YYYY-MM-DD, UTC
127    count: int = 0

One UTC-day cell on the contribution calendar.

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

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):
239class ChallengeFlagStats(CtfyModel):
240    """Per-flag aggregate for one challenge (for the detail page)."""
241
242    flag_id: str
243    solves_count: int = 0

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

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 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):
260class ChallengeSolveAttempt(CtfyModel):
261    """One archived instance for a challenge — used to visualise per-team
262    multi-solve attempts on the challenge detail page."""
263
264    instance_id: str
265    team_id: str = ""
266    team_name: str = ""
267    display_name: str = ""
268    challenge_id: str = ""
269    started_at: float = 0.0
270    stopped_at: float = 0.0
271    duration_s: float = 0.0
272    attempts: int = 0
273    solved: bool = False
274    solved_flags: list[str] = Field(default_factory=list)
275    stop_reason: str = ""

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

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):
292class ChallengeSolveAttemptsResponse(CtfyModel):
293    challenge_id: str
294    attempts: list[ChallengeSolveAttempt] = Field(default_factory=list)
295    per_team: list[ChallengeTeamSolveSummary] = Field(default_factory=list)
296    # Total archived instances (incl. unsolved) — lets the UI show
297    # "showing N solved of M total" when the response is filtered.
298    total_attempts: int = 0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

challenge_id: str = PydanticUndefined
attempts: list[ChallengeSolveAttempt] = PydanticUndefined
per_team: list[ChallengeTeamSolveSummary] = PydanticUndefined
total_attempts: int = 0
class ChallengeStats(ctfy.core.models.CtfyModel):
246class ChallengeStats(CtfyModel):
247    challenge_id: str
248    name: str = ""
249    category: ChallengeCategory | None = None
250    difficulty: str = ""
251    # Teams that captured every declared flag on this challenge.
252    solves_count: int = 0
253    total_attempts: int = 0
254    success_rate: float = 0.0
255    # Per-flag breakdown. Single-flag challenges have a single entry with
256    # ``flag_id == "flag"`` and ``solves_count == solves_count`` above.
257    flag_stats: list[ChallengeFlagStats] = Field(default_factory=list)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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):
278class ChallengeTeamSolveSummary(CtfyModel):
279    """Per-team aggregate for the challenge detail solve-attempts panel."""
280
281    team_id: str
282    team_name: str = ""
283    display_name: str = ""
284    solve_count: int = 0
285    attempt_count: int = 0
286    best_duration_s: float = 0.0
287    total_duration_s: float = 0.0
288    first_solved_at: float = 0.0
289    last_solved_at: float = 0.0

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

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):
164class CompetitionAdminInfo(CtfyModel):
165    """One per-competition admin grant, for the super-admin management
166    card on the competition detail page."""
167
168    user_id: str
169    display_name: str = ""
170    email: str = ""
171    granted_by: str = ""
172    granted_at: datetime | None = None

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

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

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

challenges: list[CompetitionChallengeRow] = PydanticUndefined
generated_at: float = 0.0
class CompetitionChallengeRow(ctfy.core.models.CtfyModel):
486class CompetitionChallengeRow(CtfyModel):
487    """Per-challenge competition aggregate: solve count, attempt
488    success rate, and first blood. Reconstructed from the solve log
489    with the same window / team filter as the scoreboard so the
490    numbers always agree with the leaderboard."""
491
492    challenge_id: str
493    name: str = ""
494    category: ChallengeCategory | None = None
495    difficulty: str = ""
496    # Teams that captured every declared flag (matches scoreboard's
497    # ``solved`` tally).
498    solves_count: int = 0
499    # In-window submissions for this challenge across registered teams.
500    attempts: int = 0
501    success_rate: float = 0.0
502    # First team to solve this challenge in-window. Empty when unsolved.
503    first_blood_team_id: str = ""
504    first_blood_team_name: str = ""
505    first_blood_at: datetime | None = None

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

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    challenge_ids: list[str] = Field(default_factory=list, max_length=200)
87    # New competitions start hidden ("draft") so admins can prepare them
88    # privately and publish when ready.
89    status: Literal["draft", "published", "archived"] = "draft"
90
91    _norm_times = field_validator("starts_at", "ends_at", mode="before")(_blank_to_none)

Request body for POST /admin/competitions.

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

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'
class CompetitionDetail(ctfy.server.models.CompetitionInfo):
147class CompetitionDetail(CompetitionInfo):
148    """Full detail payload — includes resolved challenge summaries and
149    the caller's registration state when authenticated.
150    """
151
152    challenges: list[ChallengeInfo] = Field(default_factory=list)
153    registered_at: datetime | None = None

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

challenges: list[ChallengeInfo] = PydanticUndefined
registered_at: datetime.datetime | None = None
class CompetitionInfo(ctfy.core.models.CtfyModel):
107class CompetitionInfo(CtfyModel):
108    """Summary row used in the list view.
109
110    Per-viewer fields (``is_registered`` / ``my_*``) are populated only
111    when the request carries an authenticated user; for anonymous
112    requests they fall back to sentinels (empty strings, ``-1``) so
113    the response shape stays stable. Front-end treats ``my_rank < 0``
114    as "no value" rather than "rank zero".
115    """
116
117    id: str
118    title: str
119    description: str
120    starts_at: datetime | None = None
121    ends_at: datetime | None = None
122    created_at: datetime | None = None
123    updated_at: datetime | None = None
124    created_by: str
125    created_by_name: str
126    challenge_ids: list[str] = Field(default_factory=list)
127    # Visibility gate. Only ``published`` rows reach non-admins; admins
128    # and per-competition admins also receive ``draft`` / ``archived``.
129    status: Literal["draft", "published", "archived"] = "published"
130    # Server-derived projection. ``"upcoming"`` / ``"running"`` / ``"past"``
131    # — saves the frontend from re-implementing the same string-compare
132    # the backend already does.
133    phase: Literal["upcoming", "running", "past"] = "running"
134    # Total registered teams. Surfaced on cards so users can gauge
135    # popularity at a glance.
136    registered_count: int = 0
137    # Viewer-scoped fields — see class docstring.
138    is_registered: bool = False
139    my_team_id: str = ""
140    my_team_name: str = ""
141    my_role: Literal["captain", "member", ""] = ""
142    my_rank: int = -1
143    my_score: int = -1
144    my_solves: int = -1

Summary row used in the list view.

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

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'
phase: Literal['upcoming', 'running', 'past'] = 'running'
registered_count: int = 0
is_registered: bool = False
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 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):
156class CompetitionRegistrationInfo(CtfyModel):
157    """One row in the admin "who's registered" table."""
158
159    team_id: str
160    team_name: str = ""
161    registered_at: datetime | None = None

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

team_id: str = PydanticUndefined
team_name: str = ''
registered_at: datetime.datetime | None = None
class CompetitionScoreDistribution(ctfy.core.models.CtfyModel):
520class CompetitionScoreDistribution(CtfyModel):
521    """Aggregate companion to the (now server-paginated) competition
522    scoreboard: the score-distribution histogram + the true team count.
523
524    The standings table pages server-side, so the histogram / "N teams"
525    figure can't be re-derived from one page in the browser — this
526    bins every registered team's ``flags_solved`` with the same
527    adaptive scheme the frontend used to do client-side.
528    """
529
530    total_teams: int = 0
531    bins: list[ScoreBucket] = Field(default_factory=list)

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

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

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

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

series: list[ScoreHistorySeries] = PydanticUndefined
generated_at: float = 0.0
class CompetitionUpdate(ctfy.core.models.CtfyModel):
 94class CompetitionUpdate(CtfyModel):
 95    """PATCH body — every field optional, ``None`` means "leave alone"."""
 96
 97    title: str | None = Field(default=None, min_length=1, max_length=200)
 98    description: str | None = Field(default=None, max_length=20000)
 99    starts_at: datetime | None = None
100    ends_at: datetime | None = None
101    challenge_ids: list[str] | None = Field(default=None, max_length=200)
102    status: Literal["draft", "published", "archived"] | None = None
103
104    _norm_times = field_validator("starts_at", "ends_at", mode="before")(_blank_to_none)

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

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
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):
109class DeleteMeRequest(CtfyModel):
110    """Body for ``DELETE /me``. ``confirm_display_name`` must match the
111    caller's user display name exactly (case-sensitive) — the typo
112    gate that stops a fat-fingered click from cascading the account's
113    data away. (Falls back to email when display_name is empty.)
114    """
115
116    confirm_display_name: str = Field(min_length=1, max_length=120)

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

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

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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):
175class GrantCompetitionAdminRequest(CtfyModel):
176    """Body for ``PUT /admin/competitions/{id}/admins`` — resolve the
177    target user by id or (case-insensitive) email."""
178
179    user_id: str = ""
180    email: str = ""

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

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):
108class InboxAnnouncement(CtfyModel):
109    """Announcement projected onto the Inbox surface.
110
111    Carries the same fields as :class:`AnnouncementInfo` plus a
112    per-user ``is_read`` flag derived from the
113    ``announcement_reads`` table. The frontend renders unread rows
114    prominently and lets the user mark them read via
115    ``POST /me/announcements/{id}/read``.
116    """
117
118    id: str
119    title: str
120    body: str
121    severity: AnnouncementSeverity
122    starts_at: datetime | None = None
123    ends_at: datetime | None = None
124    created_at: datetime | None = None
125    updated_at: datetime | None = None
126    created_by: str
127    created_by_name: str
128    is_read: bool = False

Announcement projected onto the Inbox surface.

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

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    invite_id: str
 99    competition_id: str
100    competition_title: str
101    team_id: str
102    team_name: str
103    requester_user_id: str
104    requester_display_name: str
105    created_at: datetime | None = None

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

invite_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 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    invite_id: str
83    competition_id: str
84    competition_title: str
85    team_id: str
86    team_name: str
87    captain_user_id: str
88    captain_display_name: str
89    created_at: datetime | None = None

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

invite_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):
131class InboxResponse(CtfyModel):
132    """``GET /me/inbox`` payload — pending invites/requests +
133    actionable announcements grouped by the action the calling
134    user can take.
135    """
136
137    incoming_invites: list[InboxIncomingInvite] = Field(default_factory=list)
138    outgoing_requests: list[InboxOutgoingRequest] = Field(default_factory=list)
139    captain_requests: list[InboxCaptainRequest] = Field(default_factory=list)
140    # Site-wide announcements relevant right now (currently live + a
141    # short tail of recently-expired) with the user's read state.
142    announcements: list[InboxAnnouncement] = Field(default_factory=list)
143    # Convenience count so the sidebar can render an unread badge
144    # without re-iterating ``announcements``.
145    unread_announcement_count: int = 0

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

incoming_invites: list[InboxIncomingInvite] = PydanticUndefined
outgoing_requests: list[InboxOutgoingRequest] = PydanticUndefined
captain_requests: list[InboxCaptainRequest] = 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    identity_id: str
36    # "github" | "google" (OAuth) or "password" for a local credential.
37    provider: str
38    provider_email: str = ""
39    provider_display_name: str = ""
40    # Stable third-party account id (GitHub numeric id, Google `sub`);
41    # empty for the local password credential.
42    provider_user_id: str = ""
43    # GitHub `@handle`; empty for Google and password.
44    provider_login: str = ""
45    avatar_url: str = ""
46    created_at: datetime | None = None
47    last_used_at: datetime | None = None

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

identity_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):
409class MilestoneProgress(CtfyModel):
410    """Per-challenge milestone progress for the calling user.
411
412    Returned by ``GET /me/milestone-progress``. One row per challenge
413    with at least one captured question, regardless of whether the
414    challenge is fully solved. The Challenges list page renders a
415    progress bar from ``len(solved_question_ids) / total_questions``
416    so a player can see "2/5 milestones" instead of an all-or-nothing
417    solved badge.
418
419    Like ``MySolveSummary`` this is user-scoped: ``solved_question_ids``
420    aggregates every question id the user captured for the challenge
421    across every team they have ever been on. The optional
422    ``competition_id`` query param narrows the aggregation to solves
423    stamped against the user's team in that comp.
424    """
425
426    challenge_id: str
427    solved_question_ids: list[str] = Field(default_factory=list)
428    total_questions: int = 0

Per-challenge milestone progress for the calling user.

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

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

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):
393class MySolveSummary(CtfyModel):
394    """Per-challenge solve summary for the calling team.
395
396    Returned by ``GET /me/solves``. One row per challenge with at least
397    one captured flag. ``best_rank`` is the team's best (smallest) rank
398    across the flags they captured for the challenge — drives the
399    1血/2血/3血 badge on the challenge cards. Multi-flag challenges may
400    have different ranks per flag; reporting the best one gives players
401    credit for whichever piece they nailed first.
402    """
403
404    challenge_id: str
405    best_rank: int
406    solved_at: datetime | None = None

Per-challenge solve summary for the calling team.

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

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    node_id: str
70    running: int = 0
71    capacity: int = 50
72    cpu_percent: float = 0.0
73    memory_percent: float = 0.0
74    disk_percent: float = 0.0
75    # Unix seconds when the node sampled the metrics. The server stores
76    # ``received_at - sampled_at`` as one-way latency on the resulting
77    # health sample. Default 0 means "not measured" — older node builds
78    # that haven't been upgraded keep working.
79    sampled_at: float = 0.0

Body for POST /nodes/heartbeat.

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

node_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    node_token: str = ""

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

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

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

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

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

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

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

Aggregate per-team analytics surfaced on the public profile.

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

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):
367class QaChallengeView(CtfyModel):
368    """One QA challenge as seen by a team on the quiz surface.
369
370    Carries the full question / choices / description data alongside
371    per-team solve and attempt state, so the frontend can render the
372    quiz list with progress badges in a single round-trip.
373    """
374
375    id: str
376    name: str
377    # Provenance bucket prefix — ``SECQA`` / ``MMLU-CS`` / ``CTI-MCQ``
378    # / etc — derived from the challenge id (everything before the
379    # trailing numeric suffix). Lets the frontend group / filter
380    # without splitting the id again client-side.
381    bucket: str
382    description: str
383    difficulty: str = ""
384    tags: list[str] = Field(default_factory=list)
385    questions: list[QuestionPublicInfo] = Field(default_factory=list)
386    # Question ids the calling team has already captured.
387    solved_question_ids: list[str] = Field(default_factory=list)
388    # Question ids the team has submitted at least one wrong answer
389    # against but never solved. Drives the "Try again" amber badge.
390    attempted_wrong_question_ids: list[str] = Field(default_factory=list)

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

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

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):
350class QaSubmissionCreate(CtfyModel):
351    """Request body for ``POST /qa/submissions`` — the instance-free
352    submission path for pure question-answer challenges.
353
354    No ``instance_id``: QA challenges have no Docker infra to launch,
355    so there's no per-team instance row to read context off. The
356    caller passes ``challenge_id`` + ``competition_id`` explicitly,
357    and the server resolves the submitter's team for the named
358    competition via the standard auth helper.
359    """
360
361    challenge_id: str
362    competition_id: str
363    question_id: str = "answer"
364    answer: str | list[str]

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

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

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 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):
513class ScoreBucket(CtfyModel):
514    """One bucket of the scoreboard score-distribution histogram."""
515
516    label: str
517    count: int = 0

One bucket of the scoreboard score-distribution histogram.

label: str = PydanticUndefined
count: int = 0
class ScoreHistoryPoint(ctfy.core.models.CtfyModel):
459class ScoreHistoryPoint(CtfyModel):
460    """One sample on a team's score/rank-over-time curve.
461
462    Both metrics travel together so the frontend can toggle
463    Score ⇄ Rank without a refetch.
464    """
465
466    ts: float = 0.0  # Unix seconds (solve time of the driving event)
467    score: int = 0  # Cumulative flags captured up to ``ts``
468    rank: int = 0  # 1-based rank among scoring teams at ``ts``

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

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

ts: float = 0.0
score: int = 0
rank: int = 0
class ScoreHistorySeries(ctfy.core.models.CtfyModel):
471class ScoreHistorySeries(CtfyModel):
472    team_id: str
473    team_name: str
474    points: list[ScoreHistoryPoint] = Field(default_factory=list)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
points: list[ScoreHistoryPoint] = PydanticUndefined
class ScoreboardEntry(ctfy.core.models.CtfyModel):
436class ScoreboardEntry(CtfyModel):
437    rank: int = 0
438    team_id: str
439    team_name: str
440    # Challenges where the team captured every declared flag. Preserved
441    # for parity with the legacy single-flag scoreboard.
442    solved: int = 0
443    # Total individual flags captured across all challenges. Primary
444    # scoreboard metric now that multi-flag challenges exist — rewards
445    # partial progress (e.g. foothold without root).
446    flags_solved: int = 0
447    attempts: int = 0
448    last_solve_at: datetime | None = None
449    # Most recent activity of any kind. Distinct from ``last_solve_at`` —
450    # a team may be actively attempting without a solve, and the UI
451    # wants to show that they're not idle.
452    last_active_at: datetime | None = None
453    # OAuth provider avatar URL for the team's earliest identity that
454    # supplies one. Empty for password-only teams; the frontend falls
455    # back to a generated initials avatar in that case.
456    avatar_url: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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):
306class SubmissionCreate(CtfyModel):
307    # ``instance_id`` is the canonical handle since 0.2: the server
308    # reads challenge_id and competition_id off the instance row, so
309    # callers don't need to repeat them and N instances per
310    # (team, challenge) are unambiguous on submit.
311    instance_id: str
312    # Which question on the challenge the agent is answering. Defaults
313    # to ``"flag"`` so single-question challenges stay one-liner submits;
314    # multi-question challenges (see ``ChallengeInfo.questions``) must
315    # specify the id explicitly.
316    question_id: str = "flag"
317    # The agent's submission. String for ``dynamic`` / ``static`` /
318    # ``single_select`` questions; list of strings for ``multi_select``
319    # (e.g. ``["idor", "ssrf"]``). The server picks the grader by
320    # consulting ``spec.questions[i].mode`` on the live instance.
321    answer: str | list[str]

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

instance_id: str = PydanticUndefined
question_id: str = 'flag'
answer: str | list[str] = PydanticUndefined
class SubmissionResponse(ctfy.core.models.CtfyModel):
324class SubmissionResponse(CtfyModel):
325    id: str
326    correct: bool
327    solve_time_s: float = 0  # seconds from instance start to correct submission
328    # 1 = first blood, 2 = second, 3 = third, 4+ = regular solve.
329    # 0 when incorrect or when the team already had a prior correct solve.
330    # Ranks within the specific question — independent of the
331    # full-challenge solve rank.
332    solve_rank: int = 0
333    # The question id this submission targeted. Echoed back to the
334    # caller so simple submission UIs don't have to thread the id
335    # separately. Equal to the request's ``question_id``.
336    question_id: str | None = None
337    # True iff this team has now captured every question declared on
338    # the challenge. Single-question challenges: always equals ``correct``.
339    challenge_fully_solved: bool = False
340    # Remaining wrong-attempt budget for the question this submission
341    # targeted, AFTER this attempt is counted (the cap is on wrongs;
342    # correct submissions don't consume budget). ``None`` when the
343    # question's mode is uncapped (dynamic free-form, or any mode the
344    # operator opted out of via ``question_attempt_caps``). The UI
345    # uses this to decrement its remaining-attempts badge without
346    # re-fetching the instance — see ``ctfy.server.submission_policy``.
347    attempts_remaining: int | None = None

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

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

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):
148class TeamInviteInfo(CtfyModel):
149    """Public projection of a :class:`TeamInviteState`.
150
151    Surfaces on captain CRUD. ``active`` is server-derived from the
152    expires/use/revoked triple. ``kind`` discriminates code-style
153    invites from directed invites/requests; ``target_user_id`` is
154    the recipient for ``kind="direct"`` (set at mint time) and the
155    requester for ``kind="join_request"`` (set when a member opens
156    a request).
157    """
158
159    id: str
160    code: str
161    team_id: str
162    created_by_user_id: str
163    created_at: datetime | None = None
164    expires_at: datetime | None = None
165    max_uses: int
166    use_count: int
167    revoked_at: datetime | None = None
168    active: bool = True
169    competition_id: str = ""
170    kind: Literal["code", "direct", "join_request"] = "code"
171    target_user_id: str = ""

Public projection of a TeamInviteState.

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

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

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

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

token_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):
149class TrendPoint(CtfyModel):
150    """One UTC-day bucket on the solve-trend line."""
151
152    date: str = ""  # YYYY-MM-DD, UTC
153    solves: int = 0

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

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

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

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

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):
534class UserScoreboardEntry(CtfyModel):
535    """One row on the user-ranked global scoreboard.
536
537    Aggregates a single user's solves across every team they've
538    ever been on (each ``SolveState`` carries both ``user_id`` and
539    ``team_id``, so per-user totals are a straight filter over the
540    global solve table). Excludes users with zero solves to keep
541    the public list compact.
542    """
543
544    rank: int = 0
545    user_id: str
546    display_name: str
547    avatar_url: str = ""
548    country: str = ""
549    # Total flags solved across all teams the user has ever been on.
550    flags_solved: int = 0
551    # Challenges fully solved (every declared flag captured).
552    solved: int = 0
553    last_solve_at: datetime | None = None

One row on the user-ranked global scoreboard.

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

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

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

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

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