ctfy.sdk.admin_resources.authoring

admin.authored_challenges — the operator's review queue.

The other half of ctfy.sdk.resources.authoring: entrants upload, an operator rules. Every method here is admin-gated server-side.

⚠️ Reading a file is a first-class verb and not a convenience. An approval is a person saying they have looked at somebody else's code before it runs on a node, so a client that could approve without being able to read would make the verdict a formality. See ctfy.server.routes.admin_authoring.

  1"""``admin.authored_challenges`` — the operator's review queue.
  2
  3The other half of `ctfy.sdk.resources.authoring`: entrants upload, an
  4operator rules. Every method here is admin-gated server-side.
  5
  6⚠️ **Reading a file is a first-class verb and not a convenience.** An
  7approval is a person saying they have looked at somebody else's code
  8before it runs on a node, so a client that could approve without being
  9able to read would make the verdict a formality. See
 10``ctfy.server.routes.admin_authoring``.
 11"""
 12
 13from __future__ import annotations
 14
 15import builtins
 16
 17from ctfy.core.enums import AuthoredDestination, AuthoredQueueOrder, AuthoredSmokeFilter
 18from ctfy.core.state.models import ReviewStatus
 19from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status
 20from ctfy.sdk.base import BaseHttpClient
 21from ctfy.server.models import (
 22    AdminAuthoredChallengeDetail,
 23    AdminAuthoredChallengeRow,
 24    AdminAuthoredRevisionRow,
 25    AdminTaskInfo,
 26    AuthoredAuditReport,
 27    AuthoredBuildCapability,
 28    AuthoredChallengeFileBody,
 29    AuthoredGraduationInfo,
 30    AuthoredRevisionDiff,
 31)
 32
 33
 34class AdminAuthoredChallengesResource:
 35    """List, read and rule on player-authored uploads."""
 36
 37    def __init__(self, http: BaseHttpClient) -> None:
 38        self._http = http
 39
 40    def list(
 41        self,
 42        *,
 43        competition_id: str | None = None,
 44        team_id: str | None = None,
 45        review_status: ReviewStatus | None = None,
 46        smoke: AuthoredSmokeFilter | None = None,
 47        revision: ReviewStatus | None = None,
 48        order: AuthoredQueueOrder | None = None,
 49        destination: AuthoredDestination | None = None,
 50        offset: int = 0,
 51        limit: int = 50,
 52    ) -> PagedList[AdminAuthoredChallengeRow]:
 53        """The queue, oldest first.
 54
 55        ``None`` on a filter means *every*; ``""`` is a value, so
 56        passing it asks for the standalone catalog rather than for
 57        everything.
 58
 59        ``smoke`` narrows by what the smoke run found: a verdict,
 60        ``not_run`` for none yet, or ``in_flight`` for a run queued or
 61        running (each row's ``smoke_in_flight`` says which). The total is
 62        counted over the same filter.
 63
 64        ``revision`` narrows to uploads whose proposed revision carries
 65        that verdict (plan P3.1): ``pending`` waits for a reviewer,
 66        ``approved`` for :meth:`apply_revision`.
 67
 68        ``order="reputation"`` (plan P3.5) puts first the uploads of
 69        authors whose published challenges players received best — each
 70        row's ``author_reputation`` — then every upload with no reading,
 71        oldest first; ``None`` leaves the server's order, oldest first. It
 72        is ordered before the page is cut, so ``offset`` pages through
 73        that order.
 74
 75        ``destination`` narrows to where the author sent the upload (plan
 76        P3.4): ``public`` or ``eval_only``, the evaluation pool.
 77        """
 78        params: dict[str, object] = {"offset": offset, "limit": limit}
 79        if competition_id is not None:
 80            params["competition_id"] = competition_id
 81        if team_id is not None:
 82            params["team_id"] = team_id
 83        if review_status is not None:
 84            params["review_status"] = review_status
 85        if smoke is not None:
 86            params["smoke"] = smoke
 87        if revision is not None:
 88            params["revision"] = revision
 89        if order is not None:
 90            params["order"] = order
 91        if destination is not None:
 92            params["destination"] = destination
 93        resp = self._http.request("GET", "/admin/authored-challenges", params=params)
 94        _raise_for_status(resp)
 95        return _extract_items(resp.json(), AdminAuthoredChallengeRow)
 96
 97    def build_capability(self) -> AuthoredBuildCapability:
 98        """Whether an approval on this deployment can become a live challenge.
 99
100        ⚠️ **Ask this before reading a verdict as a deployment.** An
101        ``approved`` row is a person saying they read the code; it is not
102        a challenge in front of anybody until this answers ``can_build``.
103
104        ⚠️ **This line used to end "and today it never does".** That was
105        true until `authored_sync` landed and is not any more: with a
106        node marked ``untrusted_builds`` the answer is now ``True``, and
107        a caller that still reads it as a constant would skip the one
108        check that distinguishes a deployment which can run an approved
109        upload from one which cannot.
110
111        ⚠️ **It answers for the deployment, never for a tree.** Whether
112        *this* challenge reached a node is `push`'s task and its
113        per-node lines.
114        """
115        resp = self._http.request("GET", "/admin/authoring/build-capability")
116        _raise_for_status(resp)
117        return AuthoredBuildCapability.model_validate(resp.json())
118
119    def get(self, challenge_id: str) -> AdminAuthoredChallengeDetail:
120        """One upload, with the paths and sizes of what is in it."""
121        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}")
122        _raise_for_status(resp)
123        return AdminAuthoredChallengeDetail.model_validate(resp.json())
124
125    def audit(self, challenge_id: str) -> AuthoredAuditReport:
126        """Run the challenge audit over this upload and report it.
127
128        ⚠️ **Evidence, never a gate.** :meth:`review` does not consult
129        this: a rule that refused an approval on a finding would make
130        the operator's judgement the audit's to overrule, and the audit
131        cannot see inside a `Dockerfile`'s `RUN` — which is the half a
132        person is reading the files for.
133
134        ⚠️ Read ``outcome`` before the counts. An empty ``findings``
135        list means *nothing was found* only when it says ``ok``;
136        ``timed_out`` and ``failed`` are the audit declining to answer,
137        and reading either as clean is the whole reason the field
138        exists.
139        """
140        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/audit")
141        _raise_for_status(resp)
142        return AuthoredAuditReport.model_validate(resp.json())
143
144    def read_file(self, challenge_id: str, path: str) -> AuthoredChallengeFileBody:
145        """One file's contents.
146
147        ⚠️ The body is **attacker-authored text written to be run**.
148        ``binary`` and ``truncated`` say what you are not looking at;
149        treat a ``False`` on either as the only claim that you are.
150        """
151        # ⚠️ The path rides the body, never the URL (#1929): a WAF that
152        # refuses a URL ending in ``.ini`` does not read a JSON body.
153        resp = self._http.request(
154            "POST", f"/admin/authored-challenges/{challenge_id}/file", json={"path": path}
155        )
156        _raise_for_status(resp)
157        return AuthoredChallengeFileBody.model_validate(resp.json())
158
159    def graduation(self, challenge_id: str, corpus_id: str = "") -> AuthoredGraduationInfo:
160        """Whether an upload may graduate into the corpus, and as what (plan P3.3).
161
162        ``refusals`` names every reason it may not yet — empty when it may.
163        ``corpus_id`` is the id it would take: the one passed, or else the
164        next ``COMMUNITY-NNN`` this deployment's catalog does not hold. The
165        pull request's title and body are what a person opens it with.
166        """
167        params = {"corpus_id": corpus_id} if corpus_id else None
168        resp = self._http.request(
169            "GET", f"/admin/authored-challenges/{challenge_id}/graduation", params=params
170        )
171        _raise_for_status(resp)
172        return AuthoredGraduationInfo.model_validate(resp.json())
173
174    def graduation_bundle(self, challenge_id: str, corpus_id: str) -> bytes:
175        """The ``.tar.gz`` to unpack at the corpus repository's root.
176
177        It holds ``COMMUNITY/<corpus_id>/``: the tree as reviewed, the
178        ``LICENSE`` its author declared and a ``CREDITS.md``. Nothing is
179        recorded on the platform — opening the pull request is a person's
180        act, in another repository.
181        """
182        resp = self._http.request(
183            "GET",
184            f"/admin/authored-challenges/{challenge_id}/graduation/bundle",
185            params={"corpus_id": corpus_id},
186        )
187        _raise_for_status(resp)
188        return resp.content
189
190    def review(
191        self, challenge_id: str, *, status: ReviewStatus, note: str = ""
192    ) -> AdminAuthoredChallengeRow:
193        """Record a verdict. ``rejected`` requires a note.
194
195        ``pending`` withdraws a verdict and clears its trail — a
196        mis-clicked approval is undoable without deleting the upload.
197        """
198        resp = self._http.request(
199            "POST",
200            f"/admin/authored-challenges/{challenge_id}/review",
201            json={"status": status, "note": note},
202        )
203        _raise_for_status(resp)
204        return AdminAuthoredChallengeRow.model_validate(resp.json())
205
206    def push(self, challenge_id: str) -> AdminTaskInfo:
207        """Send an approved upload to every node marked for untrusted builds.
208
209        ⚠️ **The verb after `publish`, and they are not the same act.**
210        `publish` writes the tree into *this deployment's* catalog root;
211        a multi-host node's catalog is a different directory on a
212        different machine, and until this runs the node answers 404 for
213        the id the platform now lists.
214
215        Returns the task: the push is per node, and which node refused
216        is on its log rather than in one summary line.
217        """
218        resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/push")
219        _raise_for_status(resp)
220        return AdminTaskInfo.model_validate(resp.json())
221
222    def smoke(self, challenge_id: str) -> AdminTaskInfo:
223        """Run this upload's own reference exploit against it, on a node.
224
225        ⚠️ **The machine check an approval cannot make.** The audit is
226        static and a reviewer reads files; only a run says whether the
227        estate comes up and the exploit captures what it claims.
228
229        Returns the task — the verdict is on its log, and it is
230        deliberately four-valued: *solved*, *unsolved*, *estate_failed*,
231        *malformed*, plus *errored* for a node that could not tell.
232        """
233        resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/smoke")
234        _raise_for_status(resp)
235        return AdminTaskInfo.model_validate(resp.json())
236
237    def publish(
238        self, challenge_id: str, *, exec_surface_reviewed: bool = False
239    ) -> AdminAuthoredChallengeRow:
240        """Write an approved upload into the deployment's catalog.
241
242        A separate verb from :meth:`review` on purpose: approving is a
243        person's verdict on somebody else's code, and it must not fail
244        because a corpus commit landed on the id in the days since. The
245        409 this raises carries every reason at once — the id is taken,
246        the row is not approved, the tree has no ``metadata.yaml`` — so a
247        caller fixes them in one pass rather than one press at a time.
248
249        ``exec_surface_reviewed`` confirms you read the files the tree
250        runs on a node or hands players to run (``executes`` on
251        :meth:`get`'s file list, plan P2.3). A tree with such files is
252        refused without it — once; the stamp then records who read it.
253
254        The row that comes back is **read back from the store**, so its
255        ``in_catalog`` is a fact about the disk after the write.
256        """
257        resp = self._http.request(
258            "POST",
259            f"/admin/authored-challenges/{challenge_id}/publish",
260            # Only what was said: absent is the server's own default.
261            params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None,
262        )
263        _raise_for_status(resp)
264        return AdminAuthoredChallengeRow.model_validate(resp.json())
265
266    def unpublish(self, challenge_id: str) -> AdminAuthoredChallengeRow:
267        """Take a published challenge back out of the deployment's catalog.
268
269        :meth:`publish` had no inverse, so an upload that turned out to
270        leak its own answer stayed in front of every entrant and the only
271        recourse was a shell on the platform host.
272
273        It deletes the *publication*, never the row: the author's tree,
274        the reviewer's verdict and the smoke result all survive, and
275        publishing again is the same call it always was. Entrants already
276        holding a box keep it until its own TTL — what closes is the
277        door, so nobody new can launch.
278
279        Raises 409 when the deployment's catalog does not hold the id,
280        which is the mirror of publishing something already published.
281        """
282        resp = self._http.request("DELETE", f"/admin/authored-challenges/{challenge_id}/publish")
283        _raise_for_status(resp)
284        return AdminAuthoredChallengeRow.model_validate(resp.json())
285
286    def curate(self, challenge_id: str, competition_id: str = "") -> AdminAuthoredChallengeRow:
287        """Put a published upload in front of this competition's entrants.
288
289        ⚠️ **The step after `publish`, and the two are not one act.**
290        Publishing writes the tree into this deployment's catalog;
291        curating adds its id to *one competition's* challenge set. Until
292        this existed the only way to take it was the competition edit
293        form, which re-sends the whole list — so two organisers working a
294        review queue dropped each other's additions. This appends.
295
296        Refused (409, with reasons) for a competition whose set comes
297        from a catalog bucket, and for an id this deployment has not
298        published: the second would put a challenge in front of entrants
299        that no node can start.
300
301        ``competition_id`` names another competition to curate it into —
302        the workshop → arena step — and every refusal is then asked of
303        that one. Empty is the upload's own competition.
304        """
305        resp = self._http.request(
306            "POST",
307            f"/admin/authored-challenges/{challenge_id}/curation",
308            params={"competition_id": competition_id} if competition_id else None,
309        )
310        _raise_for_status(resp)
311        return AdminAuthoredChallengeRow.model_validate(resp.json())
312
313    def uncurate(self, challenge_id: str, competition_id: str = "") -> AdminAuthoredChallengeRow:
314        """Take an upload back out of this competition's challenge set.
315
316        Carries neither of :meth:`curate`'s refusals: removing an id from
317        a set that should not hold it is the repair, so gating it on the
318        same conditions would keep the broken state. ``competition_id``
319        names which competition; empty is the upload's own.
320        """
321        resp = self._http.request(
322            "DELETE",
323            f"/admin/authored-challenges/{challenge_id}/curation",
324            params={"competition_id": competition_id} if competition_id else None,
325        )
326        _raise_for_status(resp)
327        return AdminAuthoredChallengeRow.model_validate(resp.json())
328
329    def revisions(self, challenge_id: str) -> builtins.list[AdminAuthoredRevisionRow]:
330        """Every revision of one upload, newest first — the proposal, if
331        any, and the history of those applied (plan P3.1)."""
332        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/revisions")
333        _raise_for_status(resp)
334        return [AdminAuthoredRevisionRow.model_validate(row) for row in resp.json()]
335
336    def revision_diff(self, challenge_id: str, revision_id: str) -> AuthoredRevisionDiff:
337        """A proposed revision against the live tree — what a reviewer reads.
338
339        ⚠️ A changed file with an empty ``diff`` says why in ``omitted``
340        (``binary``, ``too_large``, ``over_budget``); read such a file
341        whole with :meth:`read_revision_file`. ``needs_exec_review`` says
342        whether :meth:`apply_revision` will ask you to confirm the
343        execution-surface review.
344        """
345        resp = self._http.request(
346            "GET", f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/diff"
347        )
348        _raise_for_status(resp)
349        return AuthoredRevisionDiff.model_validate(resp.json())
350
351    def read_revision_file(
352        self, challenge_id: str, revision_id: str, path: str
353    ) -> AuthoredChallengeFileBody:
354        """One file of a revision, whole — attacker-authored text written
355        to be run, like :meth:`read_file`'s."""
356        resp = self._http.request(
357            "POST",
358            f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/file",
359            json={"path": path},
360        )
361        _raise_for_status(resp)
362        return AuthoredChallengeFileBody.model_validate(resp.json())
363
364    def review_revision(
365        self, challenge_id: str, revision_id: str, *, status: ReviewStatus, note: str = ""
366    ) -> AdminAuthoredRevisionRow:
367        """Record a verdict on a proposed revision. ``rejected`` requires a
368        note and ``pending`` withdraws a verdict, as for an upload. An
369        applied revision is history and is refused (409)."""
370        resp = self._http.request(
371            "POST",
372            f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/review",
373            json={"status": status, "note": note},
374        )
375        _raise_for_status(resp)
376        return AdminAuthoredRevisionRow.model_validate(resp.json())
377
378    def apply_revision(
379        self, challenge_id: str, revision_id: str, *, exec_surface_reviewed: bool = False
380    ) -> AdminAuthoredChallengeRow:
381        """Make an approved revision the upload's live version (plan P3.1).
382
383        A published upload's directory is replaced whole — boxes already
384        running keep the bytes they started with — and the catalog is
385        reloaded. Nodes that hold the upload keep the previous tree until
386        :meth:`push` sends the new one.
387
388        ``exec_surface_reviewed`` confirms you read the revision's files
389        marked ``executes``: a published upload whose revision has any is
390        refused without it, every time — the bytes are new. The 409 carries
391        every reason at once.
392        """
393        resp = self._http.request(
394            "POST",
395            f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/apply",
396            params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None,
397        )
398        _raise_for_status(resp)
399        return AdminAuthoredChallengeRow.model_validate(resp.json())
class AdminAuthoredChallengesResource:
 35class AdminAuthoredChallengesResource:
 36    """List, read and rule on player-authored uploads."""
 37
 38    def __init__(self, http: BaseHttpClient) -> None:
 39        self._http = http
 40
 41    def list(
 42        self,
 43        *,
 44        competition_id: str | None = None,
 45        team_id: str | None = None,
 46        review_status: ReviewStatus | None = None,
 47        smoke: AuthoredSmokeFilter | None = None,
 48        revision: ReviewStatus | None = None,
 49        order: AuthoredQueueOrder | None = None,
 50        destination: AuthoredDestination | None = None,
 51        offset: int = 0,
 52        limit: int = 50,
 53    ) -> PagedList[AdminAuthoredChallengeRow]:
 54        """The queue, oldest first.
 55
 56        ``None`` on a filter means *every*; ``""`` is a value, so
 57        passing it asks for the standalone catalog rather than for
 58        everything.
 59
 60        ``smoke`` narrows by what the smoke run found: a verdict,
 61        ``not_run`` for none yet, or ``in_flight`` for a run queued or
 62        running (each row's ``smoke_in_flight`` says which). The total is
 63        counted over the same filter.
 64
 65        ``revision`` narrows to uploads whose proposed revision carries
 66        that verdict (plan P3.1): ``pending`` waits for a reviewer,
 67        ``approved`` for :meth:`apply_revision`.
 68
 69        ``order="reputation"`` (plan P3.5) puts first the uploads of
 70        authors whose published challenges players received best — each
 71        row's ``author_reputation`` — then every upload with no reading,
 72        oldest first; ``None`` leaves the server's order, oldest first. It
 73        is ordered before the page is cut, so ``offset`` pages through
 74        that order.
 75
 76        ``destination`` narrows to where the author sent the upload (plan
 77        P3.4): ``public`` or ``eval_only``, the evaluation pool.
 78        """
 79        params: dict[str, object] = {"offset": offset, "limit": limit}
 80        if competition_id is not None:
 81            params["competition_id"] = competition_id
 82        if team_id is not None:
 83            params["team_id"] = team_id
 84        if review_status is not None:
 85            params["review_status"] = review_status
 86        if smoke is not None:
 87            params["smoke"] = smoke
 88        if revision is not None:
 89            params["revision"] = revision
 90        if order is not None:
 91            params["order"] = order
 92        if destination is not None:
 93            params["destination"] = destination
 94        resp = self._http.request("GET", "/admin/authored-challenges", params=params)
 95        _raise_for_status(resp)
 96        return _extract_items(resp.json(), AdminAuthoredChallengeRow)
 97
 98    def build_capability(self) -> AuthoredBuildCapability:
 99        """Whether an approval on this deployment can become a live challenge.
100
101        ⚠️ **Ask this before reading a verdict as a deployment.** An
102        ``approved`` row is a person saying they read the code; it is not
103        a challenge in front of anybody until this answers ``can_build``.
104
105        ⚠️ **This line used to end "and today it never does".** That was
106        true until `authored_sync` landed and is not any more: with a
107        node marked ``untrusted_builds`` the answer is now ``True``, and
108        a caller that still reads it as a constant would skip the one
109        check that distinguishes a deployment which can run an approved
110        upload from one which cannot.
111
112        ⚠️ **It answers for the deployment, never for a tree.** Whether
113        *this* challenge reached a node is `push`'s task and its
114        per-node lines.
115        """
116        resp = self._http.request("GET", "/admin/authoring/build-capability")
117        _raise_for_status(resp)
118        return AuthoredBuildCapability.model_validate(resp.json())
119
120    def get(self, challenge_id: str) -> AdminAuthoredChallengeDetail:
121        """One upload, with the paths and sizes of what is in it."""
122        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}")
123        _raise_for_status(resp)
124        return AdminAuthoredChallengeDetail.model_validate(resp.json())
125
126    def audit(self, challenge_id: str) -> AuthoredAuditReport:
127        """Run the challenge audit over this upload and report it.
128
129        ⚠️ **Evidence, never a gate.** :meth:`review` does not consult
130        this: a rule that refused an approval on a finding would make
131        the operator's judgement the audit's to overrule, and the audit
132        cannot see inside a `Dockerfile`'s `RUN` — which is the half a
133        person is reading the files for.
134
135        ⚠️ Read ``outcome`` before the counts. An empty ``findings``
136        list means *nothing was found* only when it says ``ok``;
137        ``timed_out`` and ``failed`` are the audit declining to answer,
138        and reading either as clean is the whole reason the field
139        exists.
140        """
141        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/audit")
142        _raise_for_status(resp)
143        return AuthoredAuditReport.model_validate(resp.json())
144
145    def read_file(self, challenge_id: str, path: str) -> AuthoredChallengeFileBody:
146        """One file's contents.
147
148        ⚠️ The body is **attacker-authored text written to be run**.
149        ``binary`` and ``truncated`` say what you are not looking at;
150        treat a ``False`` on either as the only claim that you are.
151        """
152        # ⚠️ The path rides the body, never the URL (#1929): a WAF that
153        # refuses a URL ending in ``.ini`` does not read a JSON body.
154        resp = self._http.request(
155            "POST", f"/admin/authored-challenges/{challenge_id}/file", json={"path": path}
156        )
157        _raise_for_status(resp)
158        return AuthoredChallengeFileBody.model_validate(resp.json())
159
160    def graduation(self, challenge_id: str, corpus_id: str = "") -> AuthoredGraduationInfo:
161        """Whether an upload may graduate into the corpus, and as what (plan P3.3).
162
163        ``refusals`` names every reason it may not yet — empty when it may.
164        ``corpus_id`` is the id it would take: the one passed, or else the
165        next ``COMMUNITY-NNN`` this deployment's catalog does not hold. The
166        pull request's title and body are what a person opens it with.
167        """
168        params = {"corpus_id": corpus_id} if corpus_id else None
169        resp = self._http.request(
170            "GET", f"/admin/authored-challenges/{challenge_id}/graduation", params=params
171        )
172        _raise_for_status(resp)
173        return AuthoredGraduationInfo.model_validate(resp.json())
174
175    def graduation_bundle(self, challenge_id: str, corpus_id: str) -> bytes:
176        """The ``.tar.gz`` to unpack at the corpus repository's root.
177
178        It holds ``COMMUNITY/<corpus_id>/``: the tree as reviewed, the
179        ``LICENSE`` its author declared and a ``CREDITS.md``. Nothing is
180        recorded on the platform — opening the pull request is a person's
181        act, in another repository.
182        """
183        resp = self._http.request(
184            "GET",
185            f"/admin/authored-challenges/{challenge_id}/graduation/bundle",
186            params={"corpus_id": corpus_id},
187        )
188        _raise_for_status(resp)
189        return resp.content
190
191    def review(
192        self, challenge_id: str, *, status: ReviewStatus, note: str = ""
193    ) -> AdminAuthoredChallengeRow:
194        """Record a verdict. ``rejected`` requires a note.
195
196        ``pending`` withdraws a verdict and clears its trail — a
197        mis-clicked approval is undoable without deleting the upload.
198        """
199        resp = self._http.request(
200            "POST",
201            f"/admin/authored-challenges/{challenge_id}/review",
202            json={"status": status, "note": note},
203        )
204        _raise_for_status(resp)
205        return AdminAuthoredChallengeRow.model_validate(resp.json())
206
207    def push(self, challenge_id: str) -> AdminTaskInfo:
208        """Send an approved upload to every node marked for untrusted builds.
209
210        ⚠️ **The verb after `publish`, and they are not the same act.**
211        `publish` writes the tree into *this deployment's* catalog root;
212        a multi-host node's catalog is a different directory on a
213        different machine, and until this runs the node answers 404 for
214        the id the platform now lists.
215
216        Returns the task: the push is per node, and which node refused
217        is on its log rather than in one summary line.
218        """
219        resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/push")
220        _raise_for_status(resp)
221        return AdminTaskInfo.model_validate(resp.json())
222
223    def smoke(self, challenge_id: str) -> AdminTaskInfo:
224        """Run this upload's own reference exploit against it, on a node.
225
226        ⚠️ **The machine check an approval cannot make.** The audit is
227        static and a reviewer reads files; only a run says whether the
228        estate comes up and the exploit captures what it claims.
229
230        Returns the task — the verdict is on its log, and it is
231        deliberately four-valued: *solved*, *unsolved*, *estate_failed*,
232        *malformed*, plus *errored* for a node that could not tell.
233        """
234        resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/smoke")
235        _raise_for_status(resp)
236        return AdminTaskInfo.model_validate(resp.json())
237
238    def publish(
239        self, challenge_id: str, *, exec_surface_reviewed: bool = False
240    ) -> AdminAuthoredChallengeRow:
241        """Write an approved upload into the deployment's catalog.
242
243        A separate verb from :meth:`review` on purpose: approving is a
244        person's verdict on somebody else's code, and it must not fail
245        because a corpus commit landed on the id in the days since. The
246        409 this raises carries every reason at once — the id is taken,
247        the row is not approved, the tree has no ``metadata.yaml`` — so a
248        caller fixes them in one pass rather than one press at a time.
249
250        ``exec_surface_reviewed`` confirms you read the files the tree
251        runs on a node or hands players to run (``executes`` on
252        :meth:`get`'s file list, plan P2.3). A tree with such files is
253        refused without it — once; the stamp then records who read it.
254
255        The row that comes back is **read back from the store**, so its
256        ``in_catalog`` is a fact about the disk after the write.
257        """
258        resp = self._http.request(
259            "POST",
260            f"/admin/authored-challenges/{challenge_id}/publish",
261            # Only what was said: absent is the server's own default.
262            params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None,
263        )
264        _raise_for_status(resp)
265        return AdminAuthoredChallengeRow.model_validate(resp.json())
266
267    def unpublish(self, challenge_id: str) -> AdminAuthoredChallengeRow:
268        """Take a published challenge back out of the deployment's catalog.
269
270        :meth:`publish` had no inverse, so an upload that turned out to
271        leak its own answer stayed in front of every entrant and the only
272        recourse was a shell on the platform host.
273
274        It deletes the *publication*, never the row: the author's tree,
275        the reviewer's verdict and the smoke result all survive, and
276        publishing again is the same call it always was. Entrants already
277        holding a box keep it until its own TTL — what closes is the
278        door, so nobody new can launch.
279
280        Raises 409 when the deployment's catalog does not hold the id,
281        which is the mirror of publishing something already published.
282        """
283        resp = self._http.request("DELETE", f"/admin/authored-challenges/{challenge_id}/publish")
284        _raise_for_status(resp)
285        return AdminAuthoredChallengeRow.model_validate(resp.json())
286
287    def curate(self, challenge_id: str, competition_id: str = "") -> AdminAuthoredChallengeRow:
288        """Put a published upload in front of this competition's entrants.
289
290        ⚠️ **The step after `publish`, and the two are not one act.**
291        Publishing writes the tree into this deployment's catalog;
292        curating adds its id to *one competition's* challenge set. Until
293        this existed the only way to take it was the competition edit
294        form, which re-sends the whole list — so two organisers working a
295        review queue dropped each other's additions. This appends.
296
297        Refused (409, with reasons) for a competition whose set comes
298        from a catalog bucket, and for an id this deployment has not
299        published: the second would put a challenge in front of entrants
300        that no node can start.
301
302        ``competition_id`` names another competition to curate it into —
303        the workshop → arena step — and every refusal is then asked of
304        that one. Empty is the upload's own competition.
305        """
306        resp = self._http.request(
307            "POST",
308            f"/admin/authored-challenges/{challenge_id}/curation",
309            params={"competition_id": competition_id} if competition_id else None,
310        )
311        _raise_for_status(resp)
312        return AdminAuthoredChallengeRow.model_validate(resp.json())
313
314    def uncurate(self, challenge_id: str, competition_id: str = "") -> AdminAuthoredChallengeRow:
315        """Take an upload back out of this competition's challenge set.
316
317        Carries neither of :meth:`curate`'s refusals: removing an id from
318        a set that should not hold it is the repair, so gating it on the
319        same conditions would keep the broken state. ``competition_id``
320        names which competition; empty is the upload's own.
321        """
322        resp = self._http.request(
323            "DELETE",
324            f"/admin/authored-challenges/{challenge_id}/curation",
325            params={"competition_id": competition_id} if competition_id else None,
326        )
327        _raise_for_status(resp)
328        return AdminAuthoredChallengeRow.model_validate(resp.json())
329
330    def revisions(self, challenge_id: str) -> builtins.list[AdminAuthoredRevisionRow]:
331        """Every revision of one upload, newest first — the proposal, if
332        any, and the history of those applied (plan P3.1)."""
333        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/revisions")
334        _raise_for_status(resp)
335        return [AdminAuthoredRevisionRow.model_validate(row) for row in resp.json()]
336
337    def revision_diff(self, challenge_id: str, revision_id: str) -> AuthoredRevisionDiff:
338        """A proposed revision against the live tree — what a reviewer reads.
339
340        ⚠️ A changed file with an empty ``diff`` says why in ``omitted``
341        (``binary``, ``too_large``, ``over_budget``); read such a file
342        whole with :meth:`read_revision_file`. ``needs_exec_review`` says
343        whether :meth:`apply_revision` will ask you to confirm the
344        execution-surface review.
345        """
346        resp = self._http.request(
347            "GET", f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/diff"
348        )
349        _raise_for_status(resp)
350        return AuthoredRevisionDiff.model_validate(resp.json())
351
352    def read_revision_file(
353        self, challenge_id: str, revision_id: str, path: str
354    ) -> AuthoredChallengeFileBody:
355        """One file of a revision, whole — attacker-authored text written
356        to be run, like :meth:`read_file`'s."""
357        resp = self._http.request(
358            "POST",
359            f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/file",
360            json={"path": path},
361        )
362        _raise_for_status(resp)
363        return AuthoredChallengeFileBody.model_validate(resp.json())
364
365    def review_revision(
366        self, challenge_id: str, revision_id: str, *, status: ReviewStatus, note: str = ""
367    ) -> AdminAuthoredRevisionRow:
368        """Record a verdict on a proposed revision. ``rejected`` requires a
369        note and ``pending`` withdraws a verdict, as for an upload. An
370        applied revision is history and is refused (409)."""
371        resp = self._http.request(
372            "POST",
373            f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/review",
374            json={"status": status, "note": note},
375        )
376        _raise_for_status(resp)
377        return AdminAuthoredRevisionRow.model_validate(resp.json())
378
379    def apply_revision(
380        self, challenge_id: str, revision_id: str, *, exec_surface_reviewed: bool = False
381    ) -> AdminAuthoredChallengeRow:
382        """Make an approved revision the upload's live version (plan P3.1).
383
384        A published upload's directory is replaced whole — boxes already
385        running keep the bytes they started with — and the catalog is
386        reloaded. Nodes that hold the upload keep the previous tree until
387        :meth:`push` sends the new one.
388
389        ``exec_surface_reviewed`` confirms you read the revision's files
390        marked ``executes``: a published upload whose revision has any is
391        refused without it, every time — the bytes are new. The 409 carries
392        every reason at once.
393        """
394        resp = self._http.request(
395            "POST",
396            f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/apply",
397            params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None,
398        )
399        _raise_for_status(resp)
400        return AdminAuthoredChallengeRow.model_validate(resp.json())

List, read and rule on player-authored uploads.

AdminAuthoredChallengesResource(http: ctfy.sdk.base.BaseHttpClient)
38    def __init__(self, http: BaseHttpClient) -> None:
39        self._http = http
def list( self, *, competition_id: str | None = None, team_id: str | None = None, review_status: Optional[Literal['pending', 'approved', 'rejected']] = None, smoke: Union[Literal['not_run', 'in_flight'], Literal['solved', 'unsolved', 'estate_failed', 'malformed', 'errored'], NoneType] = None, revision: Optional[Literal['pending', 'approved', 'rejected']] = None, order: Optional[Literal['oldest', 'reputation']] = None, destination: Optional[Literal['public', 'eval_only']] = None, offset: int = 0, limit: int = 50) -> ctfy.sdk._helpers.PagedList[ctfy.server.models.AdminAuthoredChallengeRow]:
41    def list(
42        self,
43        *,
44        competition_id: str | None = None,
45        team_id: str | None = None,
46        review_status: ReviewStatus | None = None,
47        smoke: AuthoredSmokeFilter | None = None,
48        revision: ReviewStatus | None = None,
49        order: AuthoredQueueOrder | None = None,
50        destination: AuthoredDestination | None = None,
51        offset: int = 0,
52        limit: int = 50,
53    ) -> PagedList[AdminAuthoredChallengeRow]:
54        """The queue, oldest first.
55
56        ``None`` on a filter means *every*; ``""`` is a value, so
57        passing it asks for the standalone catalog rather than for
58        everything.
59
60        ``smoke`` narrows by what the smoke run found: a verdict,
61        ``not_run`` for none yet, or ``in_flight`` for a run queued or
62        running (each row's ``smoke_in_flight`` says which). The total is
63        counted over the same filter.
64
65        ``revision`` narrows to uploads whose proposed revision carries
66        that verdict (plan P3.1): ``pending`` waits for a reviewer,
67        ``approved`` for :meth:`apply_revision`.
68
69        ``order="reputation"`` (plan P3.5) puts first the uploads of
70        authors whose published challenges players received best — each
71        row's ``author_reputation`` — then every upload with no reading,
72        oldest first; ``None`` leaves the server's order, oldest first. It
73        is ordered before the page is cut, so ``offset`` pages through
74        that order.
75
76        ``destination`` narrows to where the author sent the upload (plan
77        P3.4): ``public`` or ``eval_only``, the evaluation pool.
78        """
79        params: dict[str, object] = {"offset": offset, "limit": limit}
80        if competition_id is not None:
81            params["competition_id"] = competition_id
82        if team_id is not None:
83            params["team_id"] = team_id
84        if review_status is not None:
85            params["review_status"] = review_status
86        if smoke is not None:
87            params["smoke"] = smoke
88        if revision is not None:
89            params["revision"] = revision
90        if order is not None:
91            params["order"] = order
92        if destination is not None:
93            params["destination"] = destination
94        resp = self._http.request("GET", "/admin/authored-challenges", params=params)
95        _raise_for_status(resp)
96        return _extract_items(resp.json(), AdminAuthoredChallengeRow)

The queue, oldest first.

None on a filter means every; "" is a value, so passing it asks for the standalone catalog rather than for everything.

smoke narrows by what the smoke run found: a verdict, not_run for none yet, or in_flight for a run queued or running (each row's smoke_in_flight says which). The total is counted over the same filter.

revision narrows to uploads whose proposed revision carries that verdict (plan P3.1): pending waits for a reviewer, approved for apply_revision().

order="reputation" (plan P3.5) puts first the uploads of authors whose published challenges players received best — each row's author_reputation — then every upload with no reading, oldest first; None leaves the server's order, oldest first. It is ordered before the page is cut, so offset pages through that order.

destination narrows to where the author sent the upload (plan P3.4): public or eval_only, the evaluation pool.

def build_capability(self) -> ctfy.server.models.AuthoredBuildCapability:
 98    def build_capability(self) -> AuthoredBuildCapability:
 99        """Whether an approval on this deployment can become a live challenge.
100
101        ⚠️ **Ask this before reading a verdict as a deployment.** An
102        ``approved`` row is a person saying they read the code; it is not
103        a challenge in front of anybody until this answers ``can_build``.
104
105        ⚠️ **This line used to end "and today it never does".** That was
106        true until `authored_sync` landed and is not any more: with a
107        node marked ``untrusted_builds`` the answer is now ``True``, and
108        a caller that still reads it as a constant would skip the one
109        check that distinguishes a deployment which can run an approved
110        upload from one which cannot.
111
112        ⚠️ **It answers for the deployment, never for a tree.** Whether
113        *this* challenge reached a node is `push`'s task and its
114        per-node lines.
115        """
116        resp = self._http.request("GET", "/admin/authoring/build-capability")
117        _raise_for_status(resp)
118        return AuthoredBuildCapability.model_validate(resp.json())

Whether an approval on this deployment can become a live challenge.

⚠️ Ask this before reading a verdict as a deployment. An approved row is a person saying they read the code; it is not a challenge in front of anybody until this answers can_build.

⚠️ This line used to end "and today it never does". That was true until authored_sync landed and is not any more: with a node marked untrusted_builds the answer is now True, and a caller that still reads it as a constant would skip the one check that distinguishes a deployment which can run an approved upload from one which cannot.

⚠️ It answers for the deployment, never for a tree. Whether this challenge reached a node is push's task and its per-node lines.

def get( self, challenge_id: str) -> ctfy.server.models.AdminAuthoredChallengeDetail:
120    def get(self, challenge_id: str) -> AdminAuthoredChallengeDetail:
121        """One upload, with the paths and sizes of what is in it."""
122        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}")
123        _raise_for_status(resp)
124        return AdminAuthoredChallengeDetail.model_validate(resp.json())

One upload, with the paths and sizes of what is in it.

def audit( self, challenge_id: str) -> ctfy.server.models.AuthoredAuditReport:
126    def audit(self, challenge_id: str) -> AuthoredAuditReport:
127        """Run the challenge audit over this upload and report it.
128
129        ⚠️ **Evidence, never a gate.** :meth:`review` does not consult
130        this: a rule that refused an approval on a finding would make
131        the operator's judgement the audit's to overrule, and the audit
132        cannot see inside a `Dockerfile`'s `RUN` — which is the half a
133        person is reading the files for.
134
135        ⚠️ Read ``outcome`` before the counts. An empty ``findings``
136        list means *nothing was found* only when it says ``ok``;
137        ``timed_out`` and ``failed`` are the audit declining to answer,
138        and reading either as clean is the whole reason the field
139        exists.
140        """
141        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/audit")
142        _raise_for_status(resp)
143        return AuthoredAuditReport.model_validate(resp.json())

Run the challenge audit over this upload and report it.

⚠️ Evidence, never a gate. review() does not consult this: a rule that refused an approval on a finding would make the operator's judgement the audit's to overrule, and the audit cannot see inside a Dockerfile's RUN — which is the half a person is reading the files for.

⚠️ Read outcome before the counts. An empty findings list means nothing was found only when it says ok; timed_out and failed are the audit declining to answer, and reading either as clean is the whole reason the field exists.

def read_file( self, challenge_id: str, path: str) -> ctfy.server.models.AuthoredChallengeFileBody:
145    def read_file(self, challenge_id: str, path: str) -> AuthoredChallengeFileBody:
146        """One file's contents.
147
148        ⚠️ The body is **attacker-authored text written to be run**.
149        ``binary`` and ``truncated`` say what you are not looking at;
150        treat a ``False`` on either as the only claim that you are.
151        """
152        # ⚠️ The path rides the body, never the URL (#1929): a WAF that
153        # refuses a URL ending in ``.ini`` does not read a JSON body.
154        resp = self._http.request(
155            "POST", f"/admin/authored-challenges/{challenge_id}/file", json={"path": path}
156        )
157        _raise_for_status(resp)
158        return AuthoredChallengeFileBody.model_validate(resp.json())

One file's contents.

⚠️ The body is attacker-authored text written to be run. binary and truncated say what you are not looking at; treat a False on either as the only claim that you are.

def graduation( self, challenge_id: str, corpus_id: str = '') -> ctfy.server.models.AuthoredGraduationInfo:
160    def graduation(self, challenge_id: str, corpus_id: str = "") -> AuthoredGraduationInfo:
161        """Whether an upload may graduate into the corpus, and as what (plan P3.3).
162
163        ``refusals`` names every reason it may not yet — empty when it may.
164        ``corpus_id`` is the id it would take: the one passed, or else the
165        next ``COMMUNITY-NNN`` this deployment's catalog does not hold. The
166        pull request's title and body are what a person opens it with.
167        """
168        params = {"corpus_id": corpus_id} if corpus_id else None
169        resp = self._http.request(
170            "GET", f"/admin/authored-challenges/{challenge_id}/graduation", params=params
171        )
172        _raise_for_status(resp)
173        return AuthoredGraduationInfo.model_validate(resp.json())

Whether an upload may graduate into the corpus, and as what (plan P3.3).

refusals names every reason it may not yet — empty when it may. corpus_id is the id it would take: the one passed, or else the next COMMUNITY-NNN this deployment's catalog does not hold. The pull request's title and body are what a person opens it with.

def graduation_bundle(self, challenge_id: str, corpus_id: str) -> bytes:
175    def graduation_bundle(self, challenge_id: str, corpus_id: str) -> bytes:
176        """The ``.tar.gz`` to unpack at the corpus repository's root.
177
178        It holds ``COMMUNITY/<corpus_id>/``: the tree as reviewed, the
179        ``LICENSE`` its author declared and a ``CREDITS.md``. Nothing is
180        recorded on the platform — opening the pull request is a person's
181        act, in another repository.
182        """
183        resp = self._http.request(
184            "GET",
185            f"/admin/authored-challenges/{challenge_id}/graduation/bundle",
186            params={"corpus_id": corpus_id},
187        )
188        _raise_for_status(resp)
189        return resp.content

The .tar.gz to unpack at the corpus repository's root.

It holds COMMUNITY/<corpus_id>/: the tree as reviewed, the LICENSE its author declared and a CREDITS.md. Nothing is recorded on the platform — opening the pull request is a person's act, in another repository.

def review( self, challenge_id: str, *, status: Literal['pending', 'approved', 'rejected'], note: str = '') -> ctfy.server.models.AdminAuthoredChallengeRow:
191    def review(
192        self, challenge_id: str, *, status: ReviewStatus, note: str = ""
193    ) -> AdminAuthoredChallengeRow:
194        """Record a verdict. ``rejected`` requires a note.
195
196        ``pending`` withdraws a verdict and clears its trail — a
197        mis-clicked approval is undoable without deleting the upload.
198        """
199        resp = self._http.request(
200            "POST",
201            f"/admin/authored-challenges/{challenge_id}/review",
202            json={"status": status, "note": note},
203        )
204        _raise_for_status(resp)
205        return AdminAuthoredChallengeRow.model_validate(resp.json())

Record a verdict. rejected requires a note.

pending withdraws a verdict and clears its trail — a mis-clicked approval is undoable without deleting the upload.

def push(self, challenge_id: str) -> ctfy.server.models.AdminTaskInfo:
207    def push(self, challenge_id: str) -> AdminTaskInfo:
208        """Send an approved upload to every node marked for untrusted builds.
209
210        ⚠️ **The verb after `publish`, and they are not the same act.**
211        `publish` writes the tree into *this deployment's* catalog root;
212        a multi-host node's catalog is a different directory on a
213        different machine, and until this runs the node answers 404 for
214        the id the platform now lists.
215
216        Returns the task: the push is per node, and which node refused
217        is on its log rather than in one summary line.
218        """
219        resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/push")
220        _raise_for_status(resp)
221        return AdminTaskInfo.model_validate(resp.json())

Send an approved upload to every node marked for untrusted builds.

⚠️ The verb after publish, and they are not the same act. publish writes the tree into this deployment's catalog root; a multi-host node's catalog is a different directory on a different machine, and until this runs the node answers 404 for the id the platform now lists.

Returns the task: the push is per node, and which node refused is on its log rather than in one summary line.

def smoke(self, challenge_id: str) -> ctfy.server.models.AdminTaskInfo:
223    def smoke(self, challenge_id: str) -> AdminTaskInfo:
224        """Run this upload's own reference exploit against it, on a node.
225
226        ⚠️ **The machine check an approval cannot make.** The audit is
227        static and a reviewer reads files; only a run says whether the
228        estate comes up and the exploit captures what it claims.
229
230        Returns the task — the verdict is on its log, and it is
231        deliberately four-valued: *solved*, *unsolved*, *estate_failed*,
232        *malformed*, plus *errored* for a node that could not tell.
233        """
234        resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/smoke")
235        _raise_for_status(resp)
236        return AdminTaskInfo.model_validate(resp.json())

Run this upload's own reference exploit against it, on a node.

⚠️ The machine check an approval cannot make. The audit is static and a reviewer reads files; only a run says whether the estate comes up and the exploit captures what it claims.

Returns the task — the verdict is on its log, and it is deliberately four-valued: solved, unsolved, estate_failed, malformed, plus errored for a node that could not tell.

def publish( self, challenge_id: str, *, exec_surface_reviewed: bool = False) -> ctfy.server.models.AdminAuthoredChallengeRow:
238    def publish(
239        self, challenge_id: str, *, exec_surface_reviewed: bool = False
240    ) -> AdminAuthoredChallengeRow:
241        """Write an approved upload into the deployment's catalog.
242
243        A separate verb from :meth:`review` on purpose: approving is a
244        person's verdict on somebody else's code, and it must not fail
245        because a corpus commit landed on the id in the days since. The
246        409 this raises carries every reason at once — the id is taken,
247        the row is not approved, the tree has no ``metadata.yaml`` — so a
248        caller fixes them in one pass rather than one press at a time.
249
250        ``exec_surface_reviewed`` confirms you read the files the tree
251        runs on a node or hands players to run (``executes`` on
252        :meth:`get`'s file list, plan P2.3). A tree with such files is
253        refused without it — once; the stamp then records who read it.
254
255        The row that comes back is **read back from the store**, so its
256        ``in_catalog`` is a fact about the disk after the write.
257        """
258        resp = self._http.request(
259            "POST",
260            f"/admin/authored-challenges/{challenge_id}/publish",
261            # Only what was said: absent is the server's own default.
262            params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None,
263        )
264        _raise_for_status(resp)
265        return AdminAuthoredChallengeRow.model_validate(resp.json())

Write an approved upload into the deployment's catalog.

A separate verb from review() on purpose: approving is a person's verdict on somebody else's code, and it must not fail because a corpus commit landed on the id in the days since. The 409 this raises carries every reason at once — the id is taken, the row is not approved, the tree has no metadata.yaml — so a caller fixes them in one pass rather than one press at a time.

exec_surface_reviewed confirms you read the files the tree runs on a node or hands players to run (executes on get()'s file list, plan P2.3). A tree with such files is refused without it — once; the stamp then records who read it.

The row that comes back is read back from the store, so its in_catalog is a fact about the disk after the write.

def unpublish( self, challenge_id: str) -> ctfy.server.models.AdminAuthoredChallengeRow:
267    def unpublish(self, challenge_id: str) -> AdminAuthoredChallengeRow:
268        """Take a published challenge back out of the deployment's catalog.
269
270        :meth:`publish` had no inverse, so an upload that turned out to
271        leak its own answer stayed in front of every entrant and the only
272        recourse was a shell on the platform host.
273
274        It deletes the *publication*, never the row: the author's tree,
275        the reviewer's verdict and the smoke result all survive, and
276        publishing again is the same call it always was. Entrants already
277        holding a box keep it until its own TTL — what closes is the
278        door, so nobody new can launch.
279
280        Raises 409 when the deployment's catalog does not hold the id,
281        which is the mirror of publishing something already published.
282        """
283        resp = self._http.request("DELETE", f"/admin/authored-challenges/{challenge_id}/publish")
284        _raise_for_status(resp)
285        return AdminAuthoredChallengeRow.model_validate(resp.json())

Take a published challenge back out of the deployment's catalog.

publish() had no inverse, so an upload that turned out to leak its own answer stayed in front of every entrant and the only recourse was a shell on the platform host.

It deletes the publication, never the row: the author's tree, the reviewer's verdict and the smoke result all survive, and publishing again is the same call it always was. Entrants already holding a box keep it until its own TTL — what closes is the door, so nobody new can launch.

Raises 409 when the deployment's catalog does not hold the id, which is the mirror of publishing something already published.

def curate( self, challenge_id: str, competition_id: str = '') -> ctfy.server.models.AdminAuthoredChallengeRow:
287    def curate(self, challenge_id: str, competition_id: str = "") -> AdminAuthoredChallengeRow:
288        """Put a published upload in front of this competition's entrants.
289
290        ⚠️ **The step after `publish`, and the two are not one act.**
291        Publishing writes the tree into this deployment's catalog;
292        curating adds its id to *one competition's* challenge set. Until
293        this existed the only way to take it was the competition edit
294        form, which re-sends the whole list — so two organisers working a
295        review queue dropped each other's additions. This appends.
296
297        Refused (409, with reasons) for a competition whose set comes
298        from a catalog bucket, and for an id this deployment has not
299        published: the second would put a challenge in front of entrants
300        that no node can start.
301
302        ``competition_id`` names another competition to curate it into —
303        the workshop → arena step — and every refusal is then asked of
304        that one. Empty is the upload's own competition.
305        """
306        resp = self._http.request(
307            "POST",
308            f"/admin/authored-challenges/{challenge_id}/curation",
309            params={"competition_id": competition_id} if competition_id else None,
310        )
311        _raise_for_status(resp)
312        return AdminAuthoredChallengeRow.model_validate(resp.json())

Put a published upload in front of this competition's entrants.

⚠️ The step after publish, and the two are not one act. Publishing writes the tree into this deployment's catalog; curating adds its id to one competition's challenge set. Until this existed the only way to take it was the competition edit form, which re-sends the whole list — so two organisers working a review queue dropped each other's additions. This appends.

Refused (409, with reasons) for a competition whose set comes from a catalog bucket, and for an id this deployment has not published: the second would put a challenge in front of entrants that no node can start.

competition_id names another competition to curate it into — the workshop → arena step — and every refusal is then asked of that one. Empty is the upload's own competition.

def uncurate( self, challenge_id: str, competition_id: str = '') -> ctfy.server.models.AdminAuthoredChallengeRow:
314    def uncurate(self, challenge_id: str, competition_id: str = "") -> AdminAuthoredChallengeRow:
315        """Take an upload back out of this competition's challenge set.
316
317        Carries neither of :meth:`curate`'s refusals: removing an id from
318        a set that should not hold it is the repair, so gating it on the
319        same conditions would keep the broken state. ``competition_id``
320        names which competition; empty is the upload's own.
321        """
322        resp = self._http.request(
323            "DELETE",
324            f"/admin/authored-challenges/{challenge_id}/curation",
325            params={"competition_id": competition_id} if competition_id else None,
326        )
327        _raise_for_status(resp)
328        return AdminAuthoredChallengeRow.model_validate(resp.json())

Take an upload back out of this competition's challenge set.

Carries neither of curate()'s refusals: removing an id from a set that should not hold it is the repair, so gating it on the same conditions would keep the broken state. competition_id names which competition; empty is the upload's own.

def revisions( self, challenge_id: str) -> list[ctfy.server.models.AdminAuthoredRevisionRow]:
330    def revisions(self, challenge_id: str) -> builtins.list[AdminAuthoredRevisionRow]:
331        """Every revision of one upload, newest first — the proposal, if
332        any, and the history of those applied (plan P3.1)."""
333        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/revisions")
334        _raise_for_status(resp)
335        return [AdminAuthoredRevisionRow.model_validate(row) for row in resp.json()]

Every revision of one upload, newest first — the proposal, if any, and the history of those applied (plan P3.1).

def revision_diff( self, challenge_id: str, revision_id: str) -> ctfy.server.models.AuthoredRevisionDiff:
337    def revision_diff(self, challenge_id: str, revision_id: str) -> AuthoredRevisionDiff:
338        """A proposed revision against the live tree — what a reviewer reads.
339
340        ⚠️ A changed file with an empty ``diff`` says why in ``omitted``
341        (``binary``, ``too_large``, ``over_budget``); read such a file
342        whole with :meth:`read_revision_file`. ``needs_exec_review`` says
343        whether :meth:`apply_revision` will ask you to confirm the
344        execution-surface review.
345        """
346        resp = self._http.request(
347            "GET", f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/diff"
348        )
349        _raise_for_status(resp)
350        return AuthoredRevisionDiff.model_validate(resp.json())

A proposed revision against the live tree — what a reviewer reads.

⚠️ A changed file with an empty diff says why in omitted (binary, too_large, over_budget); read such a file whole with read_revision_file(). needs_exec_review says whether apply_revision() will ask you to confirm the execution-surface review.

def read_revision_file( self, challenge_id: str, revision_id: str, path: str) -> ctfy.server.models.AuthoredChallengeFileBody:
352    def read_revision_file(
353        self, challenge_id: str, revision_id: str, path: str
354    ) -> AuthoredChallengeFileBody:
355        """One file of a revision, whole — attacker-authored text written
356        to be run, like :meth:`read_file`'s."""
357        resp = self._http.request(
358            "POST",
359            f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/file",
360            json={"path": path},
361        )
362        _raise_for_status(resp)
363        return AuthoredChallengeFileBody.model_validate(resp.json())

One file of a revision, whole — attacker-authored text written to be run, like read_file()'s.

def review_revision( self, challenge_id: str, revision_id: str, *, status: Literal['pending', 'approved', 'rejected'], note: str = '') -> ctfy.server.models.AdminAuthoredRevisionRow:
365    def review_revision(
366        self, challenge_id: str, revision_id: str, *, status: ReviewStatus, note: str = ""
367    ) -> AdminAuthoredRevisionRow:
368        """Record a verdict on a proposed revision. ``rejected`` requires a
369        note and ``pending`` withdraws a verdict, as for an upload. An
370        applied revision is history and is refused (409)."""
371        resp = self._http.request(
372            "POST",
373            f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/review",
374            json={"status": status, "note": note},
375        )
376        _raise_for_status(resp)
377        return AdminAuthoredRevisionRow.model_validate(resp.json())

Record a verdict on a proposed revision. rejected requires a note and pending withdraws a verdict, as for an upload. An applied revision is history and is refused (409).

def apply_revision( self, challenge_id: str, revision_id: str, *, exec_surface_reviewed: bool = False) -> ctfy.server.models.AdminAuthoredChallengeRow:
379    def apply_revision(
380        self, challenge_id: str, revision_id: str, *, exec_surface_reviewed: bool = False
381    ) -> AdminAuthoredChallengeRow:
382        """Make an approved revision the upload's live version (plan P3.1).
383
384        A published upload's directory is replaced whole — boxes already
385        running keep the bytes they started with — and the catalog is
386        reloaded. Nodes that hold the upload keep the previous tree until
387        :meth:`push` sends the new one.
388
389        ``exec_surface_reviewed`` confirms you read the revision's files
390        marked ``executes``: a published upload whose revision has any is
391        refused without it, every time — the bytes are new. The 409 carries
392        every reason at once.
393        """
394        resp = self._http.request(
395            "POST",
396            f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/apply",
397            params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None,
398        )
399        _raise_for_status(resp)
400        return AdminAuthoredChallengeRow.model_validate(resp.json())

Make an approved revision the upload's live version (plan P3.1).

A published upload's directory is replaced whole — boxes already running keep the bytes they started with — and the catalog is reloaded. Nodes that hold the upload keep the previous tree until push() sends the new one.

exec_surface_reviewed confirms you read the revision's files marked executes: a published upload whose revision has any is refused without it, every time — the bytes are new. The 409 carries every reason at once.