ctfy.sdk.resources.authoring

client.authoring — uploading a challenge you wrote.

For the competition format where entrants set challenges for each other. The upload is a .tar.gz of one benchmark directory, exactly the shape tar czf mine.tgz mychallenge/ produces — the wrapping directory is stripped server-side, so an author never has to know whether to include it.

⚠️ The server records the upload pending and does not judge it: a verdict means running the audit and a smoke test on a worker, which takes minutes. review_status on the returned row is what to poll.

  1"""``client.authoring`` — uploading a challenge you wrote.
  2
  3For the competition format where entrants set challenges for each other.
  4The upload is a ``.tar.gz`` of one benchmark directory, exactly the
  5shape ``tar czf mine.tgz mychallenge/`` produces — the wrapping
  6directory is stripped server-side, so an author never has to know
  7whether to include it.
  8
  9⚠️ The server records the upload ``pending`` and does not judge it: a
 10verdict means running the audit and a smoke test on a worker, which
 11takes minutes. ``review_status`` on the returned row is what to poll.
 12"""
 13
 14from __future__ import annotations
 15
 16import builtins
 17import time
 18
 19import httpx
 20
 21from ctfy.challenge.benchmark import ChallengeCategory
 22from ctfy.challenge.scaffold import Stack
 23from ctfy.core.challenge_stats import Audience
 24from ctfy.core.enums import (
 25    AuthoredAiAssistanceState,
 26    AuthoredCredit,
 27    AuthoredDestination,
 28    AuthoredLicenseState,
 29    AuthoredProvenanceState,
 30)
 31from ctfy.sdk._helpers import _raise_for_status
 32from ctfy.sdk.base import BaseHttpClient
 33from ctfy.server.models import (
 34    AuthoredAuditReport,
 35    AuthoredChallengeInfo,
 36    AuthoredChallengeReception,
 37    AuthoredChallengeRow,
 38    AuthoredCheckReport,
 39    AuthoredFlow,
 40    AuthoredFlowGraph,
 41    AuthoredRevisionInfo,
 42    AuthoredSmokeLive,
 43    AuthoredSmokeLog,
 44    AuthoredSmokeLogSummary,
 45    AuthoredSmokeStepLines,
 46    AuthoredUploadAccepted,
 47    AuthoredVersionDiff,
 48    AuthoredVersionMessage,
 49    AuthoredVersionRow,
 50    AuthoringPauseInfo,
 51    AuthoringQuotaInfo,
 52    AuthoringReceptionInfo,
 53    AuthoringTemplateManifest,
 54    AuthorModelReviewInfo,
 55    AuthorProbeInfo,
 56    ChallengeReceptionStats,
 57    PrecheckRunsInfo,
 58    TeamAuthoringAgreementStatus,
 59)
 60
 61
 62class AuthoringResource:
 63    """Upload and list the calling team's authored challenges."""
 64
 65    def __init__(self, http: BaseHttpClient) -> None:
 66        self._http = http
 67
 68    def _settled(self, competition_id: str, resp: httpx.Response) -> httpx.Response:
 69        """The archive door's own answer, waiting out a 202 (#2646).
 70
 71        A door that has not finished checking and storing the archive within
 72        its deadline answers 202 with a job; the job's poll answers exactly
 73        what the door would have — the row, or the refusal. A front that
 74        times the request (Cloudflare's 524) never sees the wait.
 75        """
 76        while resp.status_code == 202:
 77            accepted = AuthoredUploadAccepted.model_validate(resp.json())
 78            time.sleep(max(1, accepted.retry_after_s))
 79            resp = self._http.request(
 80                "GET", f"/competitions/{competition_id}/authored-uploads/{accepted.id}"
 81            )
 82        return resp
 83
 84    def upload(
 85        self,
 86        competition_id: str,
 87        *,
 88        slug: str,
 89        archive: bytes,
 90        filename: str = "challenge.tar.gz",
 91        license: AuthoredLicenseState = "",
 92        provenance: AuthoredProvenanceState = "",
 93        source_url: str = "",
 94        ai_assisted: AuthoredAiAssistanceState = "",
 95        eval_use: bool = False,
 96        credit: AuthoredCredit = "anonymous",
 97        accepted_terms_version: str = "",
 98        destination: AuthoredDestination = "public",
 99        note: str = "",
100        replaces: str = "",
101    ) -> AuthoredChallengeInfo:
102        """Upload one challenge tree.
103
104        ``slug`` is your own label for the upload, never its id: the
105        platform mints the benchmark id (``AUTHORED-<hex>``) and answers
106        with it on the returned row. The label is normalised and refused
107        rather than sanitised if it cannot be one.
108
109        Re-uploading the same slug replaces the tree of a **pending or
110        rejected** upload under the same id; an approved upload takes a
111        revision instead (:meth:`revise`).
112
113        ``replaces`` (#3109) names one of your own uploads in this
114        competition, by id or slug, and makes this upload a new version of it
115        rather than a new challenge, whatever ``slug`` says (``slug`` becomes
116        its name). Refused ``authored_replaces_unknown`` when it is not yours
117        here, ``authored_replaces_conflict`` when ``slug`` already names a
118        different upload of yours, and ``authored_challenge_already_reviewed``
119        for an approved one. ``same_title`` on the returned row is the
120        duplicate warning: this upload minted a **new** challenge and these of
121        yours here carry the same title.
122
123        The contributor agreement: when the competition names one
124        (``authoring_terms_version`` / ``authoring_terms_url`` on its
125        detail), pass that version as ``accepted_terms_version`` and
126        answer ``license``, ``provenance`` (with ``source_url`` for a
127        ``port``) and ``ai_assisted``. ``eval_use`` and ``credit`` are
128        grants, refused unless given.
129
130        ``destination`` is where it goes once published (plan P3.4): the
131        public catalog, or ``eval_only`` — the evaluation pool, kept out of
132        every public listing and launched only through a competition that
133        is not public and curates it. The pool takes an original challenge
134        (``provenance="original"``) whose author granted ``eval_use``;
135        AI-assisted is allowed, and marked on the evaluation board.
136
137        The account must hold a verified email address, on every
138        deployment: an unproved one is refused
139        ``authoring_requires_verified_email`` before the archive is read.
140
141        ``auto_smoke`` on the returned row says whether the deployment is
142        smoking the upload on an isolated worker (``queued`` — the verdict
143        lands on the listing's ``smoke_verdict``) and, if not, why:
144        ``budget_spent`` carries when the next run is free. ``None`` is a
145        server that did not say, never *not smoked*.
146
147        ``note`` is your own line on what changed, kept on the version this
148        upload stores (#2037): every stored tree is a version (v1, v2, …),
149        and an identical tree stores none (:meth:`versions`).
150
151        ⚠️ **Only what you name is sent.** An absent declaration is
152        *nobody said* on the platform, and putting ``license=`` or
153        ``eval_use=false`` on the wire for a caller that chose neither
154        would send a value nobody picked.
155        """
156        declared: dict[str, str] = {
157            name: value
158            for name, value in (
159                ("license", license),
160                ("provenance", provenance),
161                ("source_url", source_url),
162                ("ai_assisted", ai_assisted),
163                ("accepted_terms_version", accepted_terms_version),
164                ("note", note),
165                ("replaces", replaces),
166            )
167            if value
168        }
169        if eval_use:
170            declared["eval_use"] = "true"
171        if credit != "anonymous":
172            declared["credit"] = credit
173        if destination != "public":
174            declared["destination"] = destination
175        resp = self._http.request(
176            "POST",
177            f"/competitions/{competition_id}/authored-challenges",
178            data={"slug": slug, **declared},
179            files={"file": (filename, archive, "application/gzip")},
180        )
181        resp = self._settled(competition_id, resp)
182        _raise_for_status(resp)
183        return AuthoredChallengeInfo.model_validate(resp.json())
184
185    def check(
186        self,
187        competition_id: str,
188        *,
189        archive: bytes,
190        slug: str = "",
191        filename: str = "challenge.tar.gz",
192        license: AuthoredLicenseState = "",
193        provenance: AuthoredProvenanceState = "",
194        source_url: str = "",
195        ai_assisted: AuthoredAiAssistanceState = "",
196        eval_use: bool = False,
197        accepted_terms_version: str = "",
198        destination: AuthoredDestination = "public",
199    ) -> AuthoredCheckReport:
200        """Check a tree against every static rule of :meth:`upload`, **without
201        uploading it** (#1965).
202
203        Same fields as :meth:`upload`, answered all at once in ``problems``
204        (each with the code and coded ``refusals`` the upload would answer),
205        beside the advisory ``audit``. ``ok`` means an upload of these bytes
206        with these fields is not refused for a static reason. Nothing is
207        stored, no smoke quota is charged and nothing counts toward the
208        rejection pause. ``slug`` names your own pending upload a real
209        upload would replace, so it is not called a copy of itself.
210        """
211        declared: dict[str, str] = {
212            name: value
213            for name, value in (
214                ("slug", slug),
215                ("license", license),
216                ("provenance", provenance),
217                ("source_url", source_url),
218                ("ai_assisted", ai_assisted),
219                ("accepted_terms_version", accepted_terms_version),
220            )
221            if value
222        }
223        if eval_use:
224            declared["eval_use"] = "true"
225        if destination != "public":
226            declared["destination"] = destination
227        resp = self._http.request(
228            "POST",
229            f"/competitions/{competition_id}/authored-challenges/check",
230            data=declared,
231            files={"file": (filename, archive, "application/gzip")},
232        )
233        resp = self._settled(competition_id, resp)
234        _raise_for_status(resp)
235        return AuthoredCheckReport.model_validate(resp.json())
236
237    def revise(
238        self,
239        competition_id: str,
240        challenge_id: str,
241        *,
242        archive: bytes,
243        filename: str = "challenge.tar.gz",
244        note: str = "",
245    ) -> AuthoredRevisionInfo:
246        """Propose a new tree for one of your **approved** uploads (plan P3.1).
247
248        Any of the upload's recorded authors may. The revision is reviewed
249        as a diff against the live version and a platform admin applies
250        it; until then players keep the live one, and boxes already
251        running keep it until they expire. A second proposal replaces the
252        first, whatever its verdict — the listing's ``revision`` says
253        where the current one stands.
254
255        Refused ``authored_revision_drops_questions`` when the tree drops a
256        question the live version has (solves are recorded against them),
257        ``authored_revision_unchanged`` for an identical tree, and
258        ``authored_challenge_not_revisable`` for an upload that is not
259        approved, and ``authoring_closed`` once the event's last authoring
260        window has shut or the event is over, whoever reviews uploads
261        there (#2268).
262
263        ``note`` is your own line on what changed, kept on the version the
264        revision is stored as (#2037).
265        """
266        resp = self._http.request(
267            "POST",
268            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/revisions",
269            data={"note": note} if note else None,
270            files={"file": (filename, archive, "application/gzip")},
271        )
272        resp = self._settled(competition_id, resp)
273        _raise_for_status(resp)
274        return AuthoredRevisionInfo.model_validate(resp.json())
275
276    def delete(self, competition_id: str, challenge_id: str) -> None:
277        """Delete one of your uploads that was **never used** (#2740 D1).
278
279        Never used: its files were never put on the platform, it was never
280        opened to everyone and it never entered a competition. For good —
281        the challenge and every version of it are deleted.
282
283        Refused ``authored_challenge_not_deletable`` (409) for one that was
284        used, or while its smoke run is in flight; ``authored_challenge_not_found``
285        (404) for one that is not yours.
286        """
287        resp = self._http.request(
288            "DELETE", f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
289        )
290        _raise_for_status(resp)
291
292    def template(
293        self,
294        *,
295        category: ChallengeCategory = ChallengeCategory.WEB,
296        stack: Stack | None = None,
297        competition_id: str = "",
298    ) -> bytes:
299        """A starter challenge tree as ``.tar.gz`` bytes.
300
301        The same tree ``ctfy authoring init`` writes and the authoring
302        page downloads — all three call the server's own
303        ``scaffold_authored``, so a template fixed on one path cannot be
304        stale on another.
305
306        The reference solution the tree ships runs under a published,
307        language-neutral contract (``AUTHORING.md`` → *The reference
308        solution and its environment*): the target arrives as the
309        arguments ``<host> <port>`` and as ``CTFY_TARGET_HOST`` /
310        ``CTFY_TARGET_PORT``; it prints ``Q[<question_id>]: <value>`` on
311        stdout and exits 0 / 1 / other; it runs non-root, read-only, on
312        the challenge network only. ``solution/exploit.py`` runs in the
313        ``ctfy-exploit-runner`` image — add a ``solution/Dockerfile`` to
314        bring another language.
315
316        ⚠️ **Bytes, not a model.** The route answers an archive; parsing
317        it here would mean this library deciding where an author's
318        files land, which is `_pack`'s job in the opposite direction and
319        the caller's here.
320
321        ⚠️ ``stack`` belongs to ``web`` (and ``udp`` to ``pwn``) — the server
322        refuses the pair on any other category and refuses ``web`` without one —
323        and it is **omitted** rather than sent empty, since the query
324        parameter is an enum and ``""`` is not a member.
325
326        ⚠️ ``competition_id`` is optional because the template contains
327        nothing an event owns, but it is *accepted*: ``authoring`` is a
328        competition-scoped category, so a ``selected`` fine-grained
329        token names its event here as it does on the upload.
330        """
331        params: dict[str, str] = {"category": category.value}
332        if stack is not None:
333            params["stack"] = stack.value
334        if competition_id:
335            params["competition_id"] = competition_id
336        resp = self._http.request("GET", "/authoring/template", params=params)
337        _raise_for_status(resp)
338        return bytes(resp.content)
339
340    def audit(self, competition_id: str, challenge_id: str) -> AuthoredAuditReport:
341        """What the challenge audit says about one of your uploads.
342
343        A defect list you can fix and re-upload against, without waiting
344        for a reviewer to read the tree and write the same findings out
345        by hand.
346
347        ⚠️ ``outcome`` is the field to read first: ``ok`` means the
348        audit ran and ``errors`` / ``warnings`` are its verdict, while
349        ``timed_out`` and ``failed`` mean it could not say — an empty
350        ``findings`` list is not by itself a clean bill.
351        """
352        resp = self._http.request(
353            "GET",
354            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/audit",
355        )
356        _raise_for_status(resp)
357        return AuthoredAuditReport.model_validate(resp.json())
358
359    def flow(self, competition_id: str, challenge_id: str) -> AuthoredFlow:
360        """Where one of your uploads is in its lifecycle (#2147).
361
362        ``state`` is one of the lifecycle's states (``uploaded`` →
363        ``smoking`` → ``pending`` → ``approved`` → ``published`` → ``live``,
364        and the ways off that path: ``smoke_failed``, ``refused``,
365        ``rejected``, ``regressed``, ``revision``, ``unpublished``).
366        ``transitions`` is what can happen from here, each with the roles
367        that may do it and ``allowed`` for *you* — a verdict is a reviewer's
368        and publishing a platform admin's, so those are listed, not yours.
369        ``log`` is every transition, oldest first; a reviewer is the review
370        group (``actor == "reviewer"``, no name), never a person.
371        """
372        resp = self._http.request(
373            "GET",
374            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/flow",
375        )
376        _raise_for_status(resp)
377        return AuthoredFlow.model_validate(resp.json())
378
379    def lifecycle(self) -> AuthoredFlowGraph:
380        """The lifecycle's whole definition (#2147): every state, and every
381        transition with the roles that may perform it — the table ``flow``'s
382        ``state`` and ``transitions`` are derived from."""
383        resp = self._http.request("GET", "/authoring/lifecycle")
384        _raise_for_status(resp)
385        return AuthoredFlowGraph.model_validate(resp.json())
386
387    def smoke_logs(
388        self, competition_id: str, challenge_id: str
389    ) -> builtins.list[AuthoredSmokeLogSummary]:
390        """The smoke runs kept for one of your uploads, newest first (#2024).
391
392        Every run the platform made of the tree — at upload, on a
393        reviewer's press, the periodic re-check, a revision's — up to the
394        newest ten; the list is the whole set, there is no page. Read
395        ``status`` first: ``stored`` / ``truncated`` hold steps,
396        ``not_reported`` is a worker too old to send them, and
397        ``platform_fault`` is a run the platform could not carry out
398        (``problem`` says why) — never something wrong with your tree.
399        ``current`` is ``False`` for a run of an earlier upload.
400        """
401        resp = self._http.request(
402            "GET",
403            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/smoke-logs",
404        )
405        _raise_for_status(resp)
406        return [AuthoredSmokeLogSummary.model_validate(row) for row in resp.json()]
407
408    def smoke_log(self, competition_id: str, challenge_id: str, log_id: str) -> AuthoredSmokeLog:
409        """One smoke run of one of your uploads, step by step (#2024).
410
411        ``steps`` in order: ``build`` (per service of your compose file),
412        ``up``, ``health`` (per service, with its container's log),
413        ``reach`` (the player's way in), ``exploit`` (your
414        ``solution/exploit.py``'s output and exit code), and ``grade`` per
415        question — ``expected``, ``emitted``, ``matched``. ``output`` is the
416        tail of what your own build, container or exploit printed, scrubbed
417        of the worker's addresses, paths and secrets.
418
419        ``step_logs`` names the steps whose **full** output was kept (#2145;
420        a run on a worker too old to stream, or one nobody watched, has
421        none): read one with :meth:`smoke_run_lines`, passing this log's
422        ``run_id`` and the step's ``key``. ``timing`` says when the run
423        waited, started and ended.
424        """
425        resp = self._http.request(
426            "GET",
427            f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
428            f"/smoke-logs/{log_id}",
429        )
430        _raise_for_status(resp)
431        return AuthoredSmokeLog.model_validate(resp.json())
432
433    def smoke_live(self, competition_id: str, challenge_id: str) -> AuthoredSmokeLive:
434        """The smoke run of one of your uploads that is queued or running now (#2145).
435
436        ``state`` is ``none`` (nothing in flight), ``queued`` (``queue`` has
437        your place among the waiting runs and ``eta_s``, ``None`` when it
438        cannot be told) or ``running`` (``steps`` are the ones that have begun,
439        each with its ``lines`` count). ``run_id`` names the run for
440        :meth:`smoke_run_lines`; once the run ends it is the ``run_id`` of its
441        stored log (:meth:`smoke_logs`).
442        """
443        resp = self._http.request(
444            "GET", f"/competitions/{competition_id}/authored-challenges/{challenge_id}/smoke-live"
445        )
446        _raise_for_status(resp)
447        return AuthoredSmokeLive.model_validate(resp.json())
448
449    def cancel_smoke(self, competition_id: str, challenge_id: str) -> AuthoredSmokeLive:
450        """Take back the smoke run of one of your uploads that is still waiting
451        for a node (#2145); returns :meth:`smoke_live` afterwards (``none``).
452
453        A run a node already holds is refused ``smoke_already_running`` (409)
454        and keeps going; nothing waiting is ``smoke_nothing_queued`` (409).
455        The charge the queued run took is given back.
456        """
457        resp = self._http.request(
458            "POST",
459            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/smoke-live/cancel",
460        )
461        _raise_for_status(resp)
462        return AuthoredSmokeLive.model_validate(resp.json())
463
464    def smoke_run_lines(
465        self,
466        competition_id: str,
467        challenge_id: str,
468        run_id: str,
469        key: str,
470        *,
471        before: int = 0,
472        after: int = 0,
473        limit: int = 200,
474    ) -> AuthoredSmokeStepLines:
475        """A window of one step's **full** output, running or kept (#2145).
476
477        *key* is a step's ``key`` from :meth:`smoke_live` or the log's
478        ``step_logs`` (``build:app``, ``exploit:``, ``grade:flag``). With no
479        cursor it is the newest *limit* lines; ``before=N`` the lines numbered
480        below ``N`` (read backwards by passing the first line's ``n``);
481        ``after=N`` the lines above it (follow a running step). A jump in the
482        line numbers is lines the platform's bound dropped: a step keeps its
483        first and last 10,000. Plain text your own process printed, scrubbed
484        of the worker's addresses, paths and secrets and of this run's minted
485        answers.
486        """
487        resp = self._http.request(
488            "GET",
489            f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
490            f"/smoke-runs/{run_id}/lines",
491            params={"step": key, "before": before, "after": after, "limit": limit},
492        )
493        _raise_for_status(resp)
494        return AuthoredSmokeStepLines.model_validate(resp.json())
495
496    def versions(self, competition_id: str, challenge_id: str) -> builtins.list[AuthoredVersionRow]:
497        """Every stored version of one of your uploads, newest first (#2037).
498
499        Exactly one is ``live`` — the tree a new box is built from; boxes
500        already running keep the tree they started with. ``pending`` is a
501        proposal waiting for review (:meth:`withdraw_version` takes it
502        back); ``superseded``, ``replaced``, ``withdrawn``, ``approved`` and
503        ``rejected`` are history. ``tree_stored`` is ``False`` for a version
504        the platform remembers without its files.
505        """
506        resp = self._http.request(
507            "GET", f"/competitions/{competition_id}/authored-challenges/{challenge_id}/versions"
508        )
509        _raise_for_status(resp)
510        return [AuthoredVersionRow.model_validate(row) for row in resp.json()]
511
512    def version_diff(
513        self, competition_id: str, challenge_id: str, *, from_version: str, to_version: str
514    ) -> AuthoredVersionDiff:
515        """What changed between two of your upload's versions (#2037): files
516        added, removed and changed, a text diff, and for a binary only its
517        sizes and execute bit. Your reviewers read the same diff."""
518        resp = self._http.request(
519            "GET",
520            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/versions/diff",
521            params={"from_version": from_version, "to_version": to_version},
522        )
523        _raise_for_status(resp)
524        return AuthoredVersionDiff.model_validate(resp.json())
525
526    def version_archive(self, competition_id: str, challenge_id: str, version_id: str) -> bytes:
527        """One version as ``.tar.gz`` bytes, wrapped in one directory and
528        with each file's execute bit, so it uploads again unchanged."""
529        resp = self._http.request(
530            "GET",
531            f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
532            f"/versions/{version_id}/archive",
533        )
534        _raise_for_status(resp)
535        return bytes(resp.content)
536
537    def version_messages(
538        self, competition_id: str, challenge_id: str, version_id: str
539    ) -> builtins.list[AuthoredVersionMessage]:
540        """The thread between you and the reviewers on one version (#2037),
541        oldest first. A rejection's note is its first message; a reviewer
542        writing as the event's review group has an empty ``display_name``."""
543        resp = self._http.request(
544            "GET",
545            f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
546            f"/versions/{version_id}/messages",
547        )
548        _raise_for_status(resp)
549        return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]
550
551    def post_version_message(
552        self, competition_id: str, challenge_id: str, version_id: str, body: str
553    ) -> builtins.list[AuthoredVersionMessage]:
554        """Reply to the reviewers on one version (#2037); they are mailed once
555        for it. Answers the whole thread."""
556        resp = self._http.request(
557            "POST",
558            f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
559            f"/versions/{version_id}/messages",
560            json={"body": body},
561        )
562        _raise_for_status(resp)
563        return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]
564
565    def withdraw_version(
566        self, competition_id: str, challenge_id: str, version_id: str
567    ) -> AuthoredVersionRow:
568        """Take back a version still waiting for review (#2037); its tree is
569        kept. Anything else is refused ``authored_version_not_withdrawable``."""
570        resp = self._http.request(
571            "POST",
572            f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
573            f"/versions/{version_id}/withdraw",
574        )
575        _raise_for_status(resp)
576        return AuthoredVersionRow.model_validate(resp.json())
577
578    def template_manifest(
579        self,
580        *,
581        category: ChallengeCategory = ChallengeCategory.WEB,
582        stack: Stack | None = None,
583        competition_id: str = "",
584    ) -> AuthoringTemplateManifest:
585        """What :meth:`template` would hand over, without downloading it.
586
587        The same tree, described rather than packed — so a caller can
588        show an author the files before spending the transfer, and the
589        two answers cannot disagree because the server builds one tree
590        for both routes.
591
592        ⚠️ The pairing rules are :meth:`template`'s, verbatim, and are
593        not restated here: the server refuses the same shapes with the
594        same message, which is what makes one refusal a menu rather
595        than three.
596        """
597        params: dict[str, str] = {"category": category.value}
598        if stack is not None:
599            params["stack"] = stack.value
600        if competition_id:
601            params["competition_id"] = competition_id
602        resp = self._http.request("GET", "/authoring/template/manifest", params=params)
603        _raise_for_status(resp)
604        return AuthoringTemplateManifest.model_validate(resp.json())
605
606    def list(self, competition_id: str) -> list[AuthoredChallengeRow]:
607        """The calling team's own uploads for one competition.
608
609        ⚠️ The team's, never the event's: before the release point a
610        list of everybody's challenge names is a preview of what the
611        field will have to solve.
612
613        ⚠️ **:class:`AuthoredChallengeRow`, never the ``Info`` the
614        upload answers with, and the difference is invisible without
615        this line.** That model adds ``files`` — the tree's paths — with
616        a ``default_factory``, so validating a listing row against it
617        *succeeds* and hands every caller ``files == []``: not a missing
618        field but a **claim that the upload has no files**, which is the
619        one sentence the two-model split exists to stop. Two live
620        readers would have said it — ``ctfy authoring list --json``
621        emits the key, and the MCP tool hands it to an agent —
622        and `test_sdk_openapi_parity` cannot see it, because that guard
623        asks whether a route is *reached* and never whether the answer
624        is *understood*.
625        """
626        resp = self._http.request("GET", f"/competitions/{competition_id}/authored-challenges")
627        _raise_for_status(resp)
628        return [AuthoredChallengeRow.model_validate(row) for row in resp.json()]
629
630    def reception(self, competition_id: str) -> AuthoringReceptionInfo:
631        """How this event's field fared against the challenges you wrote.
632
633        The other half of the board's authoring column: that one says
634        *how much*, this says which of your challenges earned it and how
635        close each landed to the band the curve pays for.
636
637        ⚠️ Read ``state`` per row before the numbers. ``priced`` is the
638        only one with a rate; ``not_curated`` means the organiser has
639        not put that challenge in this event's set, and ``no_field``
640        that your division is too small to produce a rate at all. Both
641        render as a blank cell and only one of them is about the
642        challenge.
643
644        ⚠️ ``scored`` is ``False`` on every competition that pays nothing
645        for authoring — neither its own rule (``peer_authoring``) nor a
646        published board (an ``authoring`` board, #1965 G) — with no rows:
647        not an error, just an event that does not pay for authoring.
648        """
649        resp = self._http.request("GET", f"/competitions/{competition_id}/authoring/reception")
650        _raise_for_status(resp)
651        return AuthoringReceptionInfo.model_validate(resp.json())
652
653    def agreement(self, competition_id: str) -> TeamAuthoringAgreementStatus:
654        """Whether your team's captain has accepted this event's contributor
655        agreement (#1965 B).
656
657        ``current`` is the team's acceptance of the version in force: an
658        upload that carries no ``accepted_terms_version`` of its own relies
659        on it — and under automatic review (``team_only``) every upload
660        must, so :meth:`upload` is refused 422
661        ``authoring_team_terms_not_accepted`` while it is ``None``.
662        ``previous`` is an older version's acceptance when the agreement has
663        moved on. Read-only on purpose: the captain accepts on the web, and
664        no token can (operator decision 6).
665        """
666        resp = self._http.request("GET", f"/competitions/{competition_id}/authoring/agreement")
667        _raise_for_status(resp)
668        return TeamAuthoringAgreementStatus.model_validate(resp.json())
669
670    def pause(self, competition_id: str) -> AuthoringPauseInfo:
671        """Whether your uploads to this event are paused after rejections.
672
673        ``scope`` is the rule the event chose (#1965 C3): ``platform`` counts
674        your rejections everywhere, ``competition`` only this event's, and
675        ``off`` none. ``paused_until`` is when uploads reopen, ``None`` when
676        they are open — the instant :meth:`upload`'s 429
677        ``authoring_cooldown`` names.
678        """
679        resp = self._http.request("GET", f"/competitions/{competition_id}/authoring/pause")
680        _raise_for_status(resp)
681        return AuthoringPauseInfo.model_validate(resp.json())
682
683    def quota(self, competition_id: str) -> AuthoringQuotaInfo:
684        """What you and your team have spent of this event's upload allowances.
685
686        Pending uploads against the team's cap, and the smoke runs left —
687        charged to ``person`` (the platform's rolling day), ``team`` (the
688        event's window quota under automatic review) or ``off``. Reads only;
689        it never spends a run to report one.
690
691        ``upload_window`` is the team's quota of **new** uploads in the
692        authoring window open now (``None`` = no per-window limit): ``used`` /
693        ``remaining`` of ``limit``, the window's bounds and ``next_window_at``.
694        A re-upload of an upload the team holds is never counted.
695        """
696        resp = self._http.request("GET", f"/competitions/{competition_id}/authoring/quota")
697        _raise_for_status(resp)
698        return AuthoringQuotaInfo.model_validate(resp.json())
699
700    def challenge_reception(
701        self, competition_id: str, challenge_id: str
702    ) -> AuthoredChallengeReception:
703        """How players have done against one of your uploads.
704
705        Teams that tried it, teams that finished it, the first finish and the
706        reaction chips. ``withheld`` is a live silent exercise: the numbers
707        are then ``None``, never ``0``.
708        """
709        resp = self._http.request(
710            "GET",
711            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/reception",
712        )
713        _raise_for_status(resp)
714        return AuthoredChallengeReception.model_validate(resp.json())
715
716    def model_review(self, competition_id: str, challenge_id: str) -> AuthorModelReviewInfo:
717        """The model's reading of one of your uploads: its verdict and its
718        findings, for the live version and a waiting revision (#3050).
719
720        Only where the upload's event shows it to its authors; elsewhere the
721        server answers ``403 authoring_model_review_withheld`` and this raises.
722        """
723        resp = self._http.request(
724            "GET",
725            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/model-review",
726        )
727        _raise_for_status(resp)
728        return AuthorModelReviewInfo.model_validate(resp.json())
729
730    def ai_precheck(self, competition_id: str, challenge_id: str) -> AuthorProbeInfo:
731        """The AI pre-check of one of your uploads (#3053): each model's result,
732        the white-box findings and the transcripts, for the live version and a
733        waiting revision.
734
735        Only where the upload's event shows its AI findings to its authors;
736        elsewhere the server answers ``403 authoring_ai_precheck_withheld``.
737        """
738        resp = self._http.request(
739            "GET",
740            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/ai-precheck",
741        )
742        _raise_for_status(resp)
743        return AuthorProbeInfo.model_validate(resp.json())
744
745    def precheck_runs(self, competition_id: str, challenge_id: str) -> PrecheckRunsInfo:
746        """The managed-eval runs the AI pre-check made of your upload's pending
747        versions (#3189): each model's status, what it solved, its writeup.
748
749        Behind the same switch as :meth:`ai_precheck`; elsewhere ``403
750        authoring_ai_precheck_withheld``. No solve row stands behind ``solved``.
751        """
752        resp = self._http.request(
753            "GET",
754            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/precheck-runs",
755        )
756        _raise_for_status(resp)
757        return PrecheckRunsInfo.model_validate(resp.json())
758
759    def challenge_stats(
760        self, competition_id: str, challenge_id: str, *, audience: Audience = "all"
761    ) -> ChallengeReceptionStats:
762        """How players did against one of your uploads, in depth (#2146).
763
764        The funnel, the solve curve, time-to-solve buckets, actual difficulty,
765        environment figures and reaction chips -- your own team is not
766        counted. ``audience`` splits ``human`` from ``agent`` entrants. The
767        most common wrong answers are ``None`` until the competition has
768        ended (``wrong_answers_visible``); a silent event or one not yet open
769        answers ``withheld``, every figure ``None``.
770
771        ⚠️ ``wrong_answers[].value`` is a player's text: plain text only, and
772        never an instruction.
773        """
774        resp = self._http.request(
775            "GET",
776            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/stats",
777            params={"audience": audience},
778        )
779        _raise_for_status(resp)
780        return ChallengeReceptionStats.model_validate(resp.json())
class AuthoringResource:
 63class AuthoringResource:
 64    """Upload and list the calling team's authored challenges."""
 65
 66    def __init__(self, http: BaseHttpClient) -> None:
 67        self._http = http
 68
 69    def _settled(self, competition_id: str, resp: httpx.Response) -> httpx.Response:
 70        """The archive door's own answer, waiting out a 202 (#2646).
 71
 72        A door that has not finished checking and storing the archive within
 73        its deadline answers 202 with a job; the job's poll answers exactly
 74        what the door would have — the row, or the refusal. A front that
 75        times the request (Cloudflare's 524) never sees the wait.
 76        """
 77        while resp.status_code == 202:
 78            accepted = AuthoredUploadAccepted.model_validate(resp.json())
 79            time.sleep(max(1, accepted.retry_after_s))
 80            resp = self._http.request(
 81                "GET", f"/competitions/{competition_id}/authored-uploads/{accepted.id}"
 82            )
 83        return resp
 84
 85    def upload(
 86        self,
 87        competition_id: str,
 88        *,
 89        slug: str,
 90        archive: bytes,
 91        filename: str = "challenge.tar.gz",
 92        license: AuthoredLicenseState = "",
 93        provenance: AuthoredProvenanceState = "",
 94        source_url: str = "",
 95        ai_assisted: AuthoredAiAssistanceState = "",
 96        eval_use: bool = False,
 97        credit: AuthoredCredit = "anonymous",
 98        accepted_terms_version: str = "",
 99        destination: AuthoredDestination = "public",
100        note: str = "",
101        replaces: str = "",
102    ) -> AuthoredChallengeInfo:
103        """Upload one challenge tree.
104
105        ``slug`` is your own label for the upload, never its id: the
106        platform mints the benchmark id (``AUTHORED-<hex>``) and answers
107        with it on the returned row. The label is normalised and refused
108        rather than sanitised if it cannot be one.
109
110        Re-uploading the same slug replaces the tree of a **pending or
111        rejected** upload under the same id; an approved upload takes a
112        revision instead (:meth:`revise`).
113
114        ``replaces`` (#3109) names one of your own uploads in this
115        competition, by id or slug, and makes this upload a new version of it
116        rather than a new challenge, whatever ``slug`` says (``slug`` becomes
117        its name). Refused ``authored_replaces_unknown`` when it is not yours
118        here, ``authored_replaces_conflict`` when ``slug`` already names a
119        different upload of yours, and ``authored_challenge_already_reviewed``
120        for an approved one. ``same_title`` on the returned row is the
121        duplicate warning: this upload minted a **new** challenge and these of
122        yours here carry the same title.
123
124        The contributor agreement: when the competition names one
125        (``authoring_terms_version`` / ``authoring_terms_url`` on its
126        detail), pass that version as ``accepted_terms_version`` and
127        answer ``license``, ``provenance`` (with ``source_url`` for a
128        ``port``) and ``ai_assisted``. ``eval_use`` and ``credit`` are
129        grants, refused unless given.
130
131        ``destination`` is where it goes once published (plan P3.4): the
132        public catalog, or ``eval_only`` — the evaluation pool, kept out of
133        every public listing and launched only through a competition that
134        is not public and curates it. The pool takes an original challenge
135        (``provenance="original"``) whose author granted ``eval_use``;
136        AI-assisted is allowed, and marked on the evaluation board.
137
138        The account must hold a verified email address, on every
139        deployment: an unproved one is refused
140        ``authoring_requires_verified_email`` before the archive is read.
141
142        ``auto_smoke`` on the returned row says whether the deployment is
143        smoking the upload on an isolated worker (``queued`` — the verdict
144        lands on the listing's ``smoke_verdict``) and, if not, why:
145        ``budget_spent`` carries when the next run is free. ``None`` is a
146        server that did not say, never *not smoked*.
147
148        ``note`` is your own line on what changed, kept on the version this
149        upload stores (#2037): every stored tree is a version (v1, v2, …),
150        and an identical tree stores none (:meth:`versions`).
151
152        ⚠️ **Only what you name is sent.** An absent declaration is
153        *nobody said* on the platform, and putting ``license=`` or
154        ``eval_use=false`` on the wire for a caller that chose neither
155        would send a value nobody picked.
156        """
157        declared: dict[str, str] = {
158            name: value
159            for name, value in (
160                ("license", license),
161                ("provenance", provenance),
162                ("source_url", source_url),
163                ("ai_assisted", ai_assisted),
164                ("accepted_terms_version", accepted_terms_version),
165                ("note", note),
166                ("replaces", replaces),
167            )
168            if value
169        }
170        if eval_use:
171            declared["eval_use"] = "true"
172        if credit != "anonymous":
173            declared["credit"] = credit
174        if destination != "public":
175            declared["destination"] = destination
176        resp = self._http.request(
177            "POST",
178            f"/competitions/{competition_id}/authored-challenges",
179            data={"slug": slug, **declared},
180            files={"file": (filename, archive, "application/gzip")},
181        )
182        resp = self._settled(competition_id, resp)
183        _raise_for_status(resp)
184        return AuthoredChallengeInfo.model_validate(resp.json())
185
186    def check(
187        self,
188        competition_id: str,
189        *,
190        archive: bytes,
191        slug: str = "",
192        filename: str = "challenge.tar.gz",
193        license: AuthoredLicenseState = "",
194        provenance: AuthoredProvenanceState = "",
195        source_url: str = "",
196        ai_assisted: AuthoredAiAssistanceState = "",
197        eval_use: bool = False,
198        accepted_terms_version: str = "",
199        destination: AuthoredDestination = "public",
200    ) -> AuthoredCheckReport:
201        """Check a tree against every static rule of :meth:`upload`, **without
202        uploading it** (#1965).
203
204        Same fields as :meth:`upload`, answered all at once in ``problems``
205        (each with the code and coded ``refusals`` the upload would answer),
206        beside the advisory ``audit``. ``ok`` means an upload of these bytes
207        with these fields is not refused for a static reason. Nothing is
208        stored, no smoke quota is charged and nothing counts toward the
209        rejection pause. ``slug`` names your own pending upload a real
210        upload would replace, so it is not called a copy of itself.
211        """
212        declared: dict[str, str] = {
213            name: value
214            for name, value in (
215                ("slug", slug),
216                ("license", license),
217                ("provenance", provenance),
218                ("source_url", source_url),
219                ("ai_assisted", ai_assisted),
220                ("accepted_terms_version", accepted_terms_version),
221            )
222            if value
223        }
224        if eval_use:
225            declared["eval_use"] = "true"
226        if destination != "public":
227            declared["destination"] = destination
228        resp = self._http.request(
229            "POST",
230            f"/competitions/{competition_id}/authored-challenges/check",
231            data=declared,
232            files={"file": (filename, archive, "application/gzip")},
233        )
234        resp = self._settled(competition_id, resp)
235        _raise_for_status(resp)
236        return AuthoredCheckReport.model_validate(resp.json())
237
238    def revise(
239        self,
240        competition_id: str,
241        challenge_id: str,
242        *,
243        archive: bytes,
244        filename: str = "challenge.tar.gz",
245        note: str = "",
246    ) -> AuthoredRevisionInfo:
247        """Propose a new tree for one of your **approved** uploads (plan P3.1).
248
249        Any of the upload's recorded authors may. The revision is reviewed
250        as a diff against the live version and a platform admin applies
251        it; until then players keep the live one, and boxes already
252        running keep it until they expire. A second proposal replaces the
253        first, whatever its verdict — the listing's ``revision`` says
254        where the current one stands.
255
256        Refused ``authored_revision_drops_questions`` when the tree drops a
257        question the live version has (solves are recorded against them),
258        ``authored_revision_unchanged`` for an identical tree, and
259        ``authored_challenge_not_revisable`` for an upload that is not
260        approved, and ``authoring_closed`` once the event's last authoring
261        window has shut or the event is over, whoever reviews uploads
262        there (#2268).
263
264        ``note`` is your own line on what changed, kept on the version the
265        revision is stored as (#2037).
266        """
267        resp = self._http.request(
268            "POST",
269            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/revisions",
270            data={"note": note} if note else None,
271            files={"file": (filename, archive, "application/gzip")},
272        )
273        resp = self._settled(competition_id, resp)
274        _raise_for_status(resp)
275        return AuthoredRevisionInfo.model_validate(resp.json())
276
277    def delete(self, competition_id: str, challenge_id: str) -> None:
278        """Delete one of your uploads that was **never used** (#2740 D1).
279
280        Never used: its files were never put on the platform, it was never
281        opened to everyone and it never entered a competition. For good —
282        the challenge and every version of it are deleted.
283
284        Refused ``authored_challenge_not_deletable`` (409) for one that was
285        used, or while its smoke run is in flight; ``authored_challenge_not_found``
286        (404) for one that is not yours.
287        """
288        resp = self._http.request(
289            "DELETE", f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
290        )
291        _raise_for_status(resp)
292
293    def template(
294        self,
295        *,
296        category: ChallengeCategory = ChallengeCategory.WEB,
297        stack: Stack | None = None,
298        competition_id: str = "",
299    ) -> bytes:
300        """A starter challenge tree as ``.tar.gz`` bytes.
301
302        The same tree ``ctfy authoring init`` writes and the authoring
303        page downloads — all three call the server's own
304        ``scaffold_authored``, so a template fixed on one path cannot be
305        stale on another.
306
307        The reference solution the tree ships runs under a published,
308        language-neutral contract (``AUTHORING.md`` → *The reference
309        solution and its environment*): the target arrives as the
310        arguments ``<host> <port>`` and as ``CTFY_TARGET_HOST`` /
311        ``CTFY_TARGET_PORT``; it prints ``Q[<question_id>]: <value>`` on
312        stdout and exits 0 / 1 / other; it runs non-root, read-only, on
313        the challenge network only. ``solution/exploit.py`` runs in the
314        ``ctfy-exploit-runner`` image — add a ``solution/Dockerfile`` to
315        bring another language.
316
317        ⚠️ **Bytes, not a model.** The route answers an archive; parsing
318        it here would mean this library deciding where an author's
319        files land, which is `_pack`'s job in the opposite direction and
320        the caller's here.
321
322        ⚠️ ``stack`` belongs to ``web`` (and ``udp`` to ``pwn``) — the server
323        refuses the pair on any other category and refuses ``web`` without one —
324        and it is **omitted** rather than sent empty, since the query
325        parameter is an enum and ``""`` is not a member.
326
327        ⚠️ ``competition_id`` is optional because the template contains
328        nothing an event owns, but it is *accepted*: ``authoring`` is a
329        competition-scoped category, so a ``selected`` fine-grained
330        token names its event here as it does on the upload.
331        """
332        params: dict[str, str] = {"category": category.value}
333        if stack is not None:
334            params["stack"] = stack.value
335        if competition_id:
336            params["competition_id"] = competition_id
337        resp = self._http.request("GET", "/authoring/template", params=params)
338        _raise_for_status(resp)
339        return bytes(resp.content)
340
341    def audit(self, competition_id: str, challenge_id: str) -> AuthoredAuditReport:
342        """What the challenge audit says about one of your uploads.
343
344        A defect list you can fix and re-upload against, without waiting
345        for a reviewer to read the tree and write the same findings out
346        by hand.
347
348        ⚠️ ``outcome`` is the field to read first: ``ok`` means the
349        audit ran and ``errors`` / ``warnings`` are its verdict, while
350        ``timed_out`` and ``failed`` mean it could not say — an empty
351        ``findings`` list is not by itself a clean bill.
352        """
353        resp = self._http.request(
354            "GET",
355            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/audit",
356        )
357        _raise_for_status(resp)
358        return AuthoredAuditReport.model_validate(resp.json())
359
360    def flow(self, competition_id: str, challenge_id: str) -> AuthoredFlow:
361        """Where one of your uploads is in its lifecycle (#2147).
362
363        ``state`` is one of the lifecycle's states (``uploaded`` →
364        ``smoking`` → ``pending`` → ``approved`` → ``published`` → ``live``,
365        and the ways off that path: ``smoke_failed``, ``refused``,
366        ``rejected``, ``regressed``, ``revision``, ``unpublished``).
367        ``transitions`` is what can happen from here, each with the roles
368        that may do it and ``allowed`` for *you* — a verdict is a reviewer's
369        and publishing a platform admin's, so those are listed, not yours.
370        ``log`` is every transition, oldest first; a reviewer is the review
371        group (``actor == "reviewer"``, no name), never a person.
372        """
373        resp = self._http.request(
374            "GET",
375            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/flow",
376        )
377        _raise_for_status(resp)
378        return AuthoredFlow.model_validate(resp.json())
379
380    def lifecycle(self) -> AuthoredFlowGraph:
381        """The lifecycle's whole definition (#2147): every state, and every
382        transition with the roles that may perform it — the table ``flow``'s
383        ``state`` and ``transitions`` are derived from."""
384        resp = self._http.request("GET", "/authoring/lifecycle")
385        _raise_for_status(resp)
386        return AuthoredFlowGraph.model_validate(resp.json())
387
388    def smoke_logs(
389        self, competition_id: str, challenge_id: str
390    ) -> builtins.list[AuthoredSmokeLogSummary]:
391        """The smoke runs kept for one of your uploads, newest first (#2024).
392
393        Every run the platform made of the tree — at upload, on a
394        reviewer's press, the periodic re-check, a revision's — up to the
395        newest ten; the list is the whole set, there is no page. Read
396        ``status`` first: ``stored`` / ``truncated`` hold steps,
397        ``not_reported`` is a worker too old to send them, and
398        ``platform_fault`` is a run the platform could not carry out
399        (``problem`` says why) — never something wrong with your tree.
400        ``current`` is ``False`` for a run of an earlier upload.
401        """
402        resp = self._http.request(
403            "GET",
404            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/smoke-logs",
405        )
406        _raise_for_status(resp)
407        return [AuthoredSmokeLogSummary.model_validate(row) for row in resp.json()]
408
409    def smoke_log(self, competition_id: str, challenge_id: str, log_id: str) -> AuthoredSmokeLog:
410        """One smoke run of one of your uploads, step by step (#2024).
411
412        ``steps`` in order: ``build`` (per service of your compose file),
413        ``up``, ``health`` (per service, with its container's log),
414        ``reach`` (the player's way in), ``exploit`` (your
415        ``solution/exploit.py``'s output and exit code), and ``grade`` per
416        question — ``expected``, ``emitted``, ``matched``. ``output`` is the
417        tail of what your own build, container or exploit printed, scrubbed
418        of the worker's addresses, paths and secrets.
419
420        ``step_logs`` names the steps whose **full** output was kept (#2145;
421        a run on a worker too old to stream, or one nobody watched, has
422        none): read one with :meth:`smoke_run_lines`, passing this log's
423        ``run_id`` and the step's ``key``. ``timing`` says when the run
424        waited, started and ended.
425        """
426        resp = self._http.request(
427            "GET",
428            f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
429            f"/smoke-logs/{log_id}",
430        )
431        _raise_for_status(resp)
432        return AuthoredSmokeLog.model_validate(resp.json())
433
434    def smoke_live(self, competition_id: str, challenge_id: str) -> AuthoredSmokeLive:
435        """The smoke run of one of your uploads that is queued or running now (#2145).
436
437        ``state`` is ``none`` (nothing in flight), ``queued`` (``queue`` has
438        your place among the waiting runs and ``eta_s``, ``None`` when it
439        cannot be told) or ``running`` (``steps`` are the ones that have begun,
440        each with its ``lines`` count). ``run_id`` names the run for
441        :meth:`smoke_run_lines`; once the run ends it is the ``run_id`` of its
442        stored log (:meth:`smoke_logs`).
443        """
444        resp = self._http.request(
445            "GET", f"/competitions/{competition_id}/authored-challenges/{challenge_id}/smoke-live"
446        )
447        _raise_for_status(resp)
448        return AuthoredSmokeLive.model_validate(resp.json())
449
450    def cancel_smoke(self, competition_id: str, challenge_id: str) -> AuthoredSmokeLive:
451        """Take back the smoke run of one of your uploads that is still waiting
452        for a node (#2145); returns :meth:`smoke_live` afterwards (``none``).
453
454        A run a node already holds is refused ``smoke_already_running`` (409)
455        and keeps going; nothing waiting is ``smoke_nothing_queued`` (409).
456        The charge the queued run took is given back.
457        """
458        resp = self._http.request(
459            "POST",
460            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/smoke-live/cancel",
461        )
462        _raise_for_status(resp)
463        return AuthoredSmokeLive.model_validate(resp.json())
464
465    def smoke_run_lines(
466        self,
467        competition_id: str,
468        challenge_id: str,
469        run_id: str,
470        key: str,
471        *,
472        before: int = 0,
473        after: int = 0,
474        limit: int = 200,
475    ) -> AuthoredSmokeStepLines:
476        """A window of one step's **full** output, running or kept (#2145).
477
478        *key* is a step's ``key`` from :meth:`smoke_live` or the log's
479        ``step_logs`` (``build:app``, ``exploit:``, ``grade:flag``). With no
480        cursor it is the newest *limit* lines; ``before=N`` the lines numbered
481        below ``N`` (read backwards by passing the first line's ``n``);
482        ``after=N`` the lines above it (follow a running step). A jump in the
483        line numbers is lines the platform's bound dropped: a step keeps its
484        first and last 10,000. Plain text your own process printed, scrubbed
485        of the worker's addresses, paths and secrets and of this run's minted
486        answers.
487        """
488        resp = self._http.request(
489            "GET",
490            f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
491            f"/smoke-runs/{run_id}/lines",
492            params={"step": key, "before": before, "after": after, "limit": limit},
493        )
494        _raise_for_status(resp)
495        return AuthoredSmokeStepLines.model_validate(resp.json())
496
497    def versions(self, competition_id: str, challenge_id: str) -> builtins.list[AuthoredVersionRow]:
498        """Every stored version of one of your uploads, newest first (#2037).
499
500        Exactly one is ``live`` — the tree a new box is built from; boxes
501        already running keep the tree they started with. ``pending`` is a
502        proposal waiting for review (:meth:`withdraw_version` takes it
503        back); ``superseded``, ``replaced``, ``withdrawn``, ``approved`` and
504        ``rejected`` are history. ``tree_stored`` is ``False`` for a version
505        the platform remembers without its files.
506        """
507        resp = self._http.request(
508            "GET", f"/competitions/{competition_id}/authored-challenges/{challenge_id}/versions"
509        )
510        _raise_for_status(resp)
511        return [AuthoredVersionRow.model_validate(row) for row in resp.json()]
512
513    def version_diff(
514        self, competition_id: str, challenge_id: str, *, from_version: str, to_version: str
515    ) -> AuthoredVersionDiff:
516        """What changed between two of your upload's versions (#2037): files
517        added, removed and changed, a text diff, and for a binary only its
518        sizes and execute bit. Your reviewers read the same diff."""
519        resp = self._http.request(
520            "GET",
521            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/versions/diff",
522            params={"from_version": from_version, "to_version": to_version},
523        )
524        _raise_for_status(resp)
525        return AuthoredVersionDiff.model_validate(resp.json())
526
527    def version_archive(self, competition_id: str, challenge_id: str, version_id: str) -> bytes:
528        """One version as ``.tar.gz`` bytes, wrapped in one directory and
529        with each file's execute bit, so it uploads again unchanged."""
530        resp = self._http.request(
531            "GET",
532            f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
533            f"/versions/{version_id}/archive",
534        )
535        _raise_for_status(resp)
536        return bytes(resp.content)
537
538    def version_messages(
539        self, competition_id: str, challenge_id: str, version_id: str
540    ) -> builtins.list[AuthoredVersionMessage]:
541        """The thread between you and the reviewers on one version (#2037),
542        oldest first. A rejection's note is its first message; a reviewer
543        writing as the event's review group has an empty ``display_name``."""
544        resp = self._http.request(
545            "GET",
546            f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
547            f"/versions/{version_id}/messages",
548        )
549        _raise_for_status(resp)
550        return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]
551
552    def post_version_message(
553        self, competition_id: str, challenge_id: str, version_id: str, body: str
554    ) -> builtins.list[AuthoredVersionMessage]:
555        """Reply to the reviewers on one version (#2037); they are mailed once
556        for it. Answers the whole thread."""
557        resp = self._http.request(
558            "POST",
559            f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
560            f"/versions/{version_id}/messages",
561            json={"body": body},
562        )
563        _raise_for_status(resp)
564        return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]
565
566    def withdraw_version(
567        self, competition_id: str, challenge_id: str, version_id: str
568    ) -> AuthoredVersionRow:
569        """Take back a version still waiting for review (#2037); its tree is
570        kept. Anything else is refused ``authored_version_not_withdrawable``."""
571        resp = self._http.request(
572            "POST",
573            f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
574            f"/versions/{version_id}/withdraw",
575        )
576        _raise_for_status(resp)
577        return AuthoredVersionRow.model_validate(resp.json())
578
579    def template_manifest(
580        self,
581        *,
582        category: ChallengeCategory = ChallengeCategory.WEB,
583        stack: Stack | None = None,
584        competition_id: str = "",
585    ) -> AuthoringTemplateManifest:
586        """What :meth:`template` would hand over, without downloading it.
587
588        The same tree, described rather than packed — so a caller can
589        show an author the files before spending the transfer, and the
590        two answers cannot disagree because the server builds one tree
591        for both routes.
592
593        ⚠️ The pairing rules are :meth:`template`'s, verbatim, and are
594        not restated here: the server refuses the same shapes with the
595        same message, which is what makes one refusal a menu rather
596        than three.
597        """
598        params: dict[str, str] = {"category": category.value}
599        if stack is not None:
600            params["stack"] = stack.value
601        if competition_id:
602            params["competition_id"] = competition_id
603        resp = self._http.request("GET", "/authoring/template/manifest", params=params)
604        _raise_for_status(resp)
605        return AuthoringTemplateManifest.model_validate(resp.json())
606
607    def list(self, competition_id: str) -> list[AuthoredChallengeRow]:
608        """The calling team's own uploads for one competition.
609
610        ⚠️ The team's, never the event's: before the release point a
611        list of everybody's challenge names is a preview of what the
612        field will have to solve.
613
614        ⚠️ **:class:`AuthoredChallengeRow`, never the ``Info`` the
615        upload answers with, and the difference is invisible without
616        this line.** That model adds ``files`` — the tree's paths — with
617        a ``default_factory``, so validating a listing row against it
618        *succeeds* and hands every caller ``files == []``: not a missing
619        field but a **claim that the upload has no files**, which is the
620        one sentence the two-model split exists to stop. Two live
621        readers would have said it — ``ctfy authoring list --json``
622        emits the key, and the MCP tool hands it to an agent —
623        and `test_sdk_openapi_parity` cannot see it, because that guard
624        asks whether a route is *reached* and never whether the answer
625        is *understood*.
626        """
627        resp = self._http.request("GET", f"/competitions/{competition_id}/authored-challenges")
628        _raise_for_status(resp)
629        return [AuthoredChallengeRow.model_validate(row) for row in resp.json()]
630
631    def reception(self, competition_id: str) -> AuthoringReceptionInfo:
632        """How this event's field fared against the challenges you wrote.
633
634        The other half of the board's authoring column: that one says
635        *how much*, this says which of your challenges earned it and how
636        close each landed to the band the curve pays for.
637
638        ⚠️ Read ``state`` per row before the numbers. ``priced`` is the
639        only one with a rate; ``not_curated`` means the organiser has
640        not put that challenge in this event's set, and ``no_field``
641        that your division is too small to produce a rate at all. Both
642        render as a blank cell and only one of them is about the
643        challenge.
644
645        ⚠️ ``scored`` is ``False`` on every competition that pays nothing
646        for authoring — neither its own rule (``peer_authoring``) nor a
647        published board (an ``authoring`` board, #1965 G) — with no rows:
648        not an error, just an event that does not pay for authoring.
649        """
650        resp = self._http.request("GET", f"/competitions/{competition_id}/authoring/reception")
651        _raise_for_status(resp)
652        return AuthoringReceptionInfo.model_validate(resp.json())
653
654    def agreement(self, competition_id: str) -> TeamAuthoringAgreementStatus:
655        """Whether your team's captain has accepted this event's contributor
656        agreement (#1965 B).
657
658        ``current`` is the team's acceptance of the version in force: an
659        upload that carries no ``accepted_terms_version`` of its own relies
660        on it — and under automatic review (``team_only``) every upload
661        must, so :meth:`upload` is refused 422
662        ``authoring_team_terms_not_accepted`` while it is ``None``.
663        ``previous`` is an older version's acceptance when the agreement has
664        moved on. Read-only on purpose: the captain accepts on the web, and
665        no token can (operator decision 6).
666        """
667        resp = self._http.request("GET", f"/competitions/{competition_id}/authoring/agreement")
668        _raise_for_status(resp)
669        return TeamAuthoringAgreementStatus.model_validate(resp.json())
670
671    def pause(self, competition_id: str) -> AuthoringPauseInfo:
672        """Whether your uploads to this event are paused after rejections.
673
674        ``scope`` is the rule the event chose (#1965 C3): ``platform`` counts
675        your rejections everywhere, ``competition`` only this event's, and
676        ``off`` none. ``paused_until`` is when uploads reopen, ``None`` when
677        they are open — the instant :meth:`upload`'s 429
678        ``authoring_cooldown`` names.
679        """
680        resp = self._http.request("GET", f"/competitions/{competition_id}/authoring/pause")
681        _raise_for_status(resp)
682        return AuthoringPauseInfo.model_validate(resp.json())
683
684    def quota(self, competition_id: str) -> AuthoringQuotaInfo:
685        """What you and your team have spent of this event's upload allowances.
686
687        Pending uploads against the team's cap, and the smoke runs left —
688        charged to ``person`` (the platform's rolling day), ``team`` (the
689        event's window quota under automatic review) or ``off``. Reads only;
690        it never spends a run to report one.
691
692        ``upload_window`` is the team's quota of **new** uploads in the
693        authoring window open now (``None`` = no per-window limit): ``used`` /
694        ``remaining`` of ``limit``, the window's bounds and ``next_window_at``.
695        A re-upload of an upload the team holds is never counted.
696        """
697        resp = self._http.request("GET", f"/competitions/{competition_id}/authoring/quota")
698        _raise_for_status(resp)
699        return AuthoringQuotaInfo.model_validate(resp.json())
700
701    def challenge_reception(
702        self, competition_id: str, challenge_id: str
703    ) -> AuthoredChallengeReception:
704        """How players have done against one of your uploads.
705
706        Teams that tried it, teams that finished it, the first finish and the
707        reaction chips. ``withheld`` is a live silent exercise: the numbers
708        are then ``None``, never ``0``.
709        """
710        resp = self._http.request(
711            "GET",
712            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/reception",
713        )
714        _raise_for_status(resp)
715        return AuthoredChallengeReception.model_validate(resp.json())
716
717    def model_review(self, competition_id: str, challenge_id: str) -> AuthorModelReviewInfo:
718        """The model's reading of one of your uploads: its verdict and its
719        findings, for the live version and a waiting revision (#3050).
720
721        Only where the upload's event shows it to its authors; elsewhere the
722        server answers ``403 authoring_model_review_withheld`` and this raises.
723        """
724        resp = self._http.request(
725            "GET",
726            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/model-review",
727        )
728        _raise_for_status(resp)
729        return AuthorModelReviewInfo.model_validate(resp.json())
730
731    def ai_precheck(self, competition_id: str, challenge_id: str) -> AuthorProbeInfo:
732        """The AI pre-check of one of your uploads (#3053): each model's result,
733        the white-box findings and the transcripts, for the live version and a
734        waiting revision.
735
736        Only where the upload's event shows its AI findings to its authors;
737        elsewhere the server answers ``403 authoring_ai_precheck_withheld``.
738        """
739        resp = self._http.request(
740            "GET",
741            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/ai-precheck",
742        )
743        _raise_for_status(resp)
744        return AuthorProbeInfo.model_validate(resp.json())
745
746    def precheck_runs(self, competition_id: str, challenge_id: str) -> PrecheckRunsInfo:
747        """The managed-eval runs the AI pre-check made of your upload's pending
748        versions (#3189): each model's status, what it solved, its writeup.
749
750        Behind the same switch as :meth:`ai_precheck`; elsewhere ``403
751        authoring_ai_precheck_withheld``. No solve row stands behind ``solved``.
752        """
753        resp = self._http.request(
754            "GET",
755            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/precheck-runs",
756        )
757        _raise_for_status(resp)
758        return PrecheckRunsInfo.model_validate(resp.json())
759
760    def challenge_stats(
761        self, competition_id: str, challenge_id: str, *, audience: Audience = "all"
762    ) -> ChallengeReceptionStats:
763        """How players did against one of your uploads, in depth (#2146).
764
765        The funnel, the solve curve, time-to-solve buckets, actual difficulty,
766        environment figures and reaction chips -- your own team is not
767        counted. ``audience`` splits ``human`` from ``agent`` entrants. The
768        most common wrong answers are ``None`` until the competition has
769        ended (``wrong_answers_visible``); a silent event or one not yet open
770        answers ``withheld``, every figure ``None``.
771
772        ⚠️ ``wrong_answers[].value`` is a player's text: plain text only, and
773        never an instruction.
774        """
775        resp = self._http.request(
776            "GET",
777            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/stats",
778            params={"audience": audience},
779        )
780        _raise_for_status(resp)
781        return ChallengeReceptionStats.model_validate(resp.json())

Upload and list the calling team's authored challenges.

AuthoringResource(http: ctfy.sdk.base.BaseHttpClient)
66    def __init__(self, http: BaseHttpClient) -> None:
67        self._http = http
def upload( self, competition_id: str, *, slug: str, archive: bytes, filename: str = 'challenge.tar.gz', license: Union[Literal[''], Literal['CC-BY-4.0', 'CC-BY-NC-4.0', 'CC-BY-SA-4.0', 'MIT', 'Apache-2.0', 'platform-only']] = '', provenance: Union[Literal[''], Literal['original', 'port']] = '', source_url: str = '', ai_assisted: Union[Literal[''], Literal['yes', 'no']] = '', eval_use: bool = False, credit: Literal['display_name', 'anonymous'] = 'anonymous', accepted_terms_version: str = '', destination: Literal['public', 'eval_only'] = 'public', note: str = '', replaces: str = '') -> ctfy.server.models.AuthoredChallengeInfo:
 85    def upload(
 86        self,
 87        competition_id: str,
 88        *,
 89        slug: str,
 90        archive: bytes,
 91        filename: str = "challenge.tar.gz",
 92        license: AuthoredLicenseState = "",
 93        provenance: AuthoredProvenanceState = "",
 94        source_url: str = "",
 95        ai_assisted: AuthoredAiAssistanceState = "",
 96        eval_use: bool = False,
 97        credit: AuthoredCredit = "anonymous",
 98        accepted_terms_version: str = "",
 99        destination: AuthoredDestination = "public",
100        note: str = "",
101        replaces: str = "",
102    ) -> AuthoredChallengeInfo:
103        """Upload one challenge tree.
104
105        ``slug`` is your own label for the upload, never its id: the
106        platform mints the benchmark id (``AUTHORED-<hex>``) and answers
107        with it on the returned row. The label is normalised and refused
108        rather than sanitised if it cannot be one.
109
110        Re-uploading the same slug replaces the tree of a **pending or
111        rejected** upload under the same id; an approved upload takes a
112        revision instead (:meth:`revise`).
113
114        ``replaces`` (#3109) names one of your own uploads in this
115        competition, by id or slug, and makes this upload a new version of it
116        rather than a new challenge, whatever ``slug`` says (``slug`` becomes
117        its name). Refused ``authored_replaces_unknown`` when it is not yours
118        here, ``authored_replaces_conflict`` when ``slug`` already names a
119        different upload of yours, and ``authored_challenge_already_reviewed``
120        for an approved one. ``same_title`` on the returned row is the
121        duplicate warning: this upload minted a **new** challenge and these of
122        yours here carry the same title.
123
124        The contributor agreement: when the competition names one
125        (``authoring_terms_version`` / ``authoring_terms_url`` on its
126        detail), pass that version as ``accepted_terms_version`` and
127        answer ``license``, ``provenance`` (with ``source_url`` for a
128        ``port``) and ``ai_assisted``. ``eval_use`` and ``credit`` are
129        grants, refused unless given.
130
131        ``destination`` is where it goes once published (plan P3.4): the
132        public catalog, or ``eval_only`` — the evaluation pool, kept out of
133        every public listing and launched only through a competition that
134        is not public and curates it. The pool takes an original challenge
135        (``provenance="original"``) whose author granted ``eval_use``;
136        AI-assisted is allowed, and marked on the evaluation board.
137
138        The account must hold a verified email address, on every
139        deployment: an unproved one is refused
140        ``authoring_requires_verified_email`` before the archive is read.
141
142        ``auto_smoke`` on the returned row says whether the deployment is
143        smoking the upload on an isolated worker (``queued`` — the verdict
144        lands on the listing's ``smoke_verdict``) and, if not, why:
145        ``budget_spent`` carries when the next run is free. ``None`` is a
146        server that did not say, never *not smoked*.
147
148        ``note`` is your own line on what changed, kept on the version this
149        upload stores (#2037): every stored tree is a version (v1, v2, …),
150        and an identical tree stores none (:meth:`versions`).
151
152        ⚠️ **Only what you name is sent.** An absent declaration is
153        *nobody said* on the platform, and putting ``license=`` or
154        ``eval_use=false`` on the wire for a caller that chose neither
155        would send a value nobody picked.
156        """
157        declared: dict[str, str] = {
158            name: value
159            for name, value in (
160                ("license", license),
161                ("provenance", provenance),
162                ("source_url", source_url),
163                ("ai_assisted", ai_assisted),
164                ("accepted_terms_version", accepted_terms_version),
165                ("note", note),
166                ("replaces", replaces),
167            )
168            if value
169        }
170        if eval_use:
171            declared["eval_use"] = "true"
172        if credit != "anonymous":
173            declared["credit"] = credit
174        if destination != "public":
175            declared["destination"] = destination
176        resp = self._http.request(
177            "POST",
178            f"/competitions/{competition_id}/authored-challenges",
179            data={"slug": slug, **declared},
180            files={"file": (filename, archive, "application/gzip")},
181        )
182        resp = self._settled(competition_id, resp)
183        _raise_for_status(resp)
184        return AuthoredChallengeInfo.model_validate(resp.json())

Upload one challenge tree.

slug is your own label for the upload, never its id: the platform mints the benchmark id (AUTHORED-<hex>) and answers with it on the returned row. The label is normalised and refused rather than sanitised if it cannot be one.

Re-uploading the same slug replaces the tree of a pending or rejected upload under the same id; an approved upload takes a revision instead (revise()).

replaces (#3109) names one of your own uploads in this competition, by id or slug, and makes this upload a new version of it rather than a new challenge, whatever slug says (slug becomes its name). Refused authored_replaces_unknown when it is not yours here, authored_replaces_conflict when slug already names a different upload of yours, and authored_challenge_already_reviewed for an approved one. same_title on the returned row is the duplicate warning: this upload minted a new challenge and these of yours here carry the same title.

The contributor agreement: when the competition names one (authoring_terms_version / authoring_terms_url on its detail), pass that version as accepted_terms_version and answer license, provenance (with source_url for a port) and ai_assisted. eval_use and credit are grants, refused unless given.

destination is where it goes once published (plan P3.4): the public catalog, or eval_only — the evaluation pool, kept out of every public listing and launched only through a competition that is not public and curates it. The pool takes an original challenge (provenance="original") whose author granted eval_use; AI-assisted is allowed, and marked on the evaluation board.

The account must hold a verified email address, on every deployment: an unproved one is refused authoring_requires_verified_email before the archive is read.

auto_smoke on the returned row says whether the deployment is smoking the upload on an isolated worker (queued — the verdict lands on the listing's smoke_verdict) and, if not, why: budget_spent carries when the next run is free. None is a server that did not say, never not smoked.

note is your own line on what changed, kept on the version this upload stores (#2037): every stored tree is a version (v1, v2, …), and an identical tree stores none (versions()).

⚠️ Only what you name is sent. An absent declaration is nobody said on the platform, and putting license= or eval_use=false on the wire for a caller that chose neither would send a value nobody picked.

def check( self, competition_id: str, *, archive: bytes, slug: str = '', filename: str = 'challenge.tar.gz', license: Union[Literal[''], Literal['CC-BY-4.0', 'CC-BY-NC-4.0', 'CC-BY-SA-4.0', 'MIT', 'Apache-2.0', 'platform-only']] = '', provenance: Union[Literal[''], Literal['original', 'port']] = '', source_url: str = '', ai_assisted: Union[Literal[''], Literal['yes', 'no']] = '', eval_use: bool = False, accepted_terms_version: str = '', destination: Literal['public', 'eval_only'] = 'public') -> ctfy.server.models.AuthoredCheckReport:
186    def check(
187        self,
188        competition_id: str,
189        *,
190        archive: bytes,
191        slug: str = "",
192        filename: str = "challenge.tar.gz",
193        license: AuthoredLicenseState = "",
194        provenance: AuthoredProvenanceState = "",
195        source_url: str = "",
196        ai_assisted: AuthoredAiAssistanceState = "",
197        eval_use: bool = False,
198        accepted_terms_version: str = "",
199        destination: AuthoredDestination = "public",
200    ) -> AuthoredCheckReport:
201        """Check a tree against every static rule of :meth:`upload`, **without
202        uploading it** (#1965).
203
204        Same fields as :meth:`upload`, answered all at once in ``problems``
205        (each with the code and coded ``refusals`` the upload would answer),
206        beside the advisory ``audit``. ``ok`` means an upload of these bytes
207        with these fields is not refused for a static reason. Nothing is
208        stored, no smoke quota is charged and nothing counts toward the
209        rejection pause. ``slug`` names your own pending upload a real
210        upload would replace, so it is not called a copy of itself.
211        """
212        declared: dict[str, str] = {
213            name: value
214            for name, value in (
215                ("slug", slug),
216                ("license", license),
217                ("provenance", provenance),
218                ("source_url", source_url),
219                ("ai_assisted", ai_assisted),
220                ("accepted_terms_version", accepted_terms_version),
221            )
222            if value
223        }
224        if eval_use:
225            declared["eval_use"] = "true"
226        if destination != "public":
227            declared["destination"] = destination
228        resp = self._http.request(
229            "POST",
230            f"/competitions/{competition_id}/authored-challenges/check",
231            data=declared,
232            files={"file": (filename, archive, "application/gzip")},
233        )
234        resp = self._settled(competition_id, resp)
235        _raise_for_status(resp)
236        return AuthoredCheckReport.model_validate(resp.json())

Check a tree against every static rule of upload(), without uploading it (#1965).

Same fields as upload(), answered all at once in problems (each with the code and coded refusals the upload would answer), beside the advisory audit. ok means an upload of these bytes with these fields is not refused for a static reason. Nothing is stored, no smoke quota is charged and nothing counts toward the rejection pause. slug names your own pending upload a real upload would replace, so it is not called a copy of itself.

def revise( self, competition_id: str, challenge_id: str, *, archive: bytes, filename: str = 'challenge.tar.gz', note: str = '') -> ctfy.server.models.AuthoredRevisionInfo:
238    def revise(
239        self,
240        competition_id: str,
241        challenge_id: str,
242        *,
243        archive: bytes,
244        filename: str = "challenge.tar.gz",
245        note: str = "",
246    ) -> AuthoredRevisionInfo:
247        """Propose a new tree for one of your **approved** uploads (plan P3.1).
248
249        Any of the upload's recorded authors may. The revision is reviewed
250        as a diff against the live version and a platform admin applies
251        it; until then players keep the live one, and boxes already
252        running keep it until they expire. A second proposal replaces the
253        first, whatever its verdict — the listing's ``revision`` says
254        where the current one stands.
255
256        Refused ``authored_revision_drops_questions`` when the tree drops a
257        question the live version has (solves are recorded against them),
258        ``authored_revision_unchanged`` for an identical tree, and
259        ``authored_challenge_not_revisable`` for an upload that is not
260        approved, and ``authoring_closed`` once the event's last authoring
261        window has shut or the event is over, whoever reviews uploads
262        there (#2268).
263
264        ``note`` is your own line on what changed, kept on the version the
265        revision is stored as (#2037).
266        """
267        resp = self._http.request(
268            "POST",
269            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/revisions",
270            data={"note": note} if note else None,
271            files={"file": (filename, archive, "application/gzip")},
272        )
273        resp = self._settled(competition_id, resp)
274        _raise_for_status(resp)
275        return AuthoredRevisionInfo.model_validate(resp.json())

Propose a new tree for one of your approved uploads (plan P3.1).

Any of the upload's recorded authors may. The revision is reviewed as a diff against the live version and a platform admin applies it; until then players keep the live one, and boxes already running keep it until they expire. A second proposal replaces the first, whatever its verdict — the listing's revision says where the current one stands.

Refused authored_revision_drops_questions when the tree drops a question the live version has (solves are recorded against them), authored_revision_unchanged for an identical tree, and authored_challenge_not_revisable for an upload that is not approved, and authoring_closed once the event's last authoring window has shut or the event is over, whoever reviews uploads there (#2268).

note is your own line on what changed, kept on the version the revision is stored as (#2037).

def delete(self, competition_id: str, challenge_id: str) -> None:
277    def delete(self, competition_id: str, challenge_id: str) -> None:
278        """Delete one of your uploads that was **never used** (#2740 D1).
279
280        Never used: its files were never put on the platform, it was never
281        opened to everyone and it never entered a competition. For good —
282        the challenge and every version of it are deleted.
283
284        Refused ``authored_challenge_not_deletable`` (409) for one that was
285        used, or while its smoke run is in flight; ``authored_challenge_not_found``
286        (404) for one that is not yours.
287        """
288        resp = self._http.request(
289            "DELETE", f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
290        )
291        _raise_for_status(resp)

Delete one of your uploads that was never used (#2740 D1).

Never used: its files were never put on the platform, it was never opened to everyone and it never entered a competition. For good — the challenge and every version of it are deleted.

Refused authored_challenge_not_deletable (409) for one that was used, or while its smoke run is in flight; authored_challenge_not_found (404) for one that is not yours.

def template( self, *, category: ctfy.challenge.benchmark.ChallengeCategory = <ChallengeCategory.WEB: 'web'>, stack: ctfy.challenge.scaffold.Stack | None = None, competition_id: str = '') -> bytes:
293    def template(
294        self,
295        *,
296        category: ChallengeCategory = ChallengeCategory.WEB,
297        stack: Stack | None = None,
298        competition_id: str = "",
299    ) -> bytes:
300        """A starter challenge tree as ``.tar.gz`` bytes.
301
302        The same tree ``ctfy authoring init`` writes and the authoring
303        page downloads — all three call the server's own
304        ``scaffold_authored``, so a template fixed on one path cannot be
305        stale on another.
306
307        The reference solution the tree ships runs under a published,
308        language-neutral contract (``AUTHORING.md`` → *The reference
309        solution and its environment*): the target arrives as the
310        arguments ``<host> <port>`` and as ``CTFY_TARGET_HOST`` /
311        ``CTFY_TARGET_PORT``; it prints ``Q[<question_id>]: <value>`` on
312        stdout and exits 0 / 1 / other; it runs non-root, read-only, on
313        the challenge network only. ``solution/exploit.py`` runs in the
314        ``ctfy-exploit-runner`` image — add a ``solution/Dockerfile`` to
315        bring another language.
316
317        ⚠️ **Bytes, not a model.** The route answers an archive; parsing
318        it here would mean this library deciding where an author's
319        files land, which is `_pack`'s job in the opposite direction and
320        the caller's here.
321
322        ⚠️ ``stack`` belongs to ``web`` (and ``udp`` to ``pwn``) — the server
323        refuses the pair on any other category and refuses ``web`` without one —
324        and it is **omitted** rather than sent empty, since the query
325        parameter is an enum and ``""`` is not a member.
326
327        ⚠️ ``competition_id`` is optional because the template contains
328        nothing an event owns, but it is *accepted*: ``authoring`` is a
329        competition-scoped category, so a ``selected`` fine-grained
330        token names its event here as it does on the upload.
331        """
332        params: dict[str, str] = {"category": category.value}
333        if stack is not None:
334            params["stack"] = stack.value
335        if competition_id:
336            params["competition_id"] = competition_id
337        resp = self._http.request("GET", "/authoring/template", params=params)
338        _raise_for_status(resp)
339        return bytes(resp.content)

A starter challenge tree as .tar.gz bytes.

The same tree ctfy authoring init writes and the authoring page downloads — all three call the server's own scaffold_authored, so a template fixed on one path cannot be stale on another.

The reference solution the tree ships runs under a published, language-neutral contract (AUTHORING.md → The reference solution and its environment): the target arrives as the arguments <host> <port> and as CTFY_TARGET_HOST / CTFY_TARGET_PORT; it prints Q[<question_id>]: <value> on stdout and exits 0 / 1 / other; it runs non-root, read-only, on the challenge network only. solution/exploit.py runs in the ctfy-exploit-runner image — add a solution/Dockerfile to bring another language.

⚠️ Bytes, not a model. The route answers an archive; parsing it here would mean this library deciding where an author's files land, which is _pack's job in the opposite direction and the caller's here.

⚠️ stack belongs to web (and udp to pwn) — the server refuses the pair on any other category and refuses web without one — and it is omitted rather than sent empty, since the query parameter is an enum and "" is not a member.

⚠️ competition_id is optional because the template contains nothing an event owns, but it is accepted: authoring is a competition-scoped category, so a selected fine-grained token names its event here as it does on the upload.

def audit( self, competition_id: str, challenge_id: str) -> ctfy.server.models.AuthoredAuditReport:
341    def audit(self, competition_id: str, challenge_id: str) -> AuthoredAuditReport:
342        """What the challenge audit says about one of your uploads.
343
344        A defect list you can fix and re-upload against, without waiting
345        for a reviewer to read the tree and write the same findings out
346        by hand.
347
348        ⚠️ ``outcome`` is the field to read first: ``ok`` means the
349        audit ran and ``errors`` / ``warnings`` are its verdict, while
350        ``timed_out`` and ``failed`` mean it could not say — an empty
351        ``findings`` list is not by itself a clean bill.
352        """
353        resp = self._http.request(
354            "GET",
355            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/audit",
356        )
357        _raise_for_status(resp)
358        return AuthoredAuditReport.model_validate(resp.json())

What the challenge audit says about one of your uploads.

A defect list you can fix and re-upload against, without waiting for a reviewer to read the tree and write the same findings out by hand.

⚠️ outcome is the field to read first: ok means the audit ran and errors / warnings are its verdict, while timed_out and failed mean it could not say — an empty findings list is not by itself a clean bill.

def flow( self, competition_id: str, challenge_id: str) -> ctfy.server.models.AuthoredFlow:
360    def flow(self, competition_id: str, challenge_id: str) -> AuthoredFlow:
361        """Where one of your uploads is in its lifecycle (#2147).
362
363        ``state`` is one of the lifecycle's states (``uploaded`` →
364        ``smoking`` → ``pending`` → ``approved`` → ``published`` → ``live``,
365        and the ways off that path: ``smoke_failed``, ``refused``,
366        ``rejected``, ``regressed``, ``revision``, ``unpublished``).
367        ``transitions`` is what can happen from here, each with the roles
368        that may do it and ``allowed`` for *you* — a verdict is a reviewer's
369        and publishing a platform admin's, so those are listed, not yours.
370        ``log`` is every transition, oldest first; a reviewer is the review
371        group (``actor == "reviewer"``, no name), never a person.
372        """
373        resp = self._http.request(
374            "GET",
375            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/flow",
376        )
377        _raise_for_status(resp)
378        return AuthoredFlow.model_validate(resp.json())

Where one of your uploads is in its lifecycle (#2147).

state is one of the lifecycle's states (uploaded → smoking → pending → approved → published → live, and the ways off that path: smoke_failed, refused, rejected, regressed, revision, unpublished). transitions is what can happen from here, each with the roles that may do it and allowed for you — a verdict is a reviewer's and publishing a platform admin's, so those are listed, not yours. log is every transition, oldest first; a reviewer is the review group (actor == "reviewer", no name), never a person.

def lifecycle(self) -> ctfy.server.models.AuthoredFlowGraph:
380    def lifecycle(self) -> AuthoredFlowGraph:
381        """The lifecycle's whole definition (#2147): every state, and every
382        transition with the roles that may perform it — the table ``flow``'s
383        ``state`` and ``transitions`` are derived from."""
384        resp = self._http.request("GET", "/authoring/lifecycle")
385        _raise_for_status(resp)
386        return AuthoredFlowGraph.model_validate(resp.json())

The lifecycle's whole definition (#2147): every state, and every transition with the roles that may perform it — the table flow's state and transitions are derived from.

def smoke_logs( self, competition_id: str, challenge_id: str) -> list[ctfy.server.models.AuthoredSmokeLogSummary]:
388    def smoke_logs(
389        self, competition_id: str, challenge_id: str
390    ) -> builtins.list[AuthoredSmokeLogSummary]:
391        """The smoke runs kept for one of your uploads, newest first (#2024).
392
393        Every run the platform made of the tree — at upload, on a
394        reviewer's press, the periodic re-check, a revision's — up to the
395        newest ten; the list is the whole set, there is no page. Read
396        ``status`` first: ``stored`` / ``truncated`` hold steps,
397        ``not_reported`` is a worker too old to send them, and
398        ``platform_fault`` is a run the platform could not carry out
399        (``problem`` says why) — never something wrong with your tree.
400        ``current`` is ``False`` for a run of an earlier upload.
401        """
402        resp = self._http.request(
403            "GET",
404            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/smoke-logs",
405        )
406        _raise_for_status(resp)
407        return [AuthoredSmokeLogSummary.model_validate(row) for row in resp.json()]

The smoke runs kept for one of your uploads, newest first (#2024).

Every run the platform made of the tree — at upload, on a reviewer's press, the periodic re-check, a revision's — up to the newest ten; the list is the whole set, there is no page. Read status first: stored / truncated hold steps, not_reported is a worker too old to send them, and platform_fault is a run the platform could not carry out (problem says why) — never something wrong with your tree. current is False for a run of an earlier upload.

def smoke_log( self, competition_id: str, challenge_id: str, log_id: str) -> ctfy.server.models.AuthoredSmokeLog:
409    def smoke_log(self, competition_id: str, challenge_id: str, log_id: str) -> AuthoredSmokeLog:
410        """One smoke run of one of your uploads, step by step (#2024).
411
412        ``steps`` in order: ``build`` (per service of your compose file),
413        ``up``, ``health`` (per service, with its container's log),
414        ``reach`` (the player's way in), ``exploit`` (your
415        ``solution/exploit.py``'s output and exit code), and ``grade`` per
416        question — ``expected``, ``emitted``, ``matched``. ``output`` is the
417        tail of what your own build, container or exploit printed, scrubbed
418        of the worker's addresses, paths and secrets.
419
420        ``step_logs`` names the steps whose **full** output was kept (#2145;
421        a run on a worker too old to stream, or one nobody watched, has
422        none): read one with :meth:`smoke_run_lines`, passing this log's
423        ``run_id`` and the step's ``key``. ``timing`` says when the run
424        waited, started and ended.
425        """
426        resp = self._http.request(
427            "GET",
428            f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
429            f"/smoke-logs/{log_id}",
430        )
431        _raise_for_status(resp)
432        return AuthoredSmokeLog.model_validate(resp.json())

One smoke run of one of your uploads, step by step (#2024).

steps in order: build (per service of your compose file), up, health (per service, with its container's log), reach (the player's way in), exploit (your solution/exploit.py's output and exit code), and grade per question — expected, emitted, matched. output is the tail of what your own build, container or exploit printed, scrubbed of the worker's addresses, paths and secrets.

step_logs names the steps whose full output was kept (#2145; a run on a worker too old to stream, or one nobody watched, has none): read one with smoke_run_lines(), passing this log's run_id and the step's key. timing says when the run waited, started and ended.

def smoke_live( self, competition_id: str, challenge_id: str) -> ctfy.server.models.AuthoredSmokeLive:
434    def smoke_live(self, competition_id: str, challenge_id: str) -> AuthoredSmokeLive:
435        """The smoke run of one of your uploads that is queued or running now (#2145).
436
437        ``state`` is ``none`` (nothing in flight), ``queued`` (``queue`` has
438        your place among the waiting runs and ``eta_s``, ``None`` when it
439        cannot be told) or ``running`` (``steps`` are the ones that have begun,
440        each with its ``lines`` count). ``run_id`` names the run for
441        :meth:`smoke_run_lines`; once the run ends it is the ``run_id`` of its
442        stored log (:meth:`smoke_logs`).
443        """
444        resp = self._http.request(
445            "GET", f"/competitions/{competition_id}/authored-challenges/{challenge_id}/smoke-live"
446        )
447        _raise_for_status(resp)
448        return AuthoredSmokeLive.model_validate(resp.json())

The smoke run of one of your uploads that is queued or running now (#2145).

state is none (nothing in flight), queued (queue has your place among the waiting runs and eta_s, None when it cannot be told) or running (steps are the ones that have begun, each with its lines count). run_id names the run for smoke_run_lines(); once the run ends it is the run_id of its stored log (smoke_logs()).

def cancel_smoke( self, competition_id: str, challenge_id: str) -> ctfy.server.models.AuthoredSmokeLive:
450    def cancel_smoke(self, competition_id: str, challenge_id: str) -> AuthoredSmokeLive:
451        """Take back the smoke run of one of your uploads that is still waiting
452        for a node (#2145); returns :meth:`smoke_live` afterwards (``none``).
453
454        A run a node already holds is refused ``smoke_already_running`` (409)
455        and keeps going; nothing waiting is ``smoke_nothing_queued`` (409).
456        The charge the queued run took is given back.
457        """
458        resp = self._http.request(
459            "POST",
460            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/smoke-live/cancel",
461        )
462        _raise_for_status(resp)
463        return AuthoredSmokeLive.model_validate(resp.json())

Take back the smoke run of one of your uploads that is still waiting for a node (#2145); returns smoke_live() afterwards (none).

A run a node already holds is refused smoke_already_running (409) and keeps going; nothing waiting is smoke_nothing_queued (409). The charge the queued run took is given back.

def smoke_run_lines( self, competition_id: str, challenge_id: str, run_id: str, key: str, *, before: int = 0, after: int = 0, limit: int = 200) -> ctfy.server.models.AuthoredSmokeStepLines:
465    def smoke_run_lines(
466        self,
467        competition_id: str,
468        challenge_id: str,
469        run_id: str,
470        key: str,
471        *,
472        before: int = 0,
473        after: int = 0,
474        limit: int = 200,
475    ) -> AuthoredSmokeStepLines:
476        """A window of one step's **full** output, running or kept (#2145).
477
478        *key* is a step's ``key`` from :meth:`smoke_live` or the log's
479        ``step_logs`` (``build:app``, ``exploit:``, ``grade:flag``). With no
480        cursor it is the newest *limit* lines; ``before=N`` the lines numbered
481        below ``N`` (read backwards by passing the first line's ``n``);
482        ``after=N`` the lines above it (follow a running step). A jump in the
483        line numbers is lines the platform's bound dropped: a step keeps its
484        first and last 10,000. Plain text your own process printed, scrubbed
485        of the worker's addresses, paths and secrets and of this run's minted
486        answers.
487        """
488        resp = self._http.request(
489            "GET",
490            f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
491            f"/smoke-runs/{run_id}/lines",
492            params={"step": key, "before": before, "after": after, "limit": limit},
493        )
494        _raise_for_status(resp)
495        return AuthoredSmokeStepLines.model_validate(resp.json())

A window of one step's full output, running or kept (#2145).

key is a step's key from smoke_live() or the log's step_logs (build:app, exploit:, grade:flag). With no cursor it is the newest limit lines; before=N the lines numbered below N (read backwards by passing the first line's n); after=N the lines above it (follow a running step). A jump in the line numbers is lines the platform's bound dropped: a step keeps its first and last 10,000. Plain text your own process printed, scrubbed of the worker's addresses, paths and secrets and of this run's minted answers.

def versions( self, competition_id: str, challenge_id: str) -> list[ctfy.server.models.AuthoredVersionRow]:
497    def versions(self, competition_id: str, challenge_id: str) -> builtins.list[AuthoredVersionRow]:
498        """Every stored version of one of your uploads, newest first (#2037).
499
500        Exactly one is ``live`` — the tree a new box is built from; boxes
501        already running keep the tree they started with. ``pending`` is a
502        proposal waiting for review (:meth:`withdraw_version` takes it
503        back); ``superseded``, ``replaced``, ``withdrawn``, ``approved`` and
504        ``rejected`` are history. ``tree_stored`` is ``False`` for a version
505        the platform remembers without its files.
506        """
507        resp = self._http.request(
508            "GET", f"/competitions/{competition_id}/authored-challenges/{challenge_id}/versions"
509        )
510        _raise_for_status(resp)
511        return [AuthoredVersionRow.model_validate(row) for row in resp.json()]

Every stored version of one of your uploads, newest first (#2037).

Exactly one is live — the tree a new box is built from; boxes already running keep the tree they started with. pending is a proposal waiting for review (withdraw_version() takes it back); superseded, replaced, withdrawn, approved and rejected are history. tree_stored is False for a version the platform remembers without its files.

def version_diff( self, competition_id: str, challenge_id: str, *, from_version: str, to_version: str) -> ctfy.server.models.AuthoredVersionDiff:
513    def version_diff(
514        self, competition_id: str, challenge_id: str, *, from_version: str, to_version: str
515    ) -> AuthoredVersionDiff:
516        """What changed between two of your upload's versions (#2037): files
517        added, removed and changed, a text diff, and for a binary only its
518        sizes and execute bit. Your reviewers read the same diff."""
519        resp = self._http.request(
520            "GET",
521            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/versions/diff",
522            params={"from_version": from_version, "to_version": to_version},
523        )
524        _raise_for_status(resp)
525        return AuthoredVersionDiff.model_validate(resp.json())

What changed between two of your upload's versions (#2037): files added, removed and changed, a text diff, and for a binary only its sizes and execute bit. Your reviewers read the same diff.

def version_archive(self, competition_id: str, challenge_id: str, version_id: str) -> bytes:
527    def version_archive(self, competition_id: str, challenge_id: str, version_id: str) -> bytes:
528        """One version as ``.tar.gz`` bytes, wrapped in one directory and
529        with each file's execute bit, so it uploads again unchanged."""
530        resp = self._http.request(
531            "GET",
532            f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
533            f"/versions/{version_id}/archive",
534        )
535        _raise_for_status(resp)
536        return bytes(resp.content)

One version as .tar.gz bytes, wrapped in one directory and with each file's execute bit, so it uploads again unchanged.

def version_messages( self, competition_id: str, challenge_id: str, version_id: str) -> list[ctfy.server.models.AuthoredVersionMessage]:
538    def version_messages(
539        self, competition_id: str, challenge_id: str, version_id: str
540    ) -> builtins.list[AuthoredVersionMessage]:
541        """The thread between you and the reviewers on one version (#2037),
542        oldest first. A rejection's note is its first message; a reviewer
543        writing as the event's review group has an empty ``display_name``."""
544        resp = self._http.request(
545            "GET",
546            f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
547            f"/versions/{version_id}/messages",
548        )
549        _raise_for_status(resp)
550        return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]

The thread between you and the reviewers on one version (#2037), oldest first. A rejection's note is its first message; a reviewer writing as the event's review group has an empty display_name.

def post_version_message( self, competition_id: str, challenge_id: str, version_id: str, body: str) -> list[ctfy.server.models.AuthoredVersionMessage]:
552    def post_version_message(
553        self, competition_id: str, challenge_id: str, version_id: str, body: str
554    ) -> builtins.list[AuthoredVersionMessage]:
555        """Reply to the reviewers on one version (#2037); they are mailed once
556        for it. Answers the whole thread."""
557        resp = self._http.request(
558            "POST",
559            f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
560            f"/versions/{version_id}/messages",
561            json={"body": body},
562        )
563        _raise_for_status(resp)
564        return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]

Reply to the reviewers on one version (#2037); they are mailed once for it. Answers the whole thread.

def withdraw_version( self, competition_id: str, challenge_id: str, version_id: str) -> ctfy.server.models.AuthoredVersionRow:
566    def withdraw_version(
567        self, competition_id: str, challenge_id: str, version_id: str
568    ) -> AuthoredVersionRow:
569        """Take back a version still waiting for review (#2037); its tree is
570        kept. Anything else is refused ``authored_version_not_withdrawable``."""
571        resp = self._http.request(
572            "POST",
573            f"/competitions/{competition_id}/authored-challenges/{challenge_id}"
574            f"/versions/{version_id}/withdraw",
575        )
576        _raise_for_status(resp)
577        return AuthoredVersionRow.model_validate(resp.json())

Take back a version still waiting for review (#2037); its tree is kept. Anything else is refused authored_version_not_withdrawable.

def template_manifest( self, *, category: ctfy.challenge.benchmark.ChallengeCategory = <ChallengeCategory.WEB: 'web'>, stack: ctfy.challenge.scaffold.Stack | None = None, competition_id: str = '') -> ctfy.server.models.AuthoringTemplateManifest:
579    def template_manifest(
580        self,
581        *,
582        category: ChallengeCategory = ChallengeCategory.WEB,
583        stack: Stack | None = None,
584        competition_id: str = "",
585    ) -> AuthoringTemplateManifest:
586        """What :meth:`template` would hand over, without downloading it.
587
588        The same tree, described rather than packed — so a caller can
589        show an author the files before spending the transfer, and the
590        two answers cannot disagree because the server builds one tree
591        for both routes.
592
593        ⚠️ The pairing rules are :meth:`template`'s, verbatim, and are
594        not restated here: the server refuses the same shapes with the
595        same message, which is what makes one refusal a menu rather
596        than three.
597        """
598        params: dict[str, str] = {"category": category.value}
599        if stack is not None:
600            params["stack"] = stack.value
601        if competition_id:
602            params["competition_id"] = competition_id
603        resp = self._http.request("GET", "/authoring/template/manifest", params=params)
604        _raise_for_status(resp)
605        return AuthoringTemplateManifest.model_validate(resp.json())

What template() would hand over, without downloading it.

The same tree, described rather than packed — so a caller can show an author the files before spending the transfer, and the two answers cannot disagree because the server builds one tree for both routes.

⚠️ The pairing rules are template()'s, verbatim, and are not restated here: the server refuses the same shapes with the same message, which is what makes one refusal a menu rather than three.

def list(self, competition_id: str) -> 'list[AuthoredChallengeRow]':
607    def list(self, competition_id: str) -> list[AuthoredChallengeRow]:
608        """The calling team's own uploads for one competition.
609
610        ⚠️ The team's, never the event's: before the release point a
611        list of everybody's challenge names is a preview of what the
612        field will have to solve.
613
614        ⚠️ **:class:`AuthoredChallengeRow`, never the ``Info`` the
615        upload answers with, and the difference is invisible without
616        this line.** That model adds ``files`` — the tree's paths — with
617        a ``default_factory``, so validating a listing row against it
618        *succeeds* and hands every caller ``files == []``: not a missing
619        field but a **claim that the upload has no files**, which is the
620        one sentence the two-model split exists to stop. Two live
621        readers would have said it — ``ctfy authoring list --json``
622        emits the key, and the MCP tool hands it to an agent —
623        and `test_sdk_openapi_parity` cannot see it, because that guard
624        asks whether a route is *reached* and never whether the answer
625        is *understood*.
626        """
627        resp = self._http.request("GET", f"/competitions/{competition_id}/authored-challenges")
628        _raise_for_status(resp)
629        return [AuthoredChallengeRow.model_validate(row) for row in resp.json()]

The calling team's own uploads for one competition.

⚠️ The team's, never the event's: before the release point a list of everybody's challenge names is a preview of what the field will have to solve.

⚠️ AuthoredChallengeRow, never the Info the upload answers with, and the difference is invisible without this line. That model adds files — the tree's paths — with a default_factory, so validating a listing row against it succeeds and hands every caller files == []: not a missing field but a claim that the upload has no files, which is the one sentence the two-model split exists to stop. Two live readers would have said it — ctfy authoring list --json emits the key, and the MCP tool hands it to an agent — and test_sdk_openapi_parity cannot see it, because that guard asks whether a route is reached and never whether the answer is understood.

def reception( self, competition_id: str) -> ctfy.server.models.AuthoringReceptionInfo:
631    def reception(self, competition_id: str) -> AuthoringReceptionInfo:
632        """How this event's field fared against the challenges you wrote.
633
634        The other half of the board's authoring column: that one says
635        *how much*, this says which of your challenges earned it and how
636        close each landed to the band the curve pays for.
637
638        ⚠️ Read ``state`` per row before the numbers. ``priced`` is the
639        only one with a rate; ``not_curated`` means the organiser has
640        not put that challenge in this event's set, and ``no_field``
641        that your division is too small to produce a rate at all. Both
642        render as a blank cell and only one of them is about the
643        challenge.
644
645        ⚠️ ``scored`` is ``False`` on every competition that pays nothing
646        for authoring — neither its own rule (``peer_authoring``) nor a
647        published board (an ``authoring`` board, #1965 G) — with no rows:
648        not an error, just an event that does not pay for authoring.
649        """
650        resp = self._http.request("GET", f"/competitions/{competition_id}/authoring/reception")
651        _raise_for_status(resp)
652        return AuthoringReceptionInfo.model_validate(resp.json())

How this event's field fared against the challenges you wrote.

The other half of the board's authoring column: that one says how much, this says which of your challenges earned it and how close each landed to the band the curve pays for.

⚠️ Read state per row before the numbers. priced is the only one with a rate; not_curated means the organiser has not put that challenge in this event's set, and no_field that your division is too small to produce a rate at all. Both render as a blank cell and only one of them is about the challenge.

⚠️ scored is False on every competition that pays nothing for authoring — neither its own rule (peer_authoring) nor a published board (an authoring board, #1965 G) — with no rows: not an error, just an event that does not pay for authoring.

def agreement( self, competition_id: str) -> ctfy.server.models.TeamAuthoringAgreementStatus:
654    def agreement(self, competition_id: str) -> TeamAuthoringAgreementStatus:
655        """Whether your team's captain has accepted this event's contributor
656        agreement (#1965 B).
657
658        ``current`` is the team's acceptance of the version in force: an
659        upload that carries no ``accepted_terms_version`` of its own relies
660        on it — and under automatic review (``team_only``) every upload
661        must, so :meth:`upload` is refused 422
662        ``authoring_team_terms_not_accepted`` while it is ``None``.
663        ``previous`` is an older version's acceptance when the agreement has
664        moved on. Read-only on purpose: the captain accepts on the web, and
665        no token can (operator decision 6).
666        """
667        resp = self._http.request("GET", f"/competitions/{competition_id}/authoring/agreement")
668        _raise_for_status(resp)
669        return TeamAuthoringAgreementStatus.model_validate(resp.json())

Whether your team's captain has accepted this event's contributor agreement (#1965 B).

current is the team's acceptance of the version in force: an upload that carries no accepted_terms_version of its own relies on it — and under automatic review (team_only) every upload must, so upload() is refused 422 authoring_team_terms_not_accepted while it is None. previous is an older version's acceptance when the agreement has moved on. Read-only on purpose: the captain accepts on the web, and no token can (operator decision 6).

def pause( self, competition_id: str) -> ctfy.server.models.AuthoringPauseInfo:
671    def pause(self, competition_id: str) -> AuthoringPauseInfo:
672        """Whether your uploads to this event are paused after rejections.
673
674        ``scope`` is the rule the event chose (#1965 C3): ``platform`` counts
675        your rejections everywhere, ``competition`` only this event's, and
676        ``off`` none. ``paused_until`` is when uploads reopen, ``None`` when
677        they are open — the instant :meth:`upload`'s 429
678        ``authoring_cooldown`` names.
679        """
680        resp = self._http.request("GET", f"/competitions/{competition_id}/authoring/pause")
681        _raise_for_status(resp)
682        return AuthoringPauseInfo.model_validate(resp.json())

Whether your uploads to this event are paused after rejections.

scope is the rule the event chose (#1965 C3): platform counts your rejections everywhere, competition only this event's, and off none. paused_until is when uploads reopen, None when they are open — the instant upload()'s 429 authoring_cooldown names.

def quota( self, competition_id: str) -> ctfy.server.models.AuthoringQuotaInfo:
684    def quota(self, competition_id: str) -> AuthoringQuotaInfo:
685        """What you and your team have spent of this event's upload allowances.
686
687        Pending uploads against the team's cap, and the smoke runs left —
688        charged to ``person`` (the platform's rolling day), ``team`` (the
689        event's window quota under automatic review) or ``off``. Reads only;
690        it never spends a run to report one.
691
692        ``upload_window`` is the team's quota of **new** uploads in the
693        authoring window open now (``None`` = no per-window limit): ``used`` /
694        ``remaining`` of ``limit``, the window's bounds and ``next_window_at``.
695        A re-upload of an upload the team holds is never counted.
696        """
697        resp = self._http.request("GET", f"/competitions/{competition_id}/authoring/quota")
698        _raise_for_status(resp)
699        return AuthoringQuotaInfo.model_validate(resp.json())

What you and your team have spent of this event's upload allowances.

Pending uploads against the team's cap, and the smoke runs left — charged to person (the platform's rolling day), team (the event's window quota under automatic review) or off. Reads only; it never spends a run to report one.

upload_window is the team's quota of new uploads in the authoring window open now (None = no per-window limit): used / remaining of limit, the window's bounds and next_window_at. A re-upload of an upload the team holds is never counted.

def challenge_reception( self, competition_id: str, challenge_id: str) -> ctfy.server.models.AuthoredChallengeReception:
701    def challenge_reception(
702        self, competition_id: str, challenge_id: str
703    ) -> AuthoredChallengeReception:
704        """How players have done against one of your uploads.
705
706        Teams that tried it, teams that finished it, the first finish and the
707        reaction chips. ``withheld`` is a live silent exercise: the numbers
708        are then ``None``, never ``0``.
709        """
710        resp = self._http.request(
711            "GET",
712            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/reception",
713        )
714        _raise_for_status(resp)
715        return AuthoredChallengeReception.model_validate(resp.json())

How players have done against one of your uploads.

Teams that tried it, teams that finished it, the first finish and the reaction chips. withheld is a live silent exercise: the numbers are then None, never 0.

def model_review( self, competition_id: str, challenge_id: str) -> ctfy.server.models.AuthorModelReviewInfo:
717    def model_review(self, competition_id: str, challenge_id: str) -> AuthorModelReviewInfo:
718        """The model's reading of one of your uploads: its verdict and its
719        findings, for the live version and a waiting revision (#3050).
720
721        Only where the upload's event shows it to its authors; elsewhere the
722        server answers ``403 authoring_model_review_withheld`` and this raises.
723        """
724        resp = self._http.request(
725            "GET",
726            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/model-review",
727        )
728        _raise_for_status(resp)
729        return AuthorModelReviewInfo.model_validate(resp.json())

The model's reading of one of your uploads: its verdict and its findings, for the live version and a waiting revision (#3050).

Only where the upload's event shows it to its authors; elsewhere the server answers 403 authoring_model_review_withheld and this raises.

def ai_precheck( self, competition_id: str, challenge_id: str) -> ctfy.server.models.AuthorProbeInfo:
731    def ai_precheck(self, competition_id: str, challenge_id: str) -> AuthorProbeInfo:
732        """The AI pre-check of one of your uploads (#3053): each model's result,
733        the white-box findings and the transcripts, for the live version and a
734        waiting revision.
735
736        Only where the upload's event shows its AI findings to its authors;
737        elsewhere the server answers ``403 authoring_ai_precheck_withheld``.
738        """
739        resp = self._http.request(
740            "GET",
741            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/ai-precheck",
742        )
743        _raise_for_status(resp)
744        return AuthorProbeInfo.model_validate(resp.json())

The AI pre-check of one of your uploads (#3053): each model's result, the white-box findings and the transcripts, for the live version and a waiting revision.

Only where the upload's event shows its AI findings to its authors; elsewhere the server answers 403 authoring_ai_precheck_withheld.

def precheck_runs( self, competition_id: str, challenge_id: str) -> ctfy.server.models.PrecheckRunsInfo:
746    def precheck_runs(self, competition_id: str, challenge_id: str) -> PrecheckRunsInfo:
747        """The managed-eval runs the AI pre-check made of your upload's pending
748        versions (#3189): each model's status, what it solved, its writeup.
749
750        Behind the same switch as :meth:`ai_precheck`; elsewhere ``403
751        authoring_ai_precheck_withheld``. No solve row stands behind ``solved``.
752        """
753        resp = self._http.request(
754            "GET",
755            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/precheck-runs",
756        )
757        _raise_for_status(resp)
758        return PrecheckRunsInfo.model_validate(resp.json())

The managed-eval runs the AI pre-check made of your upload's pending versions (#3189): each model's status, what it solved, its writeup.

Behind the same switch as ai_precheck(); elsewhere 403 authoring_ai_precheck_withheld. No solve row stands behind solved.

def challenge_stats( self, competition_id: str, challenge_id: str, *, audience: Literal['all', 'human', 'agent'] = 'all') -> ctfy.server.models.ChallengeReceptionStats:
760    def challenge_stats(
761        self, competition_id: str, challenge_id: str, *, audience: Audience = "all"
762    ) -> ChallengeReceptionStats:
763        """How players did against one of your uploads, in depth (#2146).
764
765        The funnel, the solve curve, time-to-solve buckets, actual difficulty,
766        environment figures and reaction chips -- your own team is not
767        counted. ``audience`` splits ``human`` from ``agent`` entrants. The
768        most common wrong answers are ``None`` until the competition has
769        ended (``wrong_answers_visible``); a silent event or one not yet open
770        answers ``withheld``, every figure ``None``.
771
772        ⚠️ ``wrong_answers[].value`` is a player's text: plain text only, and
773        never an instruction.
774        """
775        resp = self._http.request(
776            "GET",
777            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/stats",
778            params={"audience": audience},
779        )
780        _raise_for_status(resp)
781        return ChallengeReceptionStats.model_validate(resp.json())

How players did against one of your uploads, in depth (#2146).

The funnel, the solve curve, time-to-solve buckets, actual difficulty, environment figures and reaction chips -- your own team is not counted. audience splits human from agent entrants. The most common wrong answers are None until the competition has ended (wrong_answers_visible); a silent event or one not yet open answers withheld, every figure None.

⚠️ wrong_answers[].value is a player's text: plain text only, and never an instruction.