@wilm-ai/wilma-client 1.5.2 → 1.6.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/dist/session.js CHANGED
@@ -1,12 +1,17 @@
1
1
  import { CookieJar } from "tough-cookie";
2
2
  import { fetch } from "undici";
3
- import { wrapNetworkError } from "./network-error.js";
3
+ import { IdleTimer, asNetworkError, watchBody } from "./timeouts.js";
4
4
  export class AuthenticationError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "AuthenticationError";
8
+ }
5
9
  }
6
10
  export class MfaRequiredError extends Error {
7
11
  formkey;
8
12
  constructor(formkey) {
9
13
  super("MFA verification required");
14
+ this.name = "MfaRequiredError";
10
15
  this.formkey = formkey;
11
16
  }
12
17
  }
@@ -14,26 +19,72 @@ export class APIError extends Error {
14
19
  status;
15
20
  constructor(message, status) {
16
21
  super(message);
22
+ this.name = "APIError";
17
23
  this.status = status;
18
24
  }
19
25
  }
26
+ /** How long Wilma may go silent (before answering, or mid-body) before a request fails. */
27
+ const REQUEST_TIMEOUT_MS = 30_000;
28
+ const MAX_REDIRECTS = 10;
20
29
  const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
21
30
  "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0 Safari/537.36";
22
31
  const LOGIN_FAIL_RE = /loginFailed/i;
23
32
  export class WilmaSession {
24
33
  baseUrl;
25
- cookieJar;
26
- loggedIn = false;
27
- username;
28
- password;
34
+ auth;
29
35
  studentNumber;
30
36
  debug = false;
31
37
  constructor(baseUrl, opts) {
32
38
  this.baseUrl = baseUrl.replace(/\/$/, "");
33
- this.cookieJar = new CookieJar();
39
+ this.auth = { cookieJar: new CookieJar(), loggedIn: false, relogin: null, generation: 0, failed: null };
34
40
  this.studentNumber = opts?.studentNumber ?? null;
35
41
  this.debug = Boolean(opts?.debug);
36
42
  }
43
+ /** The login's cookies, so another process can resume the session (see resumeState). */
44
+ exportState() {
45
+ return JSON.stringify(this.auth.cookieJar.serializeSync());
46
+ }
47
+ /**
48
+ * Continue a session saved by exportState() instead of logging in. If Wilma
49
+ * has ended it meanwhile, the first request notices (401/403 or the login
50
+ * page) and logs in again with these credentials. Returns false when the
51
+ * state holds no session to resume.
52
+ */
53
+ resumeState(state, username, password) {
54
+ let jar;
55
+ try {
56
+ jar = CookieJar.deserializeSync(JSON.parse(state));
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ if (!jar.getCookiesSync(this.baseUrl).some((cookie) => cookie.key === "Wilma2SID"))
62
+ return false;
63
+ this.auth.cookieJar = jar;
64
+ this.auth.loggedIn = true;
65
+ this.auth.generation += 1;
66
+ this.auth.username = username;
67
+ this.auth.password = password;
68
+ return true;
69
+ }
70
+ /** Called after every successful login (first or again). */
71
+ onLogin(callback) {
72
+ this.auth.onLogin = callback;
73
+ }
74
+ /** Used to answer the two-step verification challenge when the session has to log in again. */
75
+ setMfaCallback(onMfa) {
76
+ this.auth.onMfa = onMfa;
77
+ }
78
+ /**
79
+ * A session for another student on the same account. It shares this
80
+ * session's login (cookies and credentials), so no new login is needed —
81
+ * Wilma picks the student from the "/!<number>/" path prefix.
82
+ */
83
+ forStudent(studentNumber) {
84
+ const session = new WilmaSession(this.baseUrl, { studentNumber, debug: this.debug });
85
+ session.auth = this.auth;
86
+ return session;
87
+ }
37
88
  get urlPrefix() {
38
89
  return this.studentNumber ? `!${this.studentNumber}` : null;
39
90
  }
@@ -47,7 +98,7 @@ export class WilmaSession {
47
98
  return path;
48
99
  }
49
100
  async login(username, password) {
50
- if (this.loggedIn) {
101
+ if (this.auth.loggedIn) {
51
102
  return;
52
103
  }
53
104
  const loginFields = await this.getLoginFormFields();
@@ -69,35 +120,53 @@ export class WilmaSession {
69
120
  },
70
121
  redirect: "manual",
71
122
  });
72
- const hasSessionCookie = this.cookieJar
123
+ const hasSessionCookie = this.auth.cookieJar
73
124
  .getCookiesSync(this.baseUrl)
74
125
  .some((c) => c.key === "Wilma2SID");
75
126
  const text = await resp.text();
127
+ const location = resp.headers.get("location") ?? "";
128
+ // Wilma itself failing is temporary — not a verdict on the username and password.
129
+ if (resp.status >= 500) {
130
+ throw new APIError(`Wilma answered HTTP ${resp.status} while logging in`, resp.status);
131
+ }
132
+ // Wilma answers a wrong username or password with a redirect to
133
+ // "?loginfailed" — empty body, no session cookie (older versions: a page
134
+ // saying so).
135
+ if (LOGIN_FAIL_RE.test(location) || (resp.status < 300 && !isLoginOk(text))) {
136
+ throw new AuthenticationError("Wilma login failed");
137
+ }
76
138
  // Check for MFA challenge by following the post-login redirect
77
- if (resp.status >= 300 && resp.status < 400 && hasSessionCookie) {
78
- const location = resp.headers.get("location");
79
- if (location) {
80
- const redirectResp = await this.rawRequest(new URL(location).pathname + new URL(location).search, { method: "GET" });
139
+ if (resp.status >= 300 && resp.status < 400 && hasSessionCookie && location) {
140
+ const next = new URL(location, `${this.baseUrl}/`);
141
+ if (next.origin === new URL(this.baseUrl).origin) {
142
+ const redirectResp = await this.rawRequest(next.pathname + next.search, { method: "GET" });
81
143
  const redirectText = await redirectResp.text();
82
144
  const mfaFormkeyMatch = /id="mfa-formkey"\s+value="([^"]+)"/.exec(redirectText);
83
145
  if (mfaFormkeyMatch) {
84
146
  if (this.debug) {
85
- console.log(`[wilmai] MFA challenge detected`);
147
+ console.error(`[wilmai] MFA challenge detected`);
86
148
  }
87
149
  // Store credentials so we can complete login after MFA
88
- this.username = username;
89
- this.password = password;
150
+ this.auth.username = username;
151
+ this.auth.password = password;
90
152
  throw new MfaRequiredError(mfaFormkeyMatch[1]);
91
153
  }
92
154
  }
93
155
  }
94
- if (hasSessionCookie || isLoginOk(text)) {
95
- this.loggedIn = true;
96
- this.username = username;
97
- this.password = password;
156
+ // A redirect counts as a login only with a session cookie: its empty body
157
+ // would otherwise pass the text check, accepting any username and password.
158
+ if (resp.status < 400 && (hasSessionCookie || resp.status < 300)) {
159
+ this.auth.loggedIn = true;
160
+ this.auth.generation += 1;
161
+ this.auth.failed = null;
162
+ this.auth.username = username;
163
+ this.auth.password = password;
164
+ this.auth.onLogin?.();
98
165
  return;
99
166
  }
100
- throw new AuthenticationError("Wilma login failed");
167
+ // Not Wilma's "login failed" answer (a rate limit, a firewall, a
168
+ // maintenance page): report it, but don't treat the password as wrong.
169
+ throw new APIError(`Wilma didn't accept the login right now (HTTP ${resp.status})`, resp.status);
101
170
  }
102
171
  async submitMfaCode(formkey, otpCode) {
103
172
  // Wilma's MFA endpoint: POST /api/v1/accounts/me/mfa/otp/check
@@ -109,7 +178,7 @@ export class WilmaSession {
109
178
  payload: JSON.stringify({ otp: otpCode, action: "login" }),
110
179
  });
111
180
  if (this.debug) {
112
- console.log(`[wilmai] POST ${path} (MFA OTP check)`);
181
+ console.error(`[wilmai] POST ${path} (MFA OTP check)`);
113
182
  }
114
183
  const resp = await this.rawRequest(path, {
115
184
  method: "POST",
@@ -118,6 +187,11 @@ export class WilmaSession {
118
187
  "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
119
188
  },
120
189
  });
190
+ // Wilma itself failing says nothing about the code.
191
+ if (resp.status >= 500) {
192
+ await resp.body?.cancel();
193
+ throw new APIError(`Wilma answered HTTP ${resp.status} while checking the code`, resp.status);
194
+ }
121
195
  const text = await resp.text();
122
196
  let success = false;
123
197
  try {
@@ -131,40 +205,192 @@ export class WilmaSession {
131
205
  if (!success) {
132
206
  throw new AuthenticationError("MFA verification failed: invalid OTP code");
133
207
  }
134
- this.loggedIn = true;
208
+ this.auth.loggedIn = true;
209
+ this.auth.generation += 1;
210
+ this.auth.failed = null;
211
+ this.auth.onLogin?.();
135
212
  if (this.debug) {
136
- console.log(`[wilmai] MFA verification successful`);
213
+ console.error(`[wilmai] MFA verification successful`);
137
214
  }
138
215
  }
139
216
  async getLoginFormFields() {
140
217
  const resp = await this.rawRequest("/login", { method: "GET" });
218
+ if (resp.status >= 500) {
219
+ await resp.body?.cancel();
220
+ throw new APIError(`Wilma answered HTTP ${resp.status} for its login page`, resp.status);
221
+ }
222
+ if (resp.status >= 300 && resp.status < 400) {
223
+ // rawRequest follows redirects within Wilma, so this one leads elsewhere.
224
+ await resp.body?.cancel();
225
+ const target = safeOrigin(resp.headers.get("location"), this.baseUrl);
226
+ throw new APIError(`${new URL(this.baseUrl).origin} sends its login page to ${target ?? "another site"}; ` +
227
+ "log in with that Wilma address instead", resp.status);
228
+ }
141
229
  if (resp.status >= 400) {
230
+ await resp.body?.cancel();
142
231
  return {};
143
232
  }
144
233
  const html = await resp.text();
145
234
  return parseLoginFormFields(html);
146
235
  }
147
- async request(path, init) {
148
- if (!this.loggedIn) {
149
- throw new AuthenticationError("WilmaSession not logged in – call login() first");
236
+ /**
237
+ * An authenticated request to this Wilma. Follows redirects within Wilma
238
+ * only; a redirect to another site is an error unless `externalRedirect`
239
+ * is "return" (then the 3xx response is handed back, e.g. so a download can
240
+ * continue without Wilma credentials).
241
+ */
242
+ async request(path, init, opts = {}) {
243
+ const auth = this.auth;
244
+ if (auth.failed)
245
+ throw auth.failed;
246
+ // A re-login in progress: wait for it (and share its outcome).
247
+ if (auth.relogin)
248
+ await auth.relogin;
249
+ if (!auth.loggedIn) {
250
+ // An earlier re-login failed (Wilma down, network blip): try again now.
251
+ if (auth.username && auth.password)
252
+ await this.relogin();
253
+ else
254
+ throw new AuthenticationError("WilmaSession not logged in – call login() first");
150
255
  }
151
256
  const prefixedPath = this.getPrefixedPath(path);
152
- let resp = await this.rawRequest(prefixedPath, init);
153
- if (resp.status === 401 && this.username && this.password) {
154
- this.loggedIn = false;
155
- await this.login(this.username, this.password);
156
- resp = await this.rawRequest(prefixedPath, init);
257
+ const generation = auth.generation;
258
+ // Some pages (messages) answer a cancelled session with a redirect to the
259
+ // login page instead of 401/403: stop there rather than read the login
260
+ // page as an empty answer.
261
+ const authed = { ...init, stopAtLogin: true };
262
+ let resp = await this.rawRequest(prefixedPath, authed, opts.timeoutMs);
263
+ // 401 or a redirect to the login page: the session expired. 403: either
264
+ // another login on the same account cancelled this session, or just this
265
+ // one item is off-limits.
266
+ const loggedOut = (r) => r.status === 401 || this.isLoginRedirect(r);
267
+ if ((loggedOut(resp) || resp.status === 403) && auth.username && auth.password) {
268
+ const someoneElseLoggedIn = () => auth.generation !== generation || auth.relogin !== null;
269
+ let retry = someoneElseLoggedIn();
270
+ if (!retry && (loggedOut(resp) || !(await this.sessionAlive()))) {
271
+ // Check again: another request may have logged in while we probed.
272
+ if (!someoneElseLoggedIn())
273
+ await this.relogin();
274
+ retry = true;
275
+ }
276
+ if (retry) {
277
+ if (auth.relogin)
278
+ await auth.relogin;
279
+ await resp.body?.cancel();
280
+ resp = await this.rawRequest(prefixedPath, authed, opts.timeoutMs);
281
+ }
157
282
  }
158
283
  if (this.debug) {
159
- console.log(`[wilmai] ${init?.method ?? "GET"} ${prefixedPath} => ${resp.status}`);
284
+ console.error(`[wilmai] ${init?.method ?? "GET"} ${redactPath(prefixedPath)} => ${resp.status}`);
285
+ }
286
+ if (this.isLoginRedirect(resp)) {
287
+ await resp.body?.cancel();
288
+ throw new APIError(`Wilma asked to log in again at ${redactPath(prefixedPath)}`, 401);
289
+ }
290
+ if (resp.status >= 300 && resp.status < 400) {
291
+ // rawRequest follows redirects within Wilma, so this one leads elsewhere.
292
+ if (opts.externalRedirect === "return")
293
+ return resp;
294
+ await resp.body?.cancel();
295
+ throw new APIError(`Wilma redirected to another site at ${redactPath(prefixedPath)}`, resp.status);
160
296
  }
161
297
  if (resp.status >= 400) {
162
- throw new APIError(`Wilma HTTP ${resp.status} at ${prefixedPath}`, resp.status);
298
+ await resp.body?.cancel();
299
+ throw new APIError(`Wilma HTTP ${resp.status} at ${redactPath(prefixedPath)}`, resp.status);
163
300
  }
164
301
  return resp;
165
302
  }
166
- async get(path, init) {
167
- return this.request(path, { ...init, method: "GET" });
303
+ /** A redirect to this Wilma's login page: the session is gone. */
304
+ isLoginRedirect(resp) {
305
+ if (resp.status < 300 || resp.status >= 400)
306
+ return false;
307
+ const location = resp.headers.get("location");
308
+ if (!location)
309
+ return false;
310
+ try {
311
+ const target = new URL(location, `${this.baseUrl}/`);
312
+ return target.origin === new URL(this.baseUrl).origin && /^\/login\/?$/i.test(target.pathname);
313
+ }
314
+ catch {
315
+ return false;
316
+ }
317
+ }
318
+ /**
319
+ * Is this login still live? A cancelled session gets 401/403 everywhere;
320
+ * a live one answers the account roles API (or 404 on Wilmas without it).
321
+ * Lets a 403 on one item be told apart from a cancelled session without
322
+ * logging in again (which would log the parent out of their own browser).
323
+ */
324
+ async sessionAlive() {
325
+ try {
326
+ const probe = await this.rawRequest("/api/v1/accounts/me/roles", { method: "GET", redirect: "manual" });
327
+ await probe.body?.cancel();
328
+ return probe.status !== 401 && probe.status !== 403 && !(probe.status >= 300 && probe.status < 400);
329
+ }
330
+ catch {
331
+ return false;
332
+ }
333
+ }
334
+ /**
335
+ * Answer a two-step verification challenge. If Wilma rejects the code (most
336
+ * likely because it was already used — logins less than 30 seconds apart
337
+ * share one), ask once more for the same challenge; the callback then
338
+ * waits for a fresh code.
339
+ */
340
+ async answerMfa(formkey, onMfa) {
341
+ try {
342
+ await this.submitMfaCode(formkey, await onMfa(formkey));
343
+ }
344
+ catch (err) {
345
+ if (err instanceof AuthenticationError && /invalid OTP/i.test(err.message)) {
346
+ await this.submitMfaCode(formkey, await onMfa(formkey));
347
+ }
348
+ else {
349
+ throw err;
350
+ }
351
+ }
352
+ }
353
+ /** Log in again, once for everyone sharing this login even if many requests notice at the same time. */
354
+ async relogin() {
355
+ if (!this.auth.relogin) {
356
+ const auth = this.auth;
357
+ const attempt = (async () => {
358
+ auth.loggedIn = false;
359
+ // Start from a clean jar: a leftover session cookie must not make a
360
+ // failed login look successful.
361
+ auth.cookieJar.removeAllCookiesSync();
362
+ if (this.debug)
363
+ console.error("[wilmai] session cancelled or expired; logging in again");
364
+ try {
365
+ await this.login(auth.username, auth.password);
366
+ }
367
+ catch (err) {
368
+ if (err instanceof MfaRequiredError && auth.onMfa) {
369
+ await this.answerMfa(err.formkey, auth.onMfa);
370
+ }
371
+ else {
372
+ throw err;
373
+ }
374
+ }
375
+ })();
376
+ // No overall deadline: every request in the login has its own idle
377
+ // timeout, and an interactive code prompt may rightly take a while. (A
378
+ // deadline would also let a second re-login start under this one.)
379
+ auth.relogin = attempt
380
+ .catch((err) => {
381
+ // The saved login itself was refused: stop here instead of retrying on every request.
382
+ if (isPermanentLoginFailure(err))
383
+ auth.failed = err;
384
+ throw err;
385
+ })
386
+ .finally(() => {
387
+ auth.relogin = null;
388
+ });
389
+ }
390
+ await this.auth.relogin;
391
+ }
392
+ async get(path, init, opts) {
393
+ return this.request(path, { ...init, method: "GET" }, opts);
168
394
  }
169
395
  async post(path, init) {
170
396
  return this.request(path, { ...init, method: "POST" });
@@ -172,7 +398,9 @@ export class WilmaSession {
172
398
  async getLoginToken() {
173
399
  const resp = await this.rawRequest("/token", { method: "GET" });
174
400
  if (resp.status !== 200) {
175
- throw new AuthenticationError("/token fetch failed");
401
+ // An outage or a changed login page, not a wrong password.
402
+ await resp.body?.cancel();
403
+ throw new APIError(`Wilma answered HTTP ${resp.status} for its login token`, resp.status);
176
404
  }
177
405
  const text = await resp.text();
178
406
  try {
@@ -186,72 +414,124 @@ export class WilmaSession {
186
414
  }
187
415
  const match = /"Wilma2LoginID"\s*:\s*"([^"\s]+)"/.exec(text);
188
416
  if (!match) {
189
- throw new AuthenticationError("Wilma2LoginID not found in /token response");
417
+ throw new APIError("Wilma's login token wasn't in the expected format", resp.status);
190
418
  }
191
419
  return match[1];
192
420
  }
193
- async rawRequest(path, init) {
194
- const url = new URL(path, this.baseUrl).toString();
195
- const cookieHeader = this.cookieJar.getCookieStringSync(url);
196
- const headers = new Headers();
421
+ /**
422
+ * One request to this Wilma, following redirects within Wilma hop by hop
423
+ * (cookies are stored per hop, and only for Wilma). A redirect to another
424
+ * origin is returned as-is: Wilma's cookies never travel there.
425
+ */
426
+ async rawRequest(path, init, timeoutMs = REQUEST_TIMEOUT_MS) {
427
+ const origin = new URL(this.baseUrl).origin;
428
+ // Paths only: "//host/x" or "https://other/x" would leave Wilma.
429
+ if (path.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(path)) {
430
+ throw new APIError("Refusing a request outside Wilma", 400);
431
+ }
432
+ let url = new URL(path, `${this.baseUrl}/`);
433
+ if (url.origin !== origin) {
434
+ throw new APIError("Refusing a request outside Wilma", 400);
435
+ }
436
+ const baseHeaders = new Headers();
197
437
  if (init?.headers) {
198
438
  const incoming = init.headers;
199
439
  if (incoming instanceof Headers) {
200
- incoming.forEach((value, key) => headers.set(key, value));
440
+ incoming.forEach((value, key) => baseHeaders.set(key, value));
201
441
  }
202
442
  else if (Array.isArray(incoming)) {
203
443
  for (const entry of incoming) {
204
444
  if (entry.length >= 2) {
205
- headers.set(entry[0], entry[1]);
445
+ baseHeaders.set(entry[0], entry[1]);
206
446
  }
207
447
  }
208
448
  }
209
449
  else {
210
450
  for (const [key, value] of Object.entries(incoming)) {
211
451
  if (value !== undefined) {
212
- headers.set(key, String(value));
452
+ baseHeaders.set(key, String(value));
213
453
  }
214
454
  }
215
455
  }
216
456
  }
217
- headers.set("User-Agent", USER_AGENT);
218
- headers.set("Referer", `${this.baseUrl}/`);
219
- if (cookieHeader) {
220
- headers.set("Cookie", cookieHeader);
221
- }
222
- if (this.debug) {
223
- const method = init?.method ?? "GET";
224
- // Avoid logging sensitive headers or body
225
- console.log(`[wilmai] ${method} ${url}`);
226
- }
227
- let resp;
228
- try {
229
- resp = await fetch(url, {
230
- ...init,
231
- headers,
232
- });
233
- }
234
- catch (err) {
235
- // `fetch failed` on its own is undiagnosable; re-throw with the cause code
236
- // and remediation attached.
237
- throw wrapNetworkError(err, url);
238
- }
239
- const headersAny = resp.headers;
240
- const setCookies = headersAny.getSetCookie?.() ?? [];
241
- if (setCookies.length) {
242
- for (const cookie of setCookies) {
243
- this.cookieJar.setCookieSync(cookie, url);
457
+ baseHeaders.set("User-Agent", USER_AGENT);
458
+ baseHeaders.set("Referer", `${this.baseUrl}/`);
459
+ const { stopAtLogin, ...fetchInit } = init ?? {};
460
+ const follow = init?.redirect !== "manual";
461
+ const idle = new IdleTimer(timeoutMs);
462
+ const signal = init?.signal ? AbortSignal.any([init.signal, idle.signal]) : idle.signal;
463
+ let method = (init?.method ?? "GET").toUpperCase();
464
+ let body = init?.body;
465
+ for (let hop = 0;; hop += 1) {
466
+ const headers = new Headers(baseHeaders);
467
+ const cookieHeader = this.auth.cookieJar.getCookieStringSync(url.href);
468
+ if (cookieHeader)
469
+ headers.set("Cookie", cookieHeader);
470
+ if (this.debug) {
471
+ // Method and path only: never headers, bodies or query strings.
472
+ console.error(`[wilmai] ${method} ${redactPath(url.pathname)}`);
244
473
  }
245
- }
246
- else {
247
- const single = resp.headers.get("set-cookie");
248
- if (single) {
249
- this.cookieJar.setCookieSync(single, url);
474
+ let resp;
475
+ try {
476
+ idle.touch();
477
+ resp = await fetch(url, { ...fetchInit, method, body, headers, signal, redirect: "manual" });
478
+ }
479
+ catch (err) {
480
+ idle.stop();
481
+ // Timeouts, and `fetch failed` with its cause code and remediation attached.
482
+ throw asNetworkError(err, origin);
483
+ }
484
+ const headersAny = resp.headers;
485
+ const setCookies = headersAny.getSetCookie?.() ?? [];
486
+ const single = setCookies.length ? [] : [resp.headers.get("set-cookie")].filter((c) => !!c);
487
+ for (const cookie of [...setCookies, ...single]) {
488
+ // A cookie Wilma isn't allowed to set (e.g. for another domain) is dropped, not fatal.
489
+ this.auth.cookieJar.setCookieSync(cookie, url.href, { ignoreError: true });
490
+ }
491
+ const location = resp.headers.get("location");
492
+ if (!follow || resp.status < 300 || resp.status >= 400 || !location) {
493
+ return watchBody(resp, idle, origin);
494
+ }
495
+ const next = new URL(location, url);
496
+ if (next.origin !== origin) {
497
+ return watchBody(resp, idle, origin); // hand the off-site redirect back to the caller
498
+ }
499
+ if (stopAtLogin && /^\/login\/?$/i.test(next.pathname)) {
500
+ return watchBody(resp, idle, origin); // logged out: the caller decides
250
501
  }
502
+ if (hop >= MAX_REDIRECTS) {
503
+ idle.stop();
504
+ await resp.body?.cancel();
505
+ throw new APIError("Wilma redirected too many times", resp.status);
506
+ }
507
+ await resp.body?.cancel();
508
+ // Like browsers: 303, and 301/302 after a POST, continue as GET without a body.
509
+ if (resp.status === 303 || ((resp.status === 301 || resp.status === 302) && method === "POST")) {
510
+ method = "GET";
511
+ body = undefined;
512
+ baseHeaders.delete("Content-Type");
513
+ }
514
+ url = next;
251
515
  }
252
- return resp;
253
516
  }
254
517
  }
518
+ /** The origin of a redirect target, for messages (never its path). */
519
+ function safeOrigin(location, base) {
520
+ try {
521
+ return location ? new URL(location, `${base}/`).origin : null;
522
+ }
523
+ catch {
524
+ return null;
525
+ }
526
+ }
527
+ /** A path for messages and logs: no student number, no query string. */
528
+ function redactPath(path) {
529
+ return path.replace(/^\/![^/]+/, "").split("?")[0] || "/";
530
+ }
531
+ function isPermanentLoginFailure(err) {
532
+ return (err instanceof MfaRequiredError ||
533
+ (err instanceof AuthenticationError && /^Wilma login failed/.test(err.message)));
534
+ }
255
535
  function isLoginOk(text) {
256
536
  return !LOGIN_FAIL_RE.test(text);
257
537
  }
@@ -0,0 +1,20 @@
1
+ import { Response } from "undici";
2
+ /** An abort signal that fires after `ms` without progress. */
3
+ export declare class IdleTimer {
4
+ private readonly ms;
5
+ private readonly controller;
6
+ private timer;
7
+ constructor(ms: number);
8
+ get signal(): AbortSignal;
9
+ /** Progress: start the wait over. */
10
+ touch(): void;
11
+ stop(): void;
12
+ }
13
+ /** A timeout or broken connection as a NetworkError; anything else unchanged. */
14
+ export declare function asNetworkError(err: unknown, origin: string): unknown;
15
+ /**
16
+ * The same response, with its body read under the idle timer: each chunk
17
+ * restarts the wait, and a stall or broken connection while reading surfaces
18
+ * as a NetworkError, like one before the response.
19
+ */
20
+ export declare function watchBody(response: Response, idle: IdleTimer, origin: string): Response;