{"_id":"@codeany-inc/hubs-client-sdk","name":"@codeany-inc/hubs-client-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-client-sdk","version":"0.1.0-alpha.1","description":"TypeScript SDK for the CodeAny hub client 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-client-sdk"},"bugs":{"url":"https://github.com/CodeAny-inc/ms-codeany-mono/issues"},"_id":"@codeany-inc/hubs-client-sdk@0.1.0-alpha.1","homepage":"https://github.com/CodeAny-inc/ms-codeany-mono#readme","_integrity":"sha512-Ypw4u1ILhERXSDI95ELf0N6TuE9f2HczgMcM4aWBL/UsEFuq4RUI/rfXPQoHqc0GlFsoMMDSzLguZXyKOkrk2Q==","_resolved":"/tmp/codeany-sdk-alpha-0282a59b.mjudCx/codeany-inc-hubs-client-sdk-0.1.0-alpha.1.tgz","_from":"file:/tmp/codeany-sdk-alpha-0282a59b.mjudCx/codeany-inc-hubs-client-sdk-0.1.0-alpha.1.tgz","_nodeVersion":"22.23.2","_npmVersion":"10.9.8","dist":{"integrity":"sha512-Ypw4u1ILhERXSDI95ELf0N6TuE9f2HczgMcM4aWBL/UsEFuq4RUI/rfXPQoHqc0GlFsoMMDSzLguZXyKOkrk2Q==","shasum":"58e8ec8a2731eaea96738e13a3d3030734c06199","tarball":"https://registry.npmjs.org/@codeany-inc/hubs-client-sdk/-/hubs-client-sdk-0.1.0-alpha.1.tgz","fileCount":25,"unpackedSize":1081096,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIGpf6F+d4DufyZ7ZSGX9l/8G4XsU0qNEh5WkCsgtXPnZAiEApgMXPHkV8mVYtj6eVnrEmlXiB9lMZtxWQw1GCLOCQFI="}]},"_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-client-sdk_0.1.0-alpha.1_1788124462685_0.8445185665275494"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-30T21:14:22.454Z","0.1.0-alpha.1":"2026-08-30T21:14:22.837Z","modified":"2026-08-30T21:14:23.110Z"},"maintainers":[{"name":"huseyn.hajiyev","email":"huseyn.hajiyev@codeany.org"}],"description":"TypeScript SDK for the CodeAny hub client 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-client-sdk"},"bugs":{"url":"https://github.com/CodeAny-inc/ms-codeany-mono/issues"},"license":"Apache-2.0","readme":"# `@codeany-inc/hubs-client-sdk`\n\nTypeScript SDK for the CodeAny hub client APIs.\n\n<!-- claim: exports hubs-client-sdk/src/index.ts = 99 names (sha 58008588) -->\n\n> **Status: pre-release.** Version `0.1.0-alpha.1` is prepared for public\n> publication under the `alpha` dist-tag and is not published yet. Its entry\n> point — [`src/index.ts`](src/index.ts), the subject of the claim above — carries the client\n> foundation: the factory, the client's shape, the credential and language\n> configuration, the escape hatch and the error model. Three of the sixteen\n> module names have endpoints behind them — `auth`, added by CODANYP-489,\n> `competitions`, added by CODANYP-490, and `participation`, added by\n> CODANYP-491 — and the typed resource methods behind\n> the other thirteen are added by later work, so those facades are still named\n> placeholders.\n\n## Usage\n\nImport from the package root only:\n\n```ts\nimport { createCodeAnyHubsClient } from '@codeany-inc/hubs-client-sdk';\n\nconst client = createCodeAnyHubsClient({\n  baseUrl: 'https://codeany.example',\n  auth: { jwt: { token: accessToken }, session: 'same-origin' },\n  interfaceLanguage: 'en',\n});\n\n// The escape hatch, which is on the root client and nowhere else.\nconst competitions = await client.raw.request({\n  method: 'GET',\n  path: '/api/competitions/list',\n});\n\n// Two scoped facades. Both are cheap, and both are a new object every call.\nconst acme = client.forHub('acme');\nconst round = client.forCompetition(42);\n```\n\n### Signing in\n\n`auth` is on the root client, and the hub is an argument: a client calling these\nhas no identity yet, and three of the four routes name a hub in their path.\nNothing here changes the client — a token is returned and never stored — so\napplying one is a line the consumer writes.\n\n```ts\nconst result = await client.auth.login('acme', {\n  username: 'ada',\n  password: 'secret',\n});\n\nif (result.outcome === 'authenticated') {\n  client.setJwtToken(result.token);\n} else {\n  // A 200 as well: this account exists and is not a member of that hub.\n  showRegistrationForm();\n}\n\n// Later, when the access token has expired. `storedRefreshToken` is the\n// consumer's own: the credential store has no slot for a refresh token.\nconst refreshed = await client.auth.refreshAccessToken({\n  refreshToken: storedRefreshToken,\n});\nclient.setJwtToken(refreshed.access);\n\nif (refreshed.refresh !== null) {\n  // A rotated token, which replaces the one just sent.\n  storedRefreshToken = refreshed.refresh;\n}\n```\n\nThat write is **conditional** because `refreshed.refresh` is `null` on every\ndeployment that leaves Simple JWT's `ROTATE_REFRESH_TOKENS` off — the default.\nSuch a deployment answers no new refresh token because the stored one is still\nthe valid one, and overwriting it with `null` would leave the consumer with\nnothing to refresh with: worse than not writing at all. Where rotation _is_ on,\nthe response carries a new refresh token and the deployment may blacklist the\none just sent, so a consumer that keeps the old one fails on the **next**\nrefresh rather than this one.\n\nRegistration takes either form the backend branches between — a password with\nits confirmation, or a Google OAuth token — and the two are exclusive in the\ntype system. `client.auth.verifyOauthToken('acme', { oauthToken })` answers what\nthe backend knows about the identity behind a token before anything is created,\nand signs the caller in when that identity is already a member.\n\nInput fields are camelCase and **response fields keep the backend's own\nspelling**, `csrf_token` and `sessionid` included: renaming a field a consumer\nreads off backend data would make this SDK's documentation and the backend's\ndisagree about what the same value is called.\n\n### Competitions, and what a participant may do in one\n\n`competitions` is on a **hub-scoped** facade, because the participant listing\nroute reads the hub from its `hub` query parameter. The other two methods take\nthe competition as an argument, because their routes name no hub.\n\n```ts\nconst competitions = client.forHub('acme').competitions;\n\nconst page = await competitions.list({ page: 1, perPage: 20 });\nfor (const competition of page.list_competitions) {\n  render(competition.id, competition.titles);\n}\n\nconst { competition, tasks } = await competitions.getContent(42);\n```\n\n`page.list_competitions` is an array even when the hub has none: the backend\nsends the string `'empty'` in place of an empty list, on this route and on the\n`tasks` of a content response, and both arrive here as `[]`. A body carrying\nsome _other_ string is refused, because that is not a document either endpoint\nproduces.\n\n**Render the workflow; do not recompute it.** `getWorkflow` answers with the\ndocument `build_workflow_state` produces, which is the same one the backend\ndecides submission scope and question permissions from — so a client that works\nout for itself whether a competition is open, from its start time and length,\nis computing a second opinion the backend will not honour.\n\n```ts\nconst workflow = await competitions.getWorkflow(42);\n\nif (workflow.can_submit_official) {\n  showSubmitButton();\n} else if (workflow.can_start) {\n  showStartButton();\n}\n```\n\nEvery flag on it is a `boolean` rather than a `boolean | null`, and a `2xx`\nmissing one is refused rather than returned with a hole: a nullable permission\ninvites `?? false` at the call site, and `false` on a permission the response\nsaid nothing about would close a competition the backend was accepting\nsubmissions for. The times, counts and status strings beside them are nullable,\nbecause a caller displays those rather than branching on them.\n\nAll three calls are reachable **unauthenticated**. An anonymous workflow read is\nnot an error: it answers `participation_state: 'anonymous'` with every flag\n`false`, which is what a \"sign in to take part\" screen renders from.\n\n### Taking part in one\n\n`participation` is on the **competition-scoped** facade, and the competition is\ninjected into every call: all five routes name one, so none of these methods\ntakes a competition argument.\n\n```ts\nconst participation = client.forCompetition(42).participation;\n\n// The questions come off the workflow document; the answers go back by id.\nconst { workflow } = await participation.register({\n  answers: [{ questionId: 7, response: 'Baku' }],\n});\n\nif (workflow.can_start) {\n  await participation.start();\n}\n```\n\n**A mutation is the workflow read.** Each of `register`, `start`, `finish` and\n`submitFeedback` answers a `message` and a `workflow`, and that workflow is the\nsame document `getWorkflow` returns, recomputed after the change — so following\none of these with `getWorkflow` asks a question already answered.\n\n**`register` is the exception, and only for the tasks.** Its view is the one of\nthe four that never attaches `competition_tasks`, so a registration's workflow\ncarries `null` there whatever it says beside it.\n\nWhat decides whether the follow-up `getWorkflow` is worth a call is a backend\ncondition with **five** arms rather than a list of cases, and any one arm is\nenough: `can_access_problems`, `finished_participation`, `feedback_required`,\n`feedback_submitted`, or `registered` together with `competition_finished`.\nRegistering into a competition **already under way** satisfies the first in the\nsame request. A **COURSE** satisfies it whether or not the course has begun,\nbecause COURSE task access does not depend on the start time at all. And an\n`already_registered` retry can satisfy any of the other four — including on a\ncompetition that merely **expired**, with the participant never having finished\nit and with upsolving and feedback both off, where the last arm is the only one\nthat fires. That last case is the reason this is written as the rule and not as\nan enumeration: the arms are the backend's, they do not correspond one-to-one to\nsituations a consumer would think to name, and a list of cases goes stale the\nfirst time one is added. [`src/participation.ts`](src/participation.ts) names each arm and the\nbackend function that reads them, `_with_accessible_competition_tasks`.\n\nNothing is read before a mutation either: the state a mutation decides on is\nmostly built inside the mutation's own transaction, so a check made here would\nbe a second opinion the backend will not honour, and a refusal arrives as a\n`400` or `403` carrying the backend's own sentence.\n\n**Mostly**, because three of the four decide something from a snapshot taken\nbefore the transaction that carries it out, and none of it is something a client\ncan compensate for. The shape is the same each time: the **view** loads the\ncompetition, and the decision is made from that instance rather than from one\nthe service locked or refetched. `start` runs its **proctor** gate that way and\nreturns its `403` without entering the service at all. `register` tests whether\nregistration is open once before taking the row lock and once after, so a\nregistration racing a reopen can be refused on the earlier read.\n`submitFeedback` is the widest: `submit_feedback` never locks and never\nrefetches, so whether feedback is accepted at all is decided from the row as the\nview found it. All three are the backend acting on its own stale snapshot, which\nis a different thing from this SDK acting on one — and the reason the rule above\nis about what\na _client_ must not do. [`src/participation.ts`](src/participation.ts) states\nwhich of the four take the competition's row lock, which one does not, and where\neach unlocked read sits.\n\n```ts\nconst done = await participation.finish();\n\nif (done.workflow.feedback_required) {\n  await participation.submitFeedback({ text: userTypedFeedback });\n}\n\nconst me = await participation.getCurrentParticipant();\nrender(me.score, me.user.full_name);\n```\n\n`me` is whichever account the **backend** resolved the request to, which is not\nalways the one the token names: this route authenticates in the opposite order\nto the four mutations above, so a client sending a session cookie and a bearer\ntoken for two different accounts reads one person here and changes the other's\nparticipation there. It is the concrete case behind\n[the client options](#the-client-options); [`src/participation.ts`](src/participation.ts) and this\npackage's [backend-contract TODO index](docs/backend-contract-todos.md) carry the evidence and the backend issue.\n\n`me.score` is a **string**, and stays one: the backend's column is a decimal\nwith ten places, so parsing it into a JavaScript number is a lossy step this SDK\ndoes not take for you. `me.tasks` is `unknown` for a related reason — the column\nis schemaless, and for anything that is not an ICPC competition the backend\nreturns it verbatim, so it is frequently not an array at all.\n\nTwo constraints are **documented and not enforced here**, and they do not cut\nthe same way. Registration answer question ids must be unique across the array,\nand feedback text must contain something other than whitespace. This SDK\nneither refuses nor repairs either: your array is forwarded unchanged and\nunmutated, and your feedback text is sent with its interior — newlines, runs of\nspaces, indentation — exactly as written. Blank feedback is then refused by the\ndeployment, whose `400` is the report. A repeated question id is refused by\n**nobody as a duplicate**: the backend keeps whichever row came last and never\nmentions the earlier one. What happens next depends entirely on that surviving\nvalue. If it answers the question, the registration succeeds and the first row\nis gone without a word. If it is blank — a form emitting an empty second row for\na question the user did answer — the registration is refused with\n`400 \"You must answer all questions!\"`, which names no question, because the\ncheck that runs afterwards asks only whether each one ended up with a value.\nEither way a form that can emit two rows for one question is a form that must\nnot, and the failure is silent in one direction and unattributable in the other\n— the [backend-contract TODO index](docs/backend-contract-todos.md) carries that\none. Both of those outcomes are a **first** registration's: a caller who is\nregistered already answers `already_registered` from a branch the backend\nreaches before it looks at the array at all, so a retry's rows are neither\nchecked nor stored whatever is in them. Refusing a caller value\nhere would need an error code that is part of the shared core's public contract,\nand this package repairs no caller value anywhere.\n\n### The client options\n\nThese configure the client itself, so they apply to every call above rather than\nto any one module.\n\n`auth` is optional and its default is **no primary credential**, which is what a\nsign-in page holds. A JWT and an LTI launch token are mutually exclusive — the\nbackend's LTI middleware authenticates allowlisted paths before DRF reads the\nbearer token, so one client carrying both would be two identities — and the\nunion refuses the pair at compile time as well as at run time. `session` is\nindependent of either and coexists with both: it is the Fetch credentials mode,\nwhich decides whether Django's session cookie goes with a request.\n\n**A cookie and a token for two different accounts are two identities, and this\none the SDK cannot refuse.** The cookie is attached by the browser and never\npasses through this package, so there is nothing here to compare — and which of\nthe two authenticated a request is decided route by route, by the backend, not\nuniformly. Hold one identity at a time: sign the session out before applying a\ntoken for another account, or configure `session: 'omit'` and carry the token\nalone. [`src/options.ts`](src/options.ts) states what the choice costs and [`src/participation.ts`](src/participation.ts)\nnames the route where two of this SDK's own calls currently disagree.\n\n**`session: 'omit'` is that answer for a JWT client and is not one for an LTI\nclient.** Omitting the cookie omits the `csrftoken` cookie too, and Django\ncompares the `X-CSRFToken` header against that cookie rather than accepting the\nheader alone. A bearer token never meets the check; a launch token authenticates\nthrough Django's session authentication, so on an unsafe method it does — with\nno cookie left to satisfy it. An LTI client that omits the cookie therefore gets\na `403` from all four of `register`, `start`, `finish` and `submitFeedback`, and\none that sends it gets that same `403` until it configures an `auth.csrf` token\nfor the header half of the comparison — and the identity ambiguity above once it\ndoes. **`'same-origin'` is not a third answer between them**: no cookie goes on\na cross-origin request\nunder that mode, whatever `SameSite` the deployment sets, so in the cross-origin\niframe an LTI launch normally runs in it sends nothing and lands in the first\ncase. Only `'include'` sends the cookie cross-origin. Reading is\nunaffected under any of the three. That is a backend blocker rather than a\nconfiguration to pick between, filed as **CODANYP-601**;\n[`src/participation.ts`](src/participation.ts) and the\n[backend-contract TODO index](docs/backend-contract-todos.md) carry the chain.\n\n`baseUrl` is the **deployment root**, not an API prefix: hub routes live under\n`/api/hubs/<hub_name>/…`, competition routes under `/api/competitions/…` and\nsign-in under `/api/users/…`, and no narrower base reaches all three. Every\nrequest path is confined to the configured base.\n\nDeep imports (for example `@codeany-inc/hubs-client-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-manage-sdk`.\n\n## License\n\nLicensed under the [Apache License 2.0](LICENSE).\n","readmeFilename":"README.md","_rev":"1-6a787836b1f41548440ea5723af4c517"}