{"_id":"@codeany-inc/hubs-manage-sdk","name":"@codeany-inc/hubs-manage-sdk","dist-tags":{"alpha":"0.1.0-alpha.1","latest":"0.1.0-alpha.1"},"versions":{"0.1.0-alpha.1":{"name":"@codeany-inc/hubs-manage-sdk","version":"0.1.0-alpha.1","description":"TypeScript SDK for the CodeAny hub management APIs.","license":"Apache-2.0","type":"module","sideEffects":false,"main":"./dist/index.cjs","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"engines":{"node":">=18.14.1 <19 || >=19.6.1"},"publishConfig":{"access":"public","tag":"alpha"},"scripts":{"build":"tsup && node ../tooling/sanitize-source-maps.mjs && node ../tooling/build-declarations.mjs && node ../tooling/check-published-output.mjs","clean":"node -e \"for (const d of ['dist', 'coverage', 'docs/api']) require('node:fs').rmSync(d, { recursive: true, force: true })\"","dev":"tsup --watch","docs:api":"typedoc","format":"prettier --write . --ignore-path ../.prettierignore","format:check":"prettier --check . --ignore-path ../.prettierignore","lint":"eslint .","quality":"npm run format:check && npm run lint && npm run typecheck && npm run test:coverage && npm run test:types && npm run build","test":"vitest run","test:contract":"vitest run tests/contract","test:coverage":"vitest run --coverage","test:types":"vitest run --typecheck.enabled --typecheck.only --passWithNoTests","typecheck":"tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json"},"devDependencies":{"@codeany-inc/sdk-core":"*"},"repository":{"type":"git","url":"git+https://github.com/CodeAny-inc/ms-codeany-mono.git","directory":"packages/codeany-sdks/hubs-manage-sdk"},"bugs":{"url":"https://github.com/CodeAny-inc/ms-codeany-mono/issues"},"_id":"@codeany-inc/hubs-manage-sdk@0.1.0-alpha.1","homepage":"https://github.com/CodeAny-inc/ms-codeany-mono#readme","_integrity":"sha512-dyhomKHwTZ6231QUtJJ9++8sw8jQ3JnoxG+C+sRg8fVxJG8JzxdkALmS2Ms1F6JyZ8Kwou6ZTQqh+byMIVXSGw==","_resolved":"/tmp/codeany-sdk-alpha-0282a59b.mjudCx/codeany-inc-hubs-manage-sdk-0.1.0-alpha.1.tgz","_from":"file:/tmp/codeany-sdk-alpha-0282a59b.mjudCx/codeany-inc-hubs-manage-sdk-0.1.0-alpha.1.tgz","_nodeVersion":"22.23.2","_npmVersion":"10.9.8","dist":{"integrity":"sha512-dyhomKHwTZ6231QUtJJ9++8sw8jQ3JnoxG+C+sRg8fVxJG8JzxdkALmS2Ms1F6JyZ8Kwou6ZTQqh+byMIVXSGw==","shasum":"289098be45f37e2568901260c6a30caaed52e99b","tarball":"https://registry.npmjs.org/@codeany-inc/hubs-manage-sdk/-/hubs-manage-sdk-0.1.0-alpha.1.tgz","fileCount":34,"unpackedSize":3131421,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQCh8lTo+Pd54h9yiz1bGh7LiRgz/6y2lwAYJ2UW6P9DnQIgEDPR+cjM2JmQAqnk4eJ8Wi0A4YsoNTa6KmV4othUPFY="}]},"_npmUser":{"name":"huseyn.hajiyev","email":"huseyn.hajiyev@codeany.org"},"directories":{},"maintainers":[{"name":"huseyn.hajiyev","email":"huseyn.hajiyev@codeany.org"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/hubs-manage-sdk_0.1.0-alpha.1_1788126207114_0.7833180795189549"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-30T21:43:26.927Z","0.1.0-alpha.1":"2026-08-30T21:43:27.274Z","modified":"2026-08-30T21:43:27.540Z"},"maintainers":[{"name":"huseyn.hajiyev","email":"huseyn.hajiyev@codeany.org"}],"description":"TypeScript SDK for the CodeAny hub management APIs.","homepage":"https://github.com/CodeAny-inc/ms-codeany-mono#readme","repository":{"type":"git","url":"git+https://github.com/CodeAny-inc/ms-codeany-mono.git","directory":"packages/codeany-sdks/hubs-manage-sdk"},"bugs":{"url":"https://github.com/CodeAny-inc/ms-codeany-mono/issues"},"license":"Apache-2.0","readme":"# `@codeany-inc/hubs-manage-sdk`\n\nTypeScript SDK for the CodeAny hub management APIs.\n\n<!-- claim: exports hubs-manage-sdk/src/index.ts = 182 names (sha acead1c0) -->\n\n> **Status: pre-release and unpublished.** The manifest is prepared as version\n> `0.1.0-alpha.1` under the `alpha` dist-tag. Its recorded backend ancestry\n> contains all four conditional security fixes required for this candidate;\n> publication still requires an explicit human decision. Its entry point —\n> `src/index.ts`, the subject of the claim above — carries the client\n> foundation (the factory, the client's shape, the escape hatch, token refresh\n> and the error model) and **all eleven** typed resource modules: `hubs` and\n> `taskAttachments` on the root client, and `registration`, `members`,\n> `competitions`, `leaderboard`, `tasks`, `hubTasks`, `taskJudge`, `testsets`\n> and `submissions` on a hub-scoped facade. **No reserved module name is left a\n> named placeholder**; `taskAttachments` and `submissions` were the last two.\n>\n> `taskAttachments` is **`@experimental`** and is the one module here whose\n> endpoints are not the hub-management ones. Its six routes are the\n> `competitions` application's, under\n> `/api/competitions/tasks/<task_pk>/attachments/`, they carry no hub segment,\n> and their permission rule is not the one every other task method here\n> documents — see [What is deliberately absent](#what-is-deliberately-absent),\n> which is also where the sixth of those routes is accounted for — the\n> redirecting one, which has no method here.\n>\n> The three task modules are three subjects rather than one split in three:\n> `tasks` is one **competition's** task set, `hubTasks` is the **hub's** task\n> library a competition draws from, and `taskJudge` is how one **task** is\n> judged — its checker, its interactor and its graders. `src/modules.ts` states\n> both splits.\n>\n> `submissions` is `@experimental`, and for a reason worth reading before\n> using it: the endpoint's own contract disagrees with itself in two places.\n> The listing answers `sub_id` and the detail is addressed by the database\n> primary key, which are different columns — so `get` names its parameter\n> `submissionDatabaseId` rather than pretending a listing row hands you\n> something you can fetch with. And `verdict` is a numeric code going out and a\n> translated display string coming back; the detail carries `verdict_value`\n> beside it for the number. Both are recorded with evidence in\n> `docs/backend-contract-todos.md`.\n>\n> `testsets` is complete since CODANYP-482. Its ZIP upload answers a server-sent\n> event stream rather than a document, so it is the one method in this package\n> that reports progress while it runs and the one whose result is the stream\n> itself — and the two testset-upload error classes in the shared core are now\n> reachable from here and re-exported, with the guards that narrow them.\n>\n> This alpha candidate is prepared alongside recorded backend SHA\n> `aa4d9d661894731dd0454b03298a4281519adcdb`. That ancestry integrates\n> CODANYP-570 for leaderboard hub scope, CODANYP-614 for testset ZIP task scope,\n> CODANYP-595 for task-local grader mutations, and CODANYP-167 for normal\n> application-path task-attachment storage cleanup. The SHA is provenance, not\n> compatibility verification, and preparing the candidate publishes nothing.\n\n## Usage\n\nImport from the package root only:\n\n```ts\nimport { createCodeAnyManageClient } from '@codeany-inc/hubs-manage-sdk';\n\nconst client = createCodeAnyManageClient({\n  baseUrl: 'https://codeany.example',\n  auth: { jwt: { token: accessToken } },\n});\n\n// The hubs this caller can manage — owner or admin, which is what the\n// endpoint's own filter means.\nconst hubs = await client.hubs.list();\nconst acme = await client.hubs.get('acme');\nconst created = await client.hubs.create({ name: 'newco' });\n\n// A hub-scoped facade. Cheap, and a new object every call.\nconst scoped = client.forHub(acme.name);\nconst registration = await scoped.registration.get();\nif (!registration.closed) {\n  // `questions` is arbitrary JSON: the backend stores it without a schema.\n}\n\n// Members. Every listing route is paginated, so a page is what comes back.\nconst firstPage = await scoped.members.list({ perPage: 50 });\nfor (const member of firstPage.results) {\n  console.log(member.profile.username, member.permission);\n}\nawait scoped.members.add({ username: 'grace' });\nawait scoped.members.setPermission('grace', { permission: 'admin' });\nawait scoped.members.updateProfile('grace', { location: 'Baku' });\n\n// Competitions. The listing is paginated and answers a shorter document than\n// the other four document-returning methods; `started` is a phrase here and an\n// integer there.\nconst running = await scoped.competitions.list({\n  status: 'in progress',\n  include: ['tasks_count'],\n});\nfor (const entry of running.results) {\n  // `tasks_count` is present because it was asked for, and absent otherwise.\n  // `started` is a forward-compatible label, so a `switch` on it needs a\n  // `default` — a backend rewording must not become a parse failure here.\n  console.log(entry.name, entry.started, entry.tasks_count);\n}\n// Experimental: the endpoint reads the name and injects everything else, so the\n// competition this returns is invisible, closed to registration and zero minutes\n// long. `competitions.create` documents the whole set of injected defaults.\nconst competition = await scoped.competitions.create({ name: 'winter-open' });\nawait scoped.competitions.update(competition.id, { visible: 1, type: 'ICPC' });\n// Start time and length are set together, in UTC, and only from here: the\n// endpoint reschedules its own start job from these six fields. Each is\n// range-checked before anything is sent.\nawait scoped.competitions.schedule(competition.id, {\n  year: 2025,\n  month: 3,\n  day: 4,\n  hour: 9,\n  minute: 0,\n  length: 180,\n});\n\n// A competition's leaderboard. `rows` is normalized from whichever of the\n// endpoint's four envelopes answered, and every count is `number | null`\n// because they do not all send the same ones.\nconst board = await scoped.leaderboard.get(42, { perPage: 50 });\nfor (const row of board.rows) {\n  // A row is an open record: four code paths build one and they agree on no\n  // field, so narrowing it is yours. An administrator also gets the\n  // `raw_score`, `raw_penalty`, `raw_tasks` and `disqualification_case_id` of\n  // a disqualified participant, which nothing here strips — on the three exits\n  // that attach them. Two exits mark no row at all: before the competition\n  // starts, where `includeDisqualified: false` also excludes nobody and\n  // `board.rowsField` is `participants`; and on a running non-ICPC board the Go\n  // generator built, which answers `leaderboard` like the exits that do mark\n  // their rows. So absence is absence, and `src/leaderboard.ts` has the\n  // evidence.\n  console.log(row.pos, row.score);\n}\n\n// The exports. `exportJson` is a document; `exportCsv` is bytes, and its\n// overloads decide which representation you get.\nconst payload = await scoped.leaderboard.exportJson(42);\nconst csv = await scoped.leaderboard.exportCsv(42);\nconst bytes = await scoped.leaderboard.exportCsv(42, undefined, {\n  as: 'array-buffer',\n});\n// A representation chosen at run time has an overload of its own, so it needs\n// neither a branch nor a cast. It answers `CodeAnyDownload<Blob | ArrayBuffer>`\n// — one narrowing of `data`, where a cast into a literal options type would\n// have promised the wrong one. The two calls above are unaffected: a literal\n// `as` still selects its own overload and its own exact result type.\nconst mode: 'blob' | 'array-buffer' = configuredRepresentation;\nconst either = await scoped.leaderboard.exportCsv(42, undefined, { as: mode });\n// The whole options object may be the thing that is configured, and then it may\n// also not be there — a `ManageLeaderboardCsvOptions | undefined`, or the\n// optional property below. That is the same call rather than a second one: an\n// absent object has always meant the default, so it is passed rather than\n// branched on. It answers the union too, because absence is a `Blob` and a\n// configured `as` may not be.\nconst maybe = await scoped.leaderboard.exportCsv(\n  42,\n  undefined,\n  csvSettings.export,\n);\n// `null` when this SDK could not read one, and never guessed from the URL. In a\n// browser on another origin it is always `null`: `Content-Disposition` is not\n// CORS-safelisted and the deployment exposes no headers, so the name the\n// endpoint proposed does not reach script. The bytes still do.\nconsole.log(csv.filename);\n\n// One competition's tasks. Every method takes the competition id first, and the\n// four that act on the task set — `list`, `add`, `remove` and `reorder` —\n// answer with the whole list in `order_index` order. The two language methods\n// answer the settings document instead.\nfor (const row of await scoped.tasks.list(42)) {\n  console.log(row.order_index, row.task_id, row.color);\n}\nawait scoped.tasks.add(42, { taskIds: [101, 102] });\nawait scoped.tasks.add(42, {\n  tasks: [{ taskId: 103, orderIndex: 3, color: '#A1B2C3' }],\n});\nconst remaining = await scoped.tasks.remove(42, { taskIds: [101] });\n\n// A reorder names the *relationship* row rather than the task, and must name\n// every row in the competition — so build it from what the last call answered\n// rather than from a list read before it.\nawait scoped.tasks.reorder(42, {\n  items: remaining.map((row, index) => ({\n    competitionTaskId: row.competition_task_id,\n    orderIndex: index + 1,\n  })),\n});\n\n// Which languages the competition may be solved in. A value is whatever the\n// backend normalizes — a key, a name, an editor id, a runner code, an alias —\n// and an empty list means \"every supported language\".\nconst languages = await scoped.tasks.getLanguages(42);\nconsole.log(languages.effective, languages.supported.length);\nawait scoped.tasks.setLanguages(42, { languages: ['C++', 'Python'] });\n\n// The hub's own tasks — the library the module above draws from. All three\n// listings answer the same document and the same page envelope.\nconst tasks = await scoped.hubTasks.list({ perPage: 50 });\nfor (const task of tasks.results) {\n  // `source` is the source column and the source document joined by a Python\n  // `str()`, so show it or discard it; it is not JSON. `visible` is the integer\n  // the column holds, and `author` is the row's creator rather than its\n  // credited author, which is `author_text`.\n  console.log(task.id, task.name, task.type, task.visible, task.source);\n}\n\n// Filtering. A text filter is an operator and a value, and this SDK assembles\n// the `condition:value` parameter the endpoint reads. The value goes out as\n// given: the backend deletes `;`, `'`, `\"` and `\\` from it and trims it, and\n// nothing here reproduces that.\nconst batch = await scoped.hubTasks.filter({\n  type: 'batch',\n  visible: 1,\n  author: { operator: 'contains', value: 'grace' },\n  search: 'sorting',\n});\nconsole.log(batch.count);\n\n// Searching what this caller can reach, in any hub. This one route has no hub\n// segment, so the facade's hub is not sent — hence the name.\nconst available = await scoped.hubTasks.searchAvailable({ query: 'graph' });\n\n// Creating one. The endpoint answers the new task's id and name and nothing\n// else, so reading the row back is a second call.\nconst task = await scoped.hubTasks.create({\n  name: 'two-digit-split',\n  type: 'batch',\n  generateSamples: true,\n});\n\n// Deleting one, and several. Experimental: `deleted_count` counts every\n// cascaded row rather than tasks — and adding the two lists' lengths is not a\n// count of tasks either, because a task named both ways is in both.\nawait scoped.hubTasks.remove(task.id);\nconst removed = await scoped.hubTasks.removeMany({\n  taskIds: [101, 102],\n  taskNames: ['retired-task'],\n});\nconsole.log(removed.deleted_tasks.by_id, removed.deleted_tasks.by_name);\n\n// How one task is judged. The checker request is a union of four branches,\n// because the endpoint reads a different part of the body in each: only the\n// custom branch carries a source, and only the floating-point one a precision.\nawait scoped.taskJudge.setChecker(1, {\n  checkerType: 'single_or_multiple_double_ignore_whitespaces',\n  precision: 4,\n});\nawait scoped.taskJudge.setChecker(1, {\n  checkerType: 'custom_checker',\n  checker: '#include \"testlib.h\"\\nint main() { return 0; }\\n',\n});\n\n// `precision` comes back as a string of digits, and only from the read: the\n// writing route never computes one, so it answers `null` for it.\nconst checker = await scoped.taskJudge.getChecker(1);\nconsole.log(checker.checker_type, checker.precision);\n\n// The interactor takes a partial update, and an update naming neither field is\n// refused before anything is sent — the endpoint would answer 200 having\n// changed nothing. The empty string is how a field is cleared; `null` is not.\nawait scoped.taskJudge.updateInteractor(1, {\n  interactorLanguage: 'cpp:17-gnu10',\n});\n\n// Graders. Pass both header fields: the backend writes an absent one into a\n// NOT NULL column, which is a 500 today — see docs/backend-contract-todos.md.\nawait scoped.taskJudge.createGrader(1, {\n  programmingLanguage: 'python',\n  code: 'def solve(values):\\n    return sum(values)\\n',\n  header: '',\n  headerFilename: '',\n});\nconst graders = await scoped.taskJudge.listGraders(1);\nconsole.log(graders.length);\n\n// CODANYP-595 historically found that a removal deleted a shared Grader row.\n// The recorded backend ancestry used for this candidate now unlinks only this\n// task and deletes the row only when it is orphaned; see the preserved evidence\n// in docs/backend-contract-todos.md.\nawait scoped.taskJudge.removeGrader(1, 'python');\n\n// One task's testsets — its subtasks. `test_count` is the data-bearing\n// testset's, which for a task whose test data references another task is the\n// reference's; every other field is the task's own row.\nconst testsets = await scoped.testsets.list(9, { perPage: 50 });\nfor (const testset of testsets.results) {\n  // `test_score` is the string the column holds: `'-1'` means \"score this\n  // testset as a whole\", and anything else is per-test scoring.\n  console.log(\n    testset.id,\n    testset.index,\n    testset.test_count,\n    testset.test_score,\n  );\n}\n\n// One testset in full. Its `inputs` and `outputs` are **previews**: the\n// endpoint truncates each at fifty characters and appends a literal `...`, and\n// nothing in the document says which ones it shortened.\nconst testset = await scoped.testsets.get(9, 11);\n// So a whole test is a call of its own, by position.\nconst test = await scoped.testsets.getTest(9, 11, 0);\n\n// Creating, updating and deleting a subtask. The update is partial and must\n// name at least one field; an empty one is refused before anything is sent.\nconst createdTestset = await scoped.testsets.create(9, { index: 2 });\nawait scoped.testsets.update(9, createdTestset.id, {\n  score: 40,\n  testScore: '-1',\n});\nawait scoped.testsets.remove(9, createdTestset.id);\n\n// Uploading one test. Each part needs a filename, because the endpoint decides\n// what it received from the extension — `.dat` or `.in` for an input, `.sol`,\n// `.out`, `.a` or `.ans` for an answer. A `File` carries one; a `Blob` needs\n// `filename`, and a pair with neither is refused rather than sent under the\n// name the platform would invent. Both parts below are `Blob`s, which is the\n// form that runs on every runtime this package supports: `File` became a\n// global in Node 20, and below that it has to be imported from `node:buffer`,\n// which has carried it since 18.13.0 and 19.2.0 — both older than this\n// package's floor.\nawait scoped.testsets.uploadTest(9, 11, {\n  input: { data: new Blob(['1 2\\n']), filename: '01.in' },\n  answer: { data: answerBlob, filename: '01.ans' },\n  // Omitted or -1 appends; a non-negative index replaces the test there.\n  position: -1,\n});\n\n// Deleting tests, one or several. The indexes must be unique: the endpoint\n// deletes in place after sorting descending, so a repeat takes a neighbouring\n// test with it.\nawait scoped.testsets.removeTest(9, 11, 0);\nawait scoped.testsets.removeTestMany(9, 11, [4, 2, 0]);\n\n// Uploading a whole testset as a ZIP. This one **replaces** the testset's\n// tests, answers a `text/event-stream` rather than a document, and is therefore\n// as long as the upload takes: the outcome is the last event of the stream.\n// `onProgress` is awaited, so an asynchronous callback finishes before the next\n// event arrives, and it is handed the terminal event too.\nconst events = await scoped.testsets.upload(\n  9,\n  11,\n  // A `File` names the part; a plain `Blob` does not, and unlike `uploadTest`\n  // that is not refused — nothing on this route reads the extension, so the\n  // part is named `testset.zip`. An explicit `filename` wins over both.\n  { archive: zipBlob, filename: 'winter-tests.zip' },\n  {\n    onProgress: (event) => {\n      // 'processing', then exactly one of 'completed' or 'failed'.\n      console.log(event.status, event.message);\n    },\n  },\n);\n// The terminal event is the last element, and the messages are prose: the\n// backend writes whatever the Celery task returned, so branch on `status`.\nconsole.log(events.at(-1)?.message);\n\n// The escape hatch, which is on the root client and nowhere else. It is for\n// the endpoints nobody has modelled yet — a task's example inputs and outputs\n// among them. The path is written as the backend spells it, and is sent\n// verbatim: nothing here encodes a segment for you.\nconst examples = await client.raw.request({\n  method: 'GET',\n  path: '/api/hubs/acme/task/9/examples',\n});\n// A task's attachments. On the **root** client, not on `scoped`: these routes\n// carry no hub, and being an owner or admin of the task's hub is not on its own\n// enough to reach them — the caller has to be the task's creator, one of its\n// editors, or Django staff.\nconst attachments = await client.taskAttachments.list(9);\nfor (const attachment of attachments) {\n  // `download_url` is the authorized route, not a storage URL, and it\n  // redirects. A navigation sends cookies and no `Authorization` header, so it\n  // works only in a browser that is already signed in. With a bearer token,\n  // call `taskAttachments.download(taskId, attachment.id)` and build an object\n  // URL from the `Blob`.\n  console.log(\n    attachment.filename,\n    attachment.file_size,\n    attachment.download_url,\n  );\n}\n\n// Every file goes in its own part under the one field name `files`. The order\n// is preserved, the array is copied at entry, and a file whose name resolves to\n// nothing is refused before anything is sent.\nconst uploaded = await client.taskAttachments.upload(9, {\n  files: [\n    { data: statementPdf, filename: 'statement.pdf' },\n    { data: sampleZip, filename: 'samples.zip' },\n  ],\n});\n// Every attachment as one ZIP. The endpoint refuses a task with fewer than two\n// of them, and nothing here counts them first — which is why the archive is\n// taken **before** the removal below rather than after: on a task that started\n// empty, removing one of these two would leave one and the archive would be a\n// `400`.\nconst archive = await client.taskAttachments.downloadAll(9);\n\n// `upload` answers a readonly array, and this package builds under\n// `noUncheckedIndexedAccess`, so an index is `ManageTaskAttachment | undefined`\n// until it is checked. Written the way a consumer's compiler will require.\nconst [first] = uploaded;\nif (first !== undefined) {\n  await client.taskAttachments.remove(9, first.id);\n}\n```\n\n```ts\n// Administrative submissions across one hub, or one of its competitions.\n// Filters are written as objects and translated to the endpoint's own suffix\n// parameters: `{ subId: { gte: 10 } }` is sent as `sub_id_gte=10`.\n// `verdict` and `verdictValue`'s `eq`/`ne` are membership filters, not\n// overall-verdict ones: every value, alias or number, also matches on the\n// per-test verdicts — `AC` when they all passed, everything else when any one\n// carries the code. The ordering operators and every `score_*` compare the\n// overall verdict directly. `ManageSubmissionVerdictAlias` states it, `WA` is\n// not wrong answers, and `score: { eq: 2 }` is exact overall equality.\nconst page = await scoped.submissions.list({\n  perPage: 50,\n  verdict: ['AC'],\n  dateFrom: '2026-01-01',\n});\nfor (const row of page.results) {\n  // `verdict` is the rendered string the endpoint sends — \"Correct ✅\", or a\n  // formatted score — and not a code you can compare to the filter above.\n  // `task` carries the localized `title` and no name.\n  console.log(row.sub_id, row.verdict, row.task.title);\n}\n\n// The same filters, scoped to one competition. `competitionIds` is refused\n// here: the endpoint ignores it when the competition is in the path.\nconst forCompetition = await scoped.submissions.listForCompetition(7, {\n  status: ['waiting'],\n});\n\n// The detail takes the submission's DATABASE ID, which is not the `sub_id`\n// a listing row carries. Passing a `sub_id` here reaches a different row or\n// none; see the module notes and the backend-contract TODOs.\nconst detail = await scoped.submissions.get(1234);\nconsole.log(detail.verdict, detail.verdict_value, detail.code);\n```\n\nEvery **typed resource method** takes an optional `{ signal }` as its last\nargument — the three on `hubs`, `registration.get`, all eleven on `members`, all\nsix on `competitions`, all six on `tasks`, all six on `hubTasks`, all eight on\n`taskJudge`, all ten on `testsets` — where `upload` takes a wider options object\nthat carries the same `signal` beside its progress callback —\n`taskAttachments.list`, `taskAttachments.upload` and `taskAttachments.remove`,\nall three on `submissions`, and\n`leaderboard.get` and\n`leaderboard.exportJson` — and so does `refreshAccessToken`, after the input\nit exchanges. The rest of the client is shaped differently, so the sentence\nstops there: `leaderboard.exportCsv`, `taskAttachments.download` and\n`taskAttachments.downloadAll` take that\nsame object plus an optional\n`as`, which is what their three overloads each select on and whose two members\nare the only values any of them accepts — one overload per literal, and a third\nfor the caller whose value is either. The two attachment downloads share those\noption types outright, so a single-file download can be cancelled and can ask\nfor an `ArrayBuffer` on exactly the terms the archive download can; `raw.request` carries `signal`\nas one field of the single object describing the call; and `forHub`,\n`setJwtToken`, `clearJwtToken`, `setCsrfToken` and `clearCsrfToken` take no\noptions at all — the first builds a facade without sending anything, and the\nother four change only what later requests carry.\n\n### What is deliberately absent\n\n`client.hubs` has no `delete`. The backend routes `DELETE /api/hubs/delete` at a\nview whose handler requires a hub name the route does not capture, so every call\nto it raises inside Django and answers `500`; there is no spelling of the request\nthat works. A method here would be a promise this SDK cannot keep, so deletion\nwaits for the backend fix. The evidence is in `src/hubs.ts`.\n\n`client.taskAttachments.download` fetches one attachment's bytes, and it calls\n`content/` rather than `download/`. The `download/` route authorizes the caller\nand then answers a `302` to whatever host the configured storage serves the\nobject from — while this SDK's transport sets `redirect: 'error'` on every\nrequest, because Fetch strips only `Authorization`, `Cookie` and\n`Proxy-Authorization` across a cross-origin redirect and the two credential\nheaders CodeAny invented, `X-CodeAny-LTI-Auth` and `X-CSRFToken`, are on no\nspecification's list. So that route is unusable from here and always was; the\n`content/` route authorizes identically and streams the bytes instead.\n\nThe bytes are proxied through the API rather than the caller being handed a\nstorage URL, and that is a cost paid on purpose: the media bucket is public, so\nany URL handed out is readable by anyone who has it, with no signature and no\nexpiry. `docs/backend-contract-todos.md` carries the measurement and what has\nto change for a URL-returning route to replace this one.\n`ManageTaskAttachment.download_url` is still the right thing to hand to a\nbrowser navigation — **when the browser is already signed in**. A navigation\ncarries cookies and no `Authorization` header, and the attachment viewset\nauthenticates with LTI, the JWT **cookie** and the Django session, so a consumer\nwhose only credential is a bearer token this SDK holds gets a `401` from that\nURL. Such a consumer calls `download` and builds an object URL from the `Blob`\ninstead — that path carries the credential the client actually has.\n\nIt is also **relative**: the serializer builds it as `/api/...`, rooted at the\ndeployment rather than at whoever reads it. A browser application served from\na different origin that hands that string straight to a navigation reaches its\n**own** origin — a `404`, or some unrelated route of the application — with\nevery CodeAny cookie present and nothing in the value to say what happened.\nResolve it against the base URL the client was configured with first. The two\nconditions are independent: resolving it does not make it authenticated, and\nbeing signed in does not make it absolute.\n\n`taskAttachments` also answers to a **different permission rule** from every\nother task method here. `user_can_manage_task`, which `testsets`, `taskJudge`\nand `hubTasks` all use, admits a hub `owner` or `admin`; the attachment viewset\nadmits Django staff, the task's creator and its editors, and never reads a hub\nmembership at all. A caller who can delete the task outright may be told `403`\nfor its attachments, and the refusal names no hub and no rank. Recorded in\n[backend-contract TODOs](docs/backend-contract-todos.md) and on every method.\n\nRegistration is read-only because the endpoint is: the backend serves a `GET` for\na hub's questionnaire and no writer for it.\n\n`members.updateProfile` carries the endpoint's eight text and questionnaire\nfields and neither of its two file fields. The backend cannot accept a file and a\n`fields` update in one request whatever the client does, and a binary surface is\nits own work here; upload through `client.raw` with a `FormData` body until it\nlands. `members.removeMany` answers nothing, because the endpoint reports what it\nremoved in the body of a `204` — a status no client may read a body from.\n\n`competitions.update` carries neither `length` nor `timeBegin`.\nThe backend decides a request is a reschedule by counting six keys in the body —\n`year`, `month`, `day`, `hour`, `minute` and `length` — and refuses any number of\nthem from one to five, so `length` alone is always a `400`; `timeBegin` is\nwritable and would move the row's start time while leaving the scheduled start\njob pointing at the old one. `competitions.schedule` sets both, together, and is\nthe only method here that reschedules.\n\n`competitions.create` is **experimental**. It takes a name and nothing else,\nbecause the creation endpoint reads nothing else off the body: it hard-codes the\ntype, the registration flag and a three-language placeholder title, and the rest\nof the row comes from model defaults. The method documents every injected value,\nand the two worth knowing before you call it are that the competition is created\n**invisible** and with registration **closed**. If the backend grows a create\nthat accepts what it currently injects, this input gains those fields.\n\n`taskJudge.updateGrader` is **experimental**, for three things the endpoint does\nthat its shape does not suggest. An `originalLanguage` that names no grader is\nnot a `404`: the view creates one, under **that** language rather than the\n`programmingLanguage` the same request supplied — the repairable one, since\n`removeGrader` deletes what it left. A rename onto a language another grader\nalready holds is not refused either, and nothing below the view refuses it — no\nconstraint exists — after which three of the four grader routes answer `500` or\n`400` for that language and **no request can repair the task**: it can be\nneither changed, nor removed, nor created again there. And on a duplicated task\nthe row it rewrites is the source task's row, which is what the example above\nnotes for `removeGrader`. All three are in\n[`docs/backend-contract-todos.md`](docs/backend-contract-todos.md), with the\nevidence, and on the method itself.\n\n`taskJudge` also **refuses** seven caller values, which `tasks` refuses none of:\na floating-point precision that is not a whole number from 0 to 15, a custom\nchecker language that is present and is not a string, an interactor update\nnaming neither field, an interactor field that is present and is not a string,\nan empty or absent custom checker source, a custom checker source that is\npresent and is neither `null` nor a string, and a grader field that is present\nand is not a string. Four of the seven are values this backend accepts and\nquietly reinterprets — `int(2.7)` is `2`, `int(True)` is `1`, an empty\ninteractor update answers `200` having changed nothing, and a non-string grader\nfield or custom checker source is stored as `str(...)` of itself — rather than\nthe values it refuses cleanly, which are left to it. **Two more are a present\n`null`**, at two routes and for two reasons. At the checker it is about a\ndefault **this SDK** resolves:\n`request.data.get(\"checker_language\", \"cpp:17-gnu10\")` answers\nthat default for an absent key and not for a key whose value is null, so a\n`null` is neither an omission nor a value the column can hold, and resolving the\ndefault for it would compile the checker as C++ on a request that named no\nlanguage. At the interactor nothing is defaulted and the endpoint's own\n`is not None` is what makes a `null` an omission, so `{ interactor: null }`\nnames no field the view will act on: sending it spends a round trip to be told\nthat a task nobody changed was changed, which is the empty update above reached\nby the road a check against `undefined` does not watch.\n\n**The two type refusals whose absence wrote a row are the ones to read.**\n`HubTaskGradersView` checks its two required fields for truthiness rather than\ntype, so a `5` reached a `CharField`; `createGrader` then rejected the `201`,\nbecause that response is built from the object in memory and still carried the\nnumber, **after** the row existed. `updateGrader` was quieter — it answers by\nre-reading the list, so the coerced strings came back and the call resolved\nhaving stored `'5'`. The two header fields keep admitting `null`, which this\npackage forwards on purpose. `HubTaskCheckerView.put`'s custom branch is the\nsame failure with not even a truthiness check in front of it: a `checker` that\nis not a string is written to a `TextField` as `str(...)` of itself and echoed\nback as it was sent, so `setChecker` rejected an answer describing a task it had\nalready reconfigured.\n\n**And the empty custom checker source is the only one a TypeScript consumer can\nreach at all.** `checker` is declared `string`, `''` is a `string`, and that\nbranch has no presence check — so `''` was stored and answered `200`, leaving a\ntask set to run a checker with nothing to compile. An **absent** source is the\nsame request and the same refusal: `request.data.get(\"checker\", \"\")` reads a\nmissing key as that same `''`, so a JavaScript consumer who leaves the field out\nreaches the state the empty string does. A present `null` is in neither that\nrefusal nor the type one above, and is forwarded: it is a `500` from a\n`NOT NULL` column, which is the deployment's to report.\n`checkerType: 'no_checker'` is the request that actually means that.\n`src/task-judge.ts` states the boundary, which falls between a **type** question\nthis SDK owns and a **value** question the endpoint answers with a clean `400`,\nand why it does not contradict the rule `src/tasks.ts` follows.\n\n`leaderboard.exportCsv` sends no `format` parameter, and that is not an\nomission: `format` is also DRF's renderer selector, and no renderer on that view\nhas the format `csv`, so `?format=csv` is a `404` raised during content\nnegotiation — before the view runs and before authentication. The view's own\ndefault is already CSV.\n\n`testsets` has several surprises worth knowing before the first call, all of them\nthe endpoint's. Its listing reads **`page_size`** where every neighbouring\nlisting reads `per_page` — the input is still `perPage`, as it is everywhere\nelse here, and the translation happens on the way out — and a `perPage` of `0`\nturns the endpoint's pagination off, so it answers a bare array, which\n`testsets.list` refuses because it is not a page. `testsets.get` answers\n`inputs` and `outputs` **truncated** at\nfifty characters with a literal `...`, with no field saying which elements were\nshortened, so a whole test is `testsets.getTest`. `test_count` is a stored\ncounter rather than a measurement: `uploadTest` never increments it while both\ndeletions decrement it, so it drifts and can go negative —\n`update({ testCount })` is the only repair — except through `upload`, which is\nthe one route that writes the counter from what it stored. The three mutating\ntest methods answer a **sentence** in `detail` rather than a document; the count\nin the bulk one is the length of the list that was sent. And `upload`\n**replaces**: the Celery task clears the testset's tests before it stores\nanything, so a ZIP upload discards every test that was there — and its\n`completed` message counts the input files in the last directory its walk\nvisited rather than the tests it stored, which is one more reason those messages\nare prose. Before CODANYP-614, `upload` did not check that the testset belonged\nto the task: a wrong `testsetId` could overwrite another testset's tests instead\nof answering `404`. The recorded backend ancestry used for this candidate scopes\nthe row in both the view and the Celery worker; the historical defect and its\nresolution remain in\n[the backend-contract TODO index](docs/backend-contract-todos.md).\n\n`hubTasks.create` answers the new task's id and name and **not** the task: the\nendpoint's creation serializer declares those two fields and no others, so the\ntype is `ManageHubTaskCreated` rather than a `ManageTask` with twelve holes in\nit. `hubTasks.searchAvailable` is not scoped to the hub the facade is bound to,\nbecause its route has no hub segment at all — it searches every task the caller\ncan reach, which is what makes it useful for building a competition — and the\nset it searches is wider than the endpoint's own comments claim, for a reason\nfiled as CODANYP-581. `hubTasks.list` is the one method in this package that\nsupplies a default the backend already has: it sends `per_page=20`, because the\nendpoint's own default is a page of **one**.\n\n`leaderboard.get` does not validate `page` or `perPage`, and neither does its\nendpoint: it substitutes its own defaults for anything unparseable or below `1`,\ncaps the page size at `100` and never answers `400`. A refusal here would be a\nrule this SDK invented, so what the endpoint does instead is written on the\ninput type rather than enforced. The exception is a **non-finite** number, which\nnever reaches the endpoint at all — see the next section — and which the\nendpoint would have defaulted like any other unparseable value.\n\n### What is validated before a request is sent\n\nAlmost nothing, on purpose: a hub name, a username, a page size or a\ncompetition's `type` is legal or not according to a deployment, and a rejection\nfrom a rule this SDK invented would be a second, quieter answer beside the\nbackend's. Seven exceptions, six of them about values a deployment does not get\nto define and one about a value a deployment silently rewrites:\n\n- `competitions.schedule` range-checks its six components — whole numbers,\n  `year` 1-9999, `month` 1-12, `day` 1-31, `hour` 0-23, `minute` 0-59, `length`\n  above zero. The `day` bound is necessary and not sufficient: a 31st of April\n  is still refused by the backend, as a `500`.\n- `competitions.update` refuses an input naming no field, which the endpoint\n  would otherwise accept and answer `200` to, having changed nothing.\n- `taskJudge` refuses seven values across its five writing methods — a\n  floating-point checker precision that is not a whole number from 0 to 15, a\n  custom checker language that is present and is not a string, an interactor\n  update naming neither field, an interactor field that is present and is not a\n  string, an empty or absent custom checker source, a custom checker source that\n  is present and is neither `null` nor a string, and a grader field that is\n  present and is not a string. These are refused for **three different\n  reasons**, and the difference is the one this whole list is drawn on.\n\n  Most are values the backend **accepts and quietly reinterprets**: `int(...)`\n  truncates a fractional precision, `str(...)` on the way to a `CharField` or a\n  `TextField` turns a number into its digits, `is not None` reads a `null`\n  interactor field as an omission, and an update naming nothing is saved and\n  answered `200`. Each of those succeeds, having done something other than what\n  was asked — and on `createGrader` and `setChecker` it succeeds at the database\n  and then fails at the client, because the response is built from the object in\n  memory and carries the value as it was sent while the row holds the coerced\n  one, so the caller is told the call failed and has the grader, or the\n  reconfigured task, anyway.\n\n  One is a **backend failure** rather than a reinterpretation. A\n  `checkerLanguage` of `null` reaches a column that is `NOT NULL`, so\n  forwarding it is a database error the caller meets as a `500` naming no\n  field — while resolving this SDK's own default for it instead would configure\n  a language the request never named.\n\n  **A `checker` of `null` is that same backend failure and is _not_ refused**,\n  which is the one asymmetry in this list. Nothing is defaulted for it, so there\n  is no second wrong answer to choose between: the `500` is the deployment's own\n  report of its own `NOT NULL` column, and this package leaves it there, exactly\n  as it forwards a `null` grader `header`. Every other non-string source is\n  refused, because those are reinterpreted rather than reported.\n\n  One is neither: an **empty custom checker source** is stored exactly as it was\n  sent, and the `200` is accurate about what happened. What it is not accurate\n  about is that the task can now be judged, and the same view file answers `400`\n  for the equivalent empty `code` on the grader route. It is also the only value\n  in this whole list a TypeScript consumer can reach without a type violation\n  somewhere. An **absent** source is refused with it, from the untyped boundary\n  where it is reachable: `request.data.get(\"checker\", \"\")` reads the missing key\n  as that same empty string, so the two are one request and one refusal.\n\n  The values the backend refuses cleanly are left to it — an empty grader\n  `code`, `programmingLanguage` or `originalLanguage` is its `400` to give — and\n  the paragraph on `taskJudge` above says which is which. The line falls between\n  a **type** question and a **value** one: a type check cannot be split along the\n  endpoint's own truthiness without writing Python's conversion table out in\n  TypeScript, so it refuses shapes the endpoint would also have refused, while a\n  value question is left to the endpoint wherever the endpoint answers it.\n\n- `testsets` refuses six things, and each of them is a `500`, a silent no-op or\n  a deletion nobody asked for on the other side: a testset `index`, `testCount`,\n  `score` or `links` element that is not a whole number the wire can carry\n  exactly — and, on `create` alone, an `index` outside the four-byte column's\n  own range, because that route writes it straight onto the column with no\n  serializer in front of it, where a fractional value is truncated rather than\n  refused and an out-of-range one is a `500`; an update naming no field; a test\n  index that is not a non-negative whole number, which Django's path converter\n  would not match and Python's `int()` would silently truncate; a multiple-test\n  deletion naming nothing, or naming the same index twice, which deletes a\n  neighbouring test as well; an upload position that is neither `-1` nor a\n  non-negative whole number, because any other negative number is a Python index\n  counted from the end; and an upload file whose name resolves to nothing, which\n  the platform would otherwise send as `filename=\"blob\"`. What it does **not**\n  refuse is as deliberate: a page size, a `testScore` string, an extension the\n  endpoint would reject, and a test index past the end of the testset — that\n  last one would need a read of the testset first, which would double every call\n  and still race it.\n- A **non-finite** number in any query parameter — `NaN`, `Infinity`,\n  `-Infinity` — is refused by the shared query layer, because there is no query\n  string that means one. This one belongs to the whole package rather than to a\n  module: the three member listings, `competitions.list`, `leaderboard.get`,\n  `testsets.list` and all three `hubTasks` listings all take a `page` and a\n  `perPage` typed `number`, and a computed page can be\n  `NaN` without anyone writing it. It is also the exception a deployment might\n  disagree with — the leaderboard endpoint, for one, would have replaced `NaN`\n  with its own default rather than refusing it — so it is documented on the\n  method rather than presented as the backend's own rule.\n\n- `leaderboard.exportCsv` refuses an `as` that is neither `'blob'` nor\n  `'array-buffer'`. This is the one value in the list that a deployment never\n  sees at all: it selects which representation of the bytes this SDK produces,\n  the request is byte-identical either way, and the vocabulary is the package's\n  own. TypeScript already rejects the rest, so the check is for a JavaScript\n  caller — where `'arraybuffer'` used to resolve quietly to a `Blob`, which is a\n  wrong answer rather than an error.\n\n- `hubTasks.removeMany` refuses a `taskIds` element that is not a whole number.\n  This is the one exception that is **not** about a rule the backend has: the\n  bulk deletion puts the list into an `id__in` lookup, and Django prepares each\n  value with `int()`, so `1.5` deletes the task called `1` and leaves it out of\n  the report — a `200` naming nothing, for a task the caller never wrote. There\n  is no `400` here to defer to, which is what makes deferring the wrong answer.\n  `NaN`, both infinities and an array hole go with it. The endpoint half is in\n  [backend-contract TODOs](docs/backend-contract-todos.md).\n\nThe first four and the seventh raise `CodeAnyConfigurationError` with code\n`INVALID_REQUEST_ARGUMENT` and a `details.property` naming the field; the fifth\nraises the same class with code `INVALID_QUERY_PARAMETER`, a `details.reason` of\n`'non-finite-number'` and the parameter's position rather than its name; the\nsixth raises it with code `INVALID_RESPONSE_MODE`, which is the code the shared\ncore raises for an unsupported response mode when it is the layer that sees one.\nNone of the seven sends a request.\n\nEvery **numeric** check among those seven also refuses a value that is not a\nnumber, and that is not a formality. `%`, `>=` and `<=` all coerce their\noperands, so `null`, `false`, `''` and `[]` reach them as `0`, `true` as `1` and\n`'5'` as `5` — each of them a whole number inside some range, and none of them a\nvalue the declared type ever admitted. TypeScript refuses them at the call site;\na JavaScript consumer, a value parsed out of JSON or a configuration object\nnobody typed does not, and these endpoints read what they are sent with Python's\n`int()`, where `int(True)` is `1`. So `competitions.schedule` with a `month` of\n`true` scheduled January, and `testsets.create(9, { index: true })` created a\ntestset at index `1`, both under a success status and neither naming the value\nthe caller wrote. `src/arguments.ts` holds the predicate `competitions` and\n`testsets` share, and the reasoning; `src/hub-tasks.ts` and `src/task-judge.ts`\neach hold one of their own, written the same way. And\n`'5'` is refused with the rest even though some of these routes would have read\nit as `5`: a predicate that is sound for numbers and lenient for strings is a\ncoercion policy, and this package has none.\n\n`tasks` adds **no validation of its own**, and `hubTasks` adds exactly the one\nabove. The other caller value either of them refuses before sending is the\nnon-finite page or page size — the fifth exception, which belongs to the shared\nquery layer rather than to a module, and which all three `hubTasks` listings\ninherit because they take a page and a page size. No other rule of these two\nmodules' own is checked. The backend's\nranges are real — an order index is a whole number from 1 to 1,000,000, a colour\nis `#RRGGBB`, a reorder must name every relationship row, a language must\nnormalize to a supported key — and every one of them is documented on the field\nit governs and enforced by the endpoint that owns it. Nothing is clamped,\ndeduplicated, reordered or repaired on the way out, and no caller array is\nwritten to; a value outside a range arrives back as a `CodeAnyApiError` carrying\nthe deployment's own `400`. `src/tasks.ts` states why a client-side refusal is\nnot on offer.\n\nMismatches the SDK does work around are listed in\n[backend-contract TODOs](docs/backend-contract-todos.md) — including the CSV\n`format` collision above. Four entries there preserve the historical evidence\nfor backend security defects that gated this candidate: CODANYP-570's cross-hub\nleaderboard read, CODANYP-614's cross-task testset ZIP write, CODANYP-595's\nshared grader mutations, and CODANYP-167's attachment-storage lifecycle gap.\nAll four fixes are integrated in recorded backend SHA\n`aa4d9d661894731dd0454b03298a4281519adcdb`, so the complete `leaderboard`,\n`testsets`, `taskJudge`, and `taskAttachments` surfaces remain in\n`0.1.0-alpha.1`. That recorded SHA is release provenance rather than a claim\nthat the SDK contract suite verified the backend, and neither public package\nhas been published. CODANYP-167's\n[forensic entry](docs/backend-contract-todos.md#deleting-an-attachment-leaves-its-stored-object-behind)\nremains intact with the normal-path fix and its operational residuals.\n\n`baseUrl` is the **deployment root**, not the hub API prefix: hub routes live\nunder `/api/hubs/<hub_name>/…` and token refresh is `POST /api/users/refresh`,\nwhich is outside that prefix. Every request path is confined to the configured\nbase.\n\nDeep imports (for example `@codeany-inc/hubs-manage-sdk/dist/...`) are not part\nof the package contract and may break in any release. The only other entry in\nthe manifest's `exports` map is `./package.json`, which resolves a file rather\nthan a module surface.\n\n## Packaging\n\n- ESM and CommonJS builds with TypeScript declarations, published from `dist/`.\n- Targets Node.js `>=18.14.1 <19 || >=19.6.1` and evergreen browsers, using the\n  platform `fetch`.\n- No runtime dependencies; the shared core is bundled in.\n- `sideEffects: false`, so unused exports can be tree-shaken.\n- JavaScript source maps are published, and they map **this package's own source\n  only**. Declaration maps also name private-core declaration sources that do\n  not ship, so those locations fall back to the bundled declaration. The shared\n  core is bundled in but its source is not shipped, so a debugger stepping into\n  a core frame shows the bundled JavaScript rather than the original. That is\n  deliberate: the core is private, and a map carrying its text would publish\n  it. The rule and how it is enforced are in\n  [package boundaries](../docs/package-boundaries.md#4-bundling-is-not-publishing-exposure-is).\n\nDevelopment happens in the [SDK workspace](../README.md); this package is\nversioned and released independently of `@codeany-inc/hubs-client-sdk`.\n\n## License\n\nLicensed under the [Apache License 2.0](LICENSE).\n","readmeFilename":"README.md","_rev":"1-3d8e3d48cd83e156a9ba264febb9c54c"}