@wilm-ai/wilma-client 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,6 +50,7 @@ Wilma allows one live session per account: a new login ends the previous one, an
50
50
  - **Log in once** and use `client.forStudent(number)` for each child; they share the login.
51
51
  - A session that Wilma ends (another login, or expiry) **logs in again by itself** on the next request — once, even when many requests notice at the same time, including the two-step verification step. A refused password stops further attempts, so a changed password can't lock the account.
52
52
  - To continue a session in another process, save `client.exportSession()` (treat it like the password) and later call `WilmaClient.resume(profile, saved, onMfa)`. `client.onLogin(callback)` tells you when a new session should be saved.
53
+ - The password for `resume()` can be a function, e.g. one reading a keychain: `WilmaClient.resume({ baseUrl, username, password: () => readFromKeychain() }, saved)`. It's called only when Wilma has ended the session and a new login is needed, never while the saved one works, and once for many requests. If it fails, the request fails with `CredentialsUnavailableError` (with `reason` and `cause` from your error) and the next request tries again: unlike a password Wilma refuses, it never stops the client for good.
53
54
  - `client.logout()` ends the session, as Wilma's own Log out does, so the parent can use Wilma in the browser. It never logs in: a session Wilma has already ended needs nothing, and it returns `false`. Afterwards the client and its `forStudent()` clients don't log in again by themselves. If Wilma can't be reached it throws and keeps the session, so it can be tried again.
54
55
 
55
56
  ### Any JavaScript runtime
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type SharedSession, type SessionEvent } from "./session.js";
1
+ import { type PasswordSource, type SharedSession, type SessionEvent } from "./session.js";
2
2
  import type { Exam, GradebookEntry, Invitation, InvitationListing, MessageRecipient, StudentDetails, LessonNote, LessonNoteSummary, Message, MessageFolder, NewsItem, NewsResource, OverviewData, PastExam, Printout, StudentInfo, UpcomingExam, WilmaProfile } from "./types.js";
3
3
  import type { WilmaTransport } from "./transport.js";
4
4
  export type MfaCallback = (formkey: string) => Promise<string>;
@@ -19,6 +19,10 @@ export interface ClientOptions {
19
19
  */
20
20
  sharedSession?: SharedSession;
21
21
  }
22
+ /** A profile for WilmaClient.resume(): the password may be a function called only for a new login. */
23
+ export type ResumeProfile = Omit<WilmaProfile, "password"> & {
24
+ password: PasswordSource;
25
+ };
22
26
  export declare class WilmaClient {
23
27
  private session;
24
28
  private constructor();
@@ -27,8 +31,13 @@ export declare class WilmaClient {
27
31
  * A client on a session saved earlier with exportSession(), without logging
28
32
  * in. If Wilma has ended that session, the first request logs in again by
29
33
  * itself. Returns null when the saved state has no session.
34
+ *
35
+ * The password may be a function (e.g. reading a keychain): it is called
36
+ * only when Wilma needs a new login, never while the saved session works.
37
+ * If it fails, the request fails with CredentialsUnavailableError and the
38
+ * next request tries again.
30
39
  */
31
- static resume(profile: WilmaProfile, state: string, onMfaRequired?: MfaCallback, options?: ClientOptions): WilmaClient | null;
40
+ static resume(profile: ResumeProfile, state: string, onMfaRequired?: MfaCallback, options?: ClientOptions): WilmaClient | null;
32
41
  /**
33
42
  * A client on a saved session without any credentials: it never logs in.
34
43
  * When Wilma ends the session, requests fail with SessionEndedError. For
package/dist/client.js CHANGED
@@ -47,6 +47,11 @@ export class WilmaClient {
47
47
  * A client on a session saved earlier with exportSession(), without logging
48
48
  * in. If Wilma has ended that session, the first request logs in again by
49
49
  * itself. Returns null when the saved state has no session.
50
+ *
51
+ * The password may be a function (e.g. reading a keychain): it is called
52
+ * only when Wilma needs a new login, never while the saved session works.
53
+ * If it fails, the request fails with CredentialsUnavailableError and the
54
+ * next request tries again.
50
55
  */
51
56
  static resume(profile, state, onMfaRequired, options = {}) {
52
57
  const session = new WilmaSession(profile.baseUrl, {
package/dist/core.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export type { Exam, ExamGrade, GradebookEntry, HomeworkItem, Invitation, InvitationListing, InvitationSlot, InvitationSlotState, MessageRecipient, StudentDetails, LessonNote, LessonNoteSummary, Message, MessageReply, MessageFolder, Municipality, NewsItem, NewsResource, NewsResourceAuthContext, OverviewData, PastExam, Printout, ScheduleLesson, TenantDiscoveryResponse, TenantInfo, UpcomingExam, WilmaProfile, StudentInfo, } from "./types.js";
2
2
  export { WilmaClient } from "./client.js";
3
- export type { MfaCallback, ClientOptions } from "./client.js";
4
- export { WilmaSession, AuthenticationError, MfaRequiredError, APIError, SessionEndedError } from "./session.js";
5
- export type { SessionOptions, RequestInit, SharedSession, SessionEvent } from "./session.js";
3
+ export type { MfaCallback, ClientOptions, ResumeProfile } from "./client.js";
4
+ export { WilmaSession, AuthenticationError, MfaRequiredError, APIError, SessionEndedError, CredentialsUnavailableError } from "./session.js";
5
+ export type { SessionOptions, RequestInit, SharedSession, SessionEvent, PasswordSource } from "./session.js";
6
6
  export { setDefaultTransport } from "./transport.js";
7
7
  export type { WilmaTransport, WilmaRequest, WilmaReply } from "./transport.js";
8
8
  export { DOWNLOAD_TIMEOUT_MS, isHtmlResponse, setExternalFetcher } from "./downloads.js";
package/dist/core.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { WilmaClient } from "./client.js";
2
- export { WilmaSession, AuthenticationError, MfaRequiredError, APIError, SessionEndedError } from "./session.js";
2
+ export { WilmaSession, AuthenticationError, MfaRequiredError, APIError, SessionEndedError, CredentialsUnavailableError } from "./session.js";
3
3
  export { setDefaultTransport } from "./transport.js";
4
4
  export { DOWNLOAD_TIMEOUT_MS, isHtmlResponse, setExternalFetcher } from "./downloads.js";
5
5
  export { NetworkError, describeNetworkCode, extractCauseCode, wrapNetworkError } from "./network-error.js";
package/dist/session.d.ts CHANGED
@@ -56,6 +56,24 @@ export declare class APIError extends Error {
56
56
  export declare class SessionEndedError extends APIError {
57
57
  constructor(message?: string);
58
58
  }
59
+ /**
60
+ * The password, or where to get it: a function called only when Wilma needs a
61
+ * new login (e.g. reading a keychain), not while a saved session works.
62
+ */
63
+ export type PasswordSource = string | (() => Promise<string>);
64
+ /**
65
+ * Wilma needed a new login and the password couldn't be had (the function
66
+ * given as the password failed: a keychain locked, declined or missing the
67
+ * entry). Not a refusal by Wilma, so it never stops the client for good;
68
+ * the next request tries again. `reason` and `cause` come from the failure.
69
+ */
70
+ export declare class CredentialsUnavailableError extends Error {
71
+ reason: string;
72
+ constructor(message: string, options?: {
73
+ reason?: string;
74
+ cause?: unknown;
75
+ });
76
+ }
59
77
  /**
60
78
  * What happened to a client's session, for a host that shows it (e.g. a
61
79
  * sign-in history): "login" (this client logged in), "adopted" (Wilma had
@@ -81,7 +99,7 @@ export declare class WilmaSession {
81
99
  * fails with SessionEndedError. Returns false when the state holds no
82
100
  * session to resume.
83
101
  */
84
- resumeState(state: string, username?: string, password?: string): boolean;
102
+ resumeState(state: string, username?: string, password?: PasswordSource): boolean;
85
103
  /** Called after every successful login (first or again). */
86
104
  /** Share this login's session with the other WilmAI programs (see SharedSession). */
87
105
  setShared(shared: SharedSession | undefined): void;
@@ -138,6 +156,13 @@ export declare class WilmaSession {
138
156
  * waits for a fresh code.
139
157
  */
140
158
  answerMfa(formkey: string, onMfa: (formkey: string) => Promise<string>): Promise<void>;
159
+ /**
160
+ * The password for a new login. A function given as the password is called
161
+ * here, only now; its answer replaces it (as a login does). If it fails, the
162
+ * session is "ended by Wilma" (never "logged out", never failed for good)
163
+ * and the error is a CredentialsUnavailableError.
164
+ */
165
+ private resolvePassword;
141
166
  /**
142
167
  * A live session after Wilma ended this one, once for everyone sharing this
143
168
  * login even if many requests notice at the same time: the newest session
package/dist/session.js CHANGED
@@ -35,6 +35,20 @@ export class SessionEndedError extends APIError {
35
35
  this.name = "SessionEndedError";
36
36
  }
37
37
  }
38
+ /**
39
+ * Wilma needed a new login and the password couldn't be had (the function
40
+ * given as the password failed: a keychain locked, declined or missing the
41
+ * entry). Not a refusal by Wilma, so it never stops the client for good;
42
+ * the next request tries again. `reason` and `cause` come from the failure.
43
+ */
44
+ export class CredentialsUnavailableError extends Error {
45
+ reason;
46
+ constructor(message, options = {}) {
47
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
48
+ this.name = "CredentialsUnavailableError";
49
+ this.reason = options.reason ?? "unavailable";
50
+ }
51
+ }
38
52
  /** How long Wilma may go silent (before answering, or mid-body) before a request fails. */
39
53
  const REQUEST_TIMEOUT_MS = 30_000;
40
54
  const MAX_REDIRECTS = 10;
@@ -268,6 +282,9 @@ export class WilmaSession {
268
282
  const ended = holdsSession(auth.cookieJar, this.baseUrl) ? await this.endSession() : false;
269
283
  auth.cookieJar.removeAllCookiesSync();
270
284
  auth.loggedIn = false;
285
+ // Logged out on purpose: not "ended by Wilma", so the next request neither
286
+ // logs in again nor continues a session another program saved.
287
+ auth.endedByWilma = false;
271
288
  auth.username = undefined;
272
289
  auth.password = undefined;
273
290
  return ended;
@@ -444,6 +461,36 @@ export class WilmaSession {
444
461
  }
445
462
  }
446
463
  }
464
+ /**
465
+ * The password for a new login. A function given as the password is called
466
+ * here, only now; its answer replaces it (as a login does). If it fails, the
467
+ * session is "ended by Wilma" (never "logged out", never failed for good)
468
+ * and the error is a CredentialsUnavailableError.
469
+ */
470
+ async resolvePassword() {
471
+ const auth = this.auth;
472
+ const source = auth.password;
473
+ if (typeof source !== "function")
474
+ return source ?? "";
475
+ try {
476
+ const password = await source();
477
+ if (typeof password !== "string" || !password)
478
+ throw new Error("No password was returned");
479
+ // logout() may have cleared it meanwhile: then it stays cleared.
480
+ if (auth.password === source)
481
+ auth.password = password;
482
+ return password;
483
+ }
484
+ catch (err) {
485
+ auth.loggedIn = false;
486
+ auth.endedByWilma = true;
487
+ auth.onEvent?.("ended");
488
+ if (err instanceof CredentialsUnavailableError)
489
+ throw err;
490
+ const reason = typeof err?.reason === "string" ? err.reason : undefined;
491
+ throw new CredentialsUnavailableError(err instanceof Error ? err.message : String(err), { reason, cause: err });
492
+ }
493
+ }
447
494
  /**
448
495
  * A live session after Wilma ended this one, once for everyone sharing this
449
496
  * login even if many requests notice at the same time: the newest session
@@ -462,6 +509,9 @@ export class WilmaSession {
462
509
  auth.onEvent?.("ended");
463
510
  throw new SessionEndedError();
464
511
  }
512
+ // Get the password before changing anything: if it can't be had, the
513
+ // session stays "ended by Wilma" and the next request tries again.
514
+ const password = await this.resolvePassword();
465
515
  auth.loggedIn = false;
466
516
  // Start from a clean jar: a leftover session cookie must not make a
467
517
  // failed login look successful.
@@ -469,7 +519,7 @@ export class WilmaSession {
469
519
  if (this.debug)
470
520
  console.error("[wilmai] session cancelled or expired; logging in again");
471
521
  try {
472
- await this.login(auth.username, auth.password);
522
+ await this.login(auth.username, password);
473
523
  }
474
524
  catch (err) {
475
525
  if (err instanceof MfaRequiredError && auth.onMfa) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wilm-ai/wilma-client",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Unofficial client for Wilma, the Finnish school information system: login (with two-step verification), schedule, homework, exams, messages and news.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,7 +39,7 @@
39
39
  "scripts": {
40
40
  "build": "tsc -p tsconfig.json",
41
41
  "lint": "tsc -p tsconfig.json --noEmit",
42
- "test": "pnpm run build && node test/login.mjs && node test/logout.mjs && node test/core.mjs && node test/write.mjs && node test/parser-attendance.mjs && node test/parser-messages.mjs && node test/parser-news.mjs && node test/parser-schedule.mjs && node test/parser-students.mjs && node test/client-students.mjs && node test/client-schedule.mjs && node test/client-attendance.mjs && node test/client-news-resources.mjs && node test/network-error.mjs && node test/audit.mjs && node test/real-pages.mjs && node test/client-features.mjs",
42
+ "test": "pnpm run build && node test/login.mjs && node test/logout.mjs && node test/password-source.mjs && node test/core.mjs && node test/write.mjs && node test/parser-attendance.mjs && node test/parser-messages.mjs && node test/parser-news.mjs && node test/parser-schedule.mjs && node test/parser-students.mjs && node test/client-students.mjs && node test/client-schedule.mjs && node test/client-attendance.mjs && node test/client-news-resources.mjs && node test/network-error.mjs && node test/audit.mjs && node test/real-pages.mjs && node test/client-features.mjs",
43
43
  "test:live": "tsc -p tsconfig.json && node test/live.spec.mjs",
44
44
  "fixtures:anonymize": "node scripts/anonymize-fixture.mjs"
45
45
  },