Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
`npm publish --provenance`.
- Repository ownership metadata now includes `.github/CODEOWNERS`, and release
docs now spell out maintainer continuity expectations.
- Portal and Synergia requests now support explicit timeouts through
`requestTimeoutMs` and `LIBRUS_TIMEOUT_MS`.

### Changed

Expand All @@ -21,6 +23,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- CLI text rendering now uses an internal formatter instead of `cliui`,
reducing the published runtime dependency footprint while preserving the
existing text/json command surface.
- Hanging SDK and CLI requests now fail predictably with the stable
`NETWORK_TIMEOUT` code and secret-safe `{ endpoint, timeoutMs }` details.

## [0.3.2] - 2026-04-01

Expand Down
39 changes: 27 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,20 @@ CLI published from this repository.

### Environment

`LibrusSession.fromEnv()` and the CLI read credentials from these variables:
`LibrusSession.fromEnv()` and the CLI read credentials and timeout settings from
these variables:

| Variable | Required | Purpose |
| ------------------------ | -------- | -------------------------------------------------- |
| `LIBRUS_PORTAL_EMAIL` | Yes | Portal login email used for `portal.librus.pl`. |
| `LIBRUS_PORTAL_PASSWORD` | Yes | Portal login password used for `portal.librus.pl`. |
| `LIBRUS_EMAIL` | Fallback | Compatibility alias for `LIBRUS_PORTAL_EMAIL`. |
| `LIBRUS_PASSWORD` | Fallback | Compatibility alias for `LIBRUS_PORTAL_PASSWORD`. |
| `LIBRUS_TIMEOUT_MS` | No | Positive integer request timeout in milliseconds. |

If no timeout is configured, portal and child-scoped SDK requests default to
`30000` milliseconds. Invalid timeout values fail fast with
`CONFIGURATION_ERROR`.

### Top-Level SDK Entry Points

Expand Down Expand Up @@ -94,7 +100,7 @@ console.log(grades);
Current high-level methods on `LibrusSession`:

- `LibrusSession.fromEnv(env?)`
- `new LibrusSession({ credentials, portalClient?, portalClientOptions?, synergiaClientOptions? })`
- `new LibrusSession({ credentials, portalClient?, portalClientOptions?, synergiaClientOptions?, requestTimeoutMs? })`
- `login()`
- `getPortalMe()`
- `getSynergiaAccounts()`
Expand Down Expand Up @@ -155,6 +161,7 @@ here rather than promising every helper type as a named top-level export.
| `portalClient` | No | Reuse an existing `PortalClient` instance instead of constructing one internally. |
| `portalClientOptions` | No | Passed to the internally created `PortalClient` when `portalClient` is not supplied. |
| `synergiaClientOptions` | No | Passed to `SynergiaApiClient` instances created by `forChild(...)`. |
| `requestTimeoutMs` | No | Session-wide default timeout for internally created portal and child clients. |

`PortalClient` constructor options (`PortalClientOptions`):

Expand All @@ -165,13 +172,15 @@ here rather than promising every helper type as a named top-level export.
| `portalApiBaseUrl` | No | Portal API base URL. Defaults to `${portalBaseUrl}/api/v3`. |
| `loginPath` | No | Login page path. Defaults to `/konto-librus/login`. |
| `loginActionPath` | No | Login form submission path. Defaults to `/konto-librus/login/action`. |
| `requestTimeoutMs` | No | Positive integer timeout in milliseconds. Defaults to `30000`. |

`SynergiaApiClient` constructor options (`SynergiaApiClientOptions`):

| Property | Required | Meaning |
| ------------ | -------- | --------------------------------------------------------------- |
| `fetch` | No | Custom fetch implementation for child-scoped API requests. |
| `apiBaseUrl` | No | Synergia API base URL. Defaults to `https://api.librus.pl/3.0`. |
| Property | Required | Meaning |
| ------------------ | -------- | --------------------------------------------------------------- |
| `fetch` | No | Custom fetch implementation for child-scoped API requests. |
| `apiBaseUrl` | No | Synergia API base URL. Defaults to `https://api.librus.pl/3.0`. |
| `requestTimeoutMs` | No | Positive integer timeout in milliseconds. Defaults to `30000`. |

`generateOpenApiDocument()` options (`GenerateOpenApiDocumentOptions`):

Expand All @@ -186,12 +195,12 @@ here rather than promising every helper type as a named top-level export.
In addition to the top-level classes above, the package root re-exports the
model and error modules under `src/sdk/models/`.

| Category | Public surface |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| Portal and child-account types | `PortalCredentials`, `PortalMe`, `ChildAccount`, `SynergiaAccountsResponse` |
| Shared response helpers | `ApiRef`, `SynergiaBinaryResult`, other common helper interfaces used by SDK responses |
| Synergia response models | Response and entity interfaces for the supported child-scoped API surface under the `synergia` model barrels |
| Error classes | `LibrusSdkError`, `LibrusApiError`, `LibrusAuthenticationError`, `LibrusConfigurationError`, `LibrusPortalPageError`, `LibrusResponseValidationError` |
| Category | Public surface |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Portal and child-account types | `PortalCredentials`, `PortalMe`, `ChildAccount`, `SynergiaAccountsResponse` |
| Shared response helpers | `ApiRef`, `SynergiaBinaryResult`, other common helper interfaces used by SDK responses |
| Synergia response models | Response and entity interfaces for the supported child-scoped API surface under the `synergia` model barrels |
| Error classes | `LibrusSdkError`, `LibrusApiError`, `LibrusAuthenticationError`, `LibrusConfigurationError`, `LibrusNetworkTimeoutError`, `LibrusPortalPageError`, `LibrusResponseValidationError` |

### CLI Surface

Expand Down Expand Up @@ -273,6 +282,7 @@ Current codes emitted by the SDK and CLI:
| `CONFIGURATION_ERROR` | Required credentials or other local configuration are missing or invalid. |
| `AUTHENTICATION_FAILED` | Portal login or post-login verification failed. |
| `PORTAL_LOGIN_PAGE_INVALID` | The portal login page no longer matches the expected CSRF-token structure. |
| `NETWORK_TIMEOUT` | A portal or Synergia request exceeded the configured timeout. |
| `API_REQUEST_FAILED` | A portal or Synergia request failed with a non-maintenance HTTP error. |
| `SERVICE_MAINTENANCE` | The Synergia API reported maintenance mode. |
| `RESPONSE_VALIDATION_FAILED` | The live payload no longer matches the validated SDK schema. |
Expand Down Expand Up @@ -303,6 +313,11 @@ Use the auth helper directly when you need to compare child tokens:
npx librus auth token-info --child <id-or-login>
```

For timeout handling, SDK and CLI timeout failures emit `NETWORK_TIMEOUT` with
secret-safe `details` shaped like `{ endpoint, timeoutMs }`. The endpoint is
the request URL only; credentials, bearer tokens, and cookie values are not
included.

### OpenAPI

`openapi.json` is generated from the SDK's supported Synergia GET endpoints and
Expand Down
46 changes: 42 additions & 4 deletions src/sdk/LibrusSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,17 @@ import {
type PortalMe,
type SynergiaAccountsResponse,
} from "./models/index.js";
import {
parseRequestTimeoutMsFromEnv,
validateOptionalRequestTimeoutMs,
} from "./requestTimeout.js";

export interface LibrusSessionOptions {
credentials: PortalCredentials;
portalClient?: PortalClient;
portalClientOptions?: PortalClientOptions;
synergiaClientOptions?: SynergiaApiClientOptions;
requestTimeoutMs?: number;
}

export class LibrusSession {
Expand All @@ -29,10 +34,34 @@ export class LibrusSession {
private accountsCache?: SynergiaAccountsResponse;

constructor(options: LibrusSessionOptions) {
const requestTimeoutMs = validateOptionalRequestTimeoutMs(
options.requestTimeoutMs,
);
const portalClientOptions =
options.portalClientOptions?.requestTimeoutMs !== undefined ||
requestTimeoutMs === undefined
? options.portalClientOptions
: options.portalClientOptions
? {
...options.portalClientOptions,
requestTimeoutMs,
}
: { requestTimeoutMs };
const synergiaClientOptions =
options.synergiaClientOptions?.requestTimeoutMs !== undefined ||
requestTimeoutMs === undefined
? options.synergiaClientOptions
: options.synergiaClientOptions
? {
...options.synergiaClientOptions,
requestTimeoutMs,
}
: { requestTimeoutMs };

this.credentials = options.credentials;
this.portalClient =
options.portalClient ?? new PortalClient(options.portalClientOptions);
this.synergiaClientOptions = options.synergiaClientOptions;
options.portalClient ?? new PortalClient(portalClientOptions);
this.synergiaClientOptions = synergiaClientOptions;
}

static fromEnv(env: NodeJS.ProcessEnv = process.env): LibrusSession {
Expand All @@ -45,9 +74,18 @@ export class LibrusSession {
);
}

return new LibrusSession({
const requestTimeoutMs = parseRequestTimeoutMsFromEnv(
env.LIBRUS_TIMEOUT_MS,
);
const sessionOptions: LibrusSessionOptions = {
credentials: { email, password },
});
};

if (requestTimeoutMs !== undefined) {
sessionOptions.requestTimeoutMs = requestTimeoutMs;
}

return new LibrusSession(sessionOptions);
}

async login(): Promise<void> {
Expand Down
11 changes: 11 additions & 0 deletions src/sdk/models/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export interface LibrusErrorDetails {
endpoint?: string;
issues?: string[];
status?: number;
timeoutMs?: number;
matches?: Array<Record<string, string | number>>;
[key: string]: unknown;
}
Expand Down Expand Up @@ -43,6 +44,16 @@ export class LibrusConfigurationError extends LibrusSdkError {
}
}

export class LibrusNetworkTimeoutError extends LibrusSdkError {
constructor(endpoint: string, timeoutMs: number) {
super("NETWORK_TIMEOUT", `Librus request timed out after ${timeoutMs}ms.`, {
endpoint,
timeoutMs,
});
this.name = "LibrusNetworkTimeoutError";
}
}

export class LibrusPortalPageError extends LibrusSdkError {
constructor(
message = "Portal login page no longer contains the expected CSRF token.",
Expand Down
12 changes: 11 additions & 1 deletion src/sdk/portal/PortalClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import {
type PortalMe,
type SynergiaAccountsResponse,
} from "../models/index.js";
import {
resolveRequestTimeoutMs,
wrapFetchWithTimeout,
} from "../requestTimeout.js";
import type { BaseIssue, BaseSchema, InferOutput } from "valibot";
import {
portalMeSchema,
Expand All @@ -21,6 +25,7 @@ export interface PortalClientOptions {
portalApiBaseUrl?: string;
loginPath?: string;
loginActionPath?: string;
requestTimeoutMs?: number;
}

export class PortalClient {
Expand All @@ -29,11 +34,16 @@ export class PortalClient {
private readonly portalApiBaseUrl: string;
private readonly loginPath: string;
private readonly loginActionPath: string;
private readonly requestTimeoutMs: number;
private loggedIn = false;

constructor(options: PortalClientOptions = {}) {
this.requestTimeoutMs = resolveRequestTimeoutMs(options.requestTimeoutMs);
const sessionFetch = createSessionFetch(options.fetch ?? fetch);
this.fetchImpl = sessionFetch.fetch;
this.fetchImpl = wrapFetchWithTimeout(
sessionFetch.fetch,
this.requestTimeoutMs,
);
this.portalBaseUrl = options.portalBaseUrl ?? "https://portal.librus.pl";
this.portalApiBaseUrl =
options.portalApiBaseUrl ?? `${this.portalBaseUrl}/api/v3`;
Expand Down
Loading
Loading