@codeany-inc/hubs-client-sdk
v0.1.0-alpha.1
Published
TypeScript SDK for the CodeAny hub client APIs.
Readme
@codeany-inc/hubs-client-sdk
TypeScript SDK for the CodeAny hub client APIs.
Status: pre-release. Version
0.1.0-alpha.1is prepared for public publication under thealphadist-tag and is not published yet. Its entry point —src/index.ts, the subject of the claim above — carries the client foundation: the factory, the client's shape, the credential and language configuration, the escape hatch and the error model. Three of the sixteen module names have endpoints behind them —auth, added by CODANYP-489,competitions, added by CODANYP-490, andparticipation, added by CODANYP-491 — and the typed resource methods behind the other thirteen are added by later work, so those facades are still named placeholders.
Usage
Import from the package root only:
import { createCodeAnyHubsClient } from '@codeany-inc/hubs-client-sdk';
const client = createCodeAnyHubsClient({
baseUrl: 'https://codeany.example',
auth: { jwt: { token: accessToken }, session: 'same-origin' },
interfaceLanguage: 'en',
});
// The escape hatch, which is on the root client and nowhere else.
const competitions = await client.raw.request({
method: 'GET',
path: '/api/competitions/list',
});
// Two scoped facades. Both are cheap, and both are a new object every call.
const acme = client.forHub('acme');
const round = client.forCompetition(42);Signing in
auth is on the root client, and the hub is an argument: a client calling these
has no identity yet, and three of the four routes name a hub in their path.
Nothing here changes the client — a token is returned and never stored — so
applying one is a line the consumer writes.
const result = await client.auth.login('acme', {
username: 'ada',
password: 'secret',
});
if (result.outcome === 'authenticated') {
client.setJwtToken(result.token);
} else {
// A 200 as well: this account exists and is not a member of that hub.
showRegistrationForm();
}
// Later, when the access token has expired. `storedRefreshToken` is the
// consumer's own: the credential store has no slot for a refresh token.
const refreshed = await client.auth.refreshAccessToken({
refreshToken: storedRefreshToken,
});
client.setJwtToken(refreshed.access);
if (refreshed.refresh !== null) {
// A rotated token, which replaces the one just sent.
storedRefreshToken = refreshed.refresh;
}That write is conditional because refreshed.refresh is null on every
deployment that leaves Simple JWT's ROTATE_REFRESH_TOKENS off — the default.
Such a deployment answers no new refresh token because the stored one is still
the valid one, and overwriting it with null would leave the consumer with
nothing to refresh with: worse than not writing at all. Where rotation is on,
the response carries a new refresh token and the deployment may blacklist the
one just sent, so a consumer that keeps the old one fails on the next
refresh rather than this one.
Registration takes either form the backend branches between — a password with
its confirmation, or a Google OAuth token — and the two are exclusive in the
type system. client.auth.verifyOauthToken('acme', { oauthToken }) answers what
the backend knows about the identity behind a token before anything is created,
and signs the caller in when that identity is already a member.
Input fields are camelCase and response fields keep the backend's own
spelling, csrf_token and sessionid included: renaming a field a consumer
reads off backend data would make this SDK's documentation and the backend's
disagree about what the same value is called.
Competitions, and what a participant may do in one
competitions is on a hub-scoped facade, because the participant listing
route reads the hub from its hub query parameter. The other two methods take
the competition as an argument, because their routes name no hub.
const competitions = client.forHub('acme').competitions;
const page = await competitions.list({ page: 1, perPage: 20 });
for (const competition of page.list_competitions) {
render(competition.id, competition.titles);
}
const { competition, tasks } = await competitions.getContent(42);page.list_competitions is an array even when the hub has none: the backend
sends the string 'empty' in place of an empty list, on this route and on the
tasks of a content response, and both arrive here as []. A body carrying
some other string is refused, because that is not a document either endpoint
produces.
Render the workflow; do not recompute it. getWorkflow answers with the
document build_workflow_state produces, which is the same one the backend
decides submission scope and question permissions from — so a client that works
out for itself whether a competition is open, from its start time and length,
is computing a second opinion the backend will not honour.
const workflow = await competitions.getWorkflow(42);
if (workflow.can_submit_official) {
showSubmitButton();
} else if (workflow.can_start) {
showStartButton();
}Every flag on it is a boolean rather than a boolean | null, and a 2xx
missing one is refused rather than returned with a hole: a nullable permission
invites ?? false at the call site, and false on a permission the response
said nothing about would close a competition the backend was accepting
submissions for. The times, counts and status strings beside them are nullable,
because a caller displays those rather than branching on them.
All three calls are reachable unauthenticated. An anonymous workflow read is
not an error: it answers participation_state: 'anonymous' with every flag
false, which is what a "sign in to take part" screen renders from.
Taking part in one
participation is on the competition-scoped facade, and the competition is
injected into every call: all five routes name one, so none of these methods
takes a competition argument.
const participation = client.forCompetition(42).participation;
// The questions come off the workflow document; the answers go back by id.
const { workflow } = await participation.register({
answers: [{ questionId: 7, response: 'Baku' }],
});
if (workflow.can_start) {
await participation.start();
}A mutation is the workflow read. Each of register, start, finish and
submitFeedback answers a message and a workflow, and that workflow is the
same document getWorkflow returns, recomputed after the change — so following
one of these with getWorkflow asks a question already answered.
register is the exception, and only for the tasks. Its view is the one of
the four that never attaches competition_tasks, so a registration's workflow
carries null there whatever it says beside it.
What decides whether the follow-up getWorkflow is worth a call is a backend
condition with five arms rather than a list of cases, and any one arm is
enough: can_access_problems, finished_participation, feedback_required,
feedback_submitted, or registered together with competition_finished.
Registering into a competition already under way satisfies the first in the
same request. A COURSE satisfies it whether or not the course has begun,
because COURSE task access does not depend on the start time at all. And an
already_registered retry can satisfy any of the other four — including on a
competition that merely expired, with the participant never having finished
it and with upsolving and feedback both off, where the last arm is the only one
that fires. That last case is the reason this is written as the rule and not as
an enumeration: the arms are the backend's, they do not correspond one-to-one to
situations a consumer would think to name, and a list of cases goes stale the
first time one is added. src/participation.ts names each arm and the
backend function that reads them, _with_accessible_competition_tasks.
Nothing is read before a mutation either: the state a mutation decides on is
mostly built inside the mutation's own transaction, so a check made here would
be a second opinion the backend will not honour, and a refusal arrives as a
400 or 403 carrying the backend's own sentence.
Mostly, because three of the four decide something from a snapshot taken
before the transaction that carries it out, and none of it is something a client
can compensate for. The shape is the same each time: the view loads the
competition, and the decision is made from that instance rather than from one
the service locked or refetched. start runs its proctor gate that way and
returns its 403 without entering the service at all. register tests whether
registration is open once before taking the row lock and once after, so a
registration racing a reopen can be refused on the earlier read.
submitFeedback is the widest: submit_feedback never locks and never
refetches, so whether feedback is accepted at all is decided from the row as the
view found it. All three are the backend acting on its own stale snapshot, which
is a different thing from this SDK acting on one — and the reason the rule above
is about what
a client must not do. src/participation.ts states
which of the four take the competition's row lock, which one does not, and where
each unlocked read sits.
const done = await participation.finish();
if (done.workflow.feedback_required) {
await participation.submitFeedback({ text: userTypedFeedback });
}
const me = await participation.getCurrentParticipant();
render(me.score, me.user.full_name);me is whichever account the backend resolved the request to, which is not
always the one the token names: this route authenticates in the opposite order
to the four mutations above, so a client sending a session cookie and a bearer
token for two different accounts reads one person here and changes the other's
participation there. It is the concrete case behind
the client options; src/participation.ts and this
package's backend-contract TODO index carry the evidence and the backend issue.
me.score is a string, and stays one: the backend's column is a decimal
with ten places, so parsing it into a JavaScript number is a lossy step this SDK
does not take for you. me.tasks is unknown for a related reason — the column
is schemaless, and for anything that is not an ICPC competition the backend
returns it verbatim, so it is frequently not an array at all.
Two constraints are documented and not enforced here, and they do not cut
the same way. Registration answer question ids must be unique across the array,
and feedback text must contain something other than whitespace. This SDK
neither refuses nor repairs either: your array is forwarded unchanged and
unmutated, and your feedback text is sent with its interior — newlines, runs of
spaces, indentation — exactly as written. Blank feedback is then refused by the
deployment, whose 400 is the report. A repeated question id is refused by
nobody as a duplicate: the backend keeps whichever row came last and never
mentions the earlier one. What happens next depends entirely on that surviving
value. If it answers the question, the registration succeeds and the first row
is gone without a word. If it is blank — a form emitting an empty second row for
a question the user did answer — the registration is refused with
400 "You must answer all questions!", which names no question, because the
check that runs afterwards asks only whether each one ended up with a value.
Either way a form that can emit two rows for one question is a form that must
not, and the failure is silent in one direction and unattributable in the other
— the backend-contract TODO index carries that
one. Both of those outcomes are a first registration's: a caller who is
registered already answers already_registered from a branch the backend
reaches before it looks at the array at all, so a retry's rows are neither
checked nor stored whatever is in them. Refusing a caller value
here would need an error code that is part of the shared core's public contract,
and this package repairs no caller value anywhere.
The client options
These configure the client itself, so they apply to every call above rather than to any one module.
auth is optional and its default is no primary credential, which is what a
sign-in page holds. A JWT and an LTI launch token are mutually exclusive — the
backend's LTI middleware authenticates allowlisted paths before DRF reads the
bearer token, so one client carrying both would be two identities — and the
union refuses the pair at compile time as well as at run time. session is
independent of either and coexists with both: it is the Fetch credentials mode,
which decides whether Django's session cookie goes with a request.
A cookie and a token for two different accounts are two identities, and this
one the SDK cannot refuse. The cookie is attached by the browser and never
passes through this package, so there is nothing here to compare — and which of
the two authenticated a request is decided route by route, by the backend, not
uniformly. Hold one identity at a time: sign the session out before applying a
token for another account, or configure session: 'omit' and carry the token
alone. src/options.ts states what the choice costs and src/participation.ts
names the route where two of this SDK's own calls currently disagree.
session: 'omit' is that answer for a JWT client and is not one for an LTI
client. Omitting the cookie omits the csrftoken cookie too, and Django
compares the X-CSRFToken header against that cookie rather than accepting the
header alone. A bearer token never meets the check; a launch token authenticates
through Django's session authentication, so on an unsafe method it does — with
no cookie left to satisfy it. An LTI client that omits the cookie therefore gets
a 403 from all four of register, start, finish and submitFeedback, and
one that sends it gets that same 403 until it configures an auth.csrf token
for the header half of the comparison — and the identity ambiguity above once it
does. 'same-origin' is not a third answer between them: no cookie goes on
a cross-origin request
under that mode, whatever SameSite the deployment sets, so in the cross-origin
iframe an LTI launch normally runs in it sends nothing and lands in the first
case. Only 'include' sends the cookie cross-origin. Reading is
unaffected under any of the three. That is a backend blocker rather than a
configuration to pick between, filed as CODANYP-601;
src/participation.ts and the
backend-contract TODO index carry the chain.
baseUrl is the deployment root, not an API prefix: hub routes live under
/api/hubs/<hub_name>/…, competition routes under /api/competitions/… and
sign-in under /api/users/…, and no narrower base reaches all three. Every
request path is confined to the configured base.
Deep imports (for example @codeany-inc/hubs-client-sdk/dist/...) are not part
of the package contract and may break in any release. The only other entry in
the manifest's exports map is ./package.json, which resolves a file rather
than a module surface.
Packaging
- ESM and CommonJS builds with TypeScript declarations, published from
dist/. - Targets Node.js
>=18.14.1 <19 || >=19.6.1and evergreen browsers, using the platformfetch. - No runtime dependencies; the shared core is bundled in.
sideEffects: false, so unused exports can be tree-shaken.- JavaScript source maps are published, and they map this package's own source only. Declaration maps also name private-core declaration sources that do not ship, so those locations fall back to the bundled declaration. The shared core is bundled in but its source is not shipped, so a debugger stepping into a core frame shows the bundled JavaScript rather than the original. That is deliberate: the core is private, and a map carrying its text would publish it. The rule and how it is enforced are in package boundaries.
Development happens in the SDK workspace; this package is
versioned and released independently of @codeany-inc/hubs-manage-sdk.
License
Licensed under the Apache License 2.0.
