@wilm-ai/wilma-cli 2.3.2 → 2.5.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.
@@ -62,7 +62,9 @@ export function newsLine(item) {
62
62
  export function messageLine(msg) {
63
63
  const sender = msg.senderName ? ` — ${compactText(msg.senderName)}` : "";
64
64
  const replies = msg.replyCount ? ` [${msg.replyCount} ${msg.replyCount === 1 ? "reply" : "replies"}]` : "";
65
- return `${finnishDay(msg.sentAt)} ${compactText(msg.subject)}${sender}${msg.unread ? " [new]" : ""}${replies} (id ${msg.wilmaId})`;
65
+ // An invitation's answer in Wilma's own word ("Applied 29.05.2026 13:00").
66
+ const answer = msg.invitationStatus ? ` [${compactText([msg.invitationStatus, msg.invitationInfo].filter(Boolean).join(" "))}]` : "";
67
+ return `${finnishDay(msg.sentAt)} ${compactText(msg.subject)}${sender}${msg.unread ? " [new]" : ""}${replies}${answer} (id ${msg.wilmaId})`;
66
68
  }
67
69
  function lessonNoteLine(note, withDate) {
68
70
  const date = withDate ? `${note.date} ` : "";
@@ -116,15 +118,27 @@ export function formatText(command, action, result) {
116
118
  case "summary":
117
119
  return perStudent(result, "summary", (s) => summaryLines(s));
118
120
  case "schedule": {
119
- const period = result.weekStart ? `${result.weekStart} – ${result.weekEnd}` : result.date;
121
+ const period = result.from ? `${result.from} – ${result.to}` : result.weekStart ? `${result.weekStart} – ${result.weekEnd}` : result.date;
120
122
  return titled(`Schedule ${period}`, result, perStudent(result, "lessons", scheduleLines));
121
123
  }
122
124
  case "homework":
123
125
  return perStudent(result, "homework", (items) => items.length ? items.map((h) => ` ${h.date} ${h.subject}: ${compactText(h.homework)}`) : [" No recent homework."]);
124
126
  case "exams":
125
127
  return perStudent(result, "exams", (items) => (items.length ? items.map((e) => ` ${examLine(e)}`) : [" No upcoming exams."]));
126
- case "grades":
127
- return perStudent(result, "grades", (items) => items.length ? items.map((g) => ` ${g.date} ${g.subject}: ${g.name} — ${g.grade}`) : [" No exam grades."]);
128
+ case "grades": {
129
+ const lines = (items) => items.length
130
+ ? items.map((g) => ` ${g.date} ${g.subject}: ${g.name} — ${g.grade ?? "no grade yet"}${g.verbalGrade ? ` (${compactText(g.verbalGrade)})` : ""}`)
131
+ : [result.from ? " No exams in this period." : " No exam grades."];
132
+ const grades = perStudent(result, "grades", lines);
133
+ return result.from ? titled(`Exams ${result.from} – ${result.to}`, result, grades) : grades;
134
+ }
135
+ case "contacts":
136
+ return perStudent(result, "contacts", (c) => [
137
+ ` Class: ${c.className ?? "not shown"}`,
138
+ ...c.supervisors.map((s) => ` ${compactText(s.role)}: ${s.name}`),
139
+ ...(c.teachers.length ? [" Teachers:", ...c.teachers.map((t) => ` ${t.name}${t.subjects.length ? ` — ${t.subjects.join(", ")}` : ""}`)] : []),
140
+ ...(c.unavailable?.length ? [` (Not available from Wilma right now: ${c.unavailable.join(", ")})`] : []),
141
+ ]);
128
142
  case "gradebook":
129
143
  return perStudent(result, "gradebook", (entries) => gradebookLines(entries));
130
144
  case "notes": {
@@ -178,7 +192,7 @@ export function formatText(command, action, result) {
178
192
  return perStudent(result, "printouts", (items) => items.length ? [...items.map((p) => ` ${compactText(p.title)} (id ${p.id})`)] : [" No printouts."]);
179
193
  case "students":
180
194
  return result.students
181
- .map((s) => `${s.studentNumber} ${s.name}${s.wilma ? ` (${s.wilma})` : ""}`)
195
+ .map((s) => `${s.studentNumber} ${s.name}${s.wilma ? ` (${s.wilma})` : ""}${s.role === "student" ? " [own login]" : ""}`)
182
196
  .join("\n");
183
197
  default:
184
198
  return JSON.stringify(result, null, 2);
package/dist/config.d.ts CHANGED
@@ -3,23 +3,53 @@ export interface StoredProfile {
3
3
  tenantUrl: string;
4
4
  tenantName?: string | null;
5
5
  username: string;
6
- passwordObfuscated: string;
6
+ /** Scrambled password: a login kept in this file (absent for a keychain login). */
7
+ passwordObfuscated?: string;
7
8
  totpSecretObfuscated?: string | null;
8
- students?: {
9
- studentNumber: string;
10
- name: string;
11
- }[];
9
+ /** "keychain": the password and authenticator key are in the OS keychain, entry `secretId`. Absent: in this file. */
10
+ secrets?: "keychain" | string;
11
+ /** The login's keychain entry (random; also kept in CliConfig.keychainLogins). */
12
+ secretId?: string;
13
+ /** Random, new whenever the login's secrets change; not secret. Part of a keychain login's pool key. */
14
+ credentialsStamp?: string;
15
+ /** Whether an authenticator key is saved (so listings don't read the keychain). */
16
+ hasTotp?: boolean;
17
+ /** When moving the login to the keychain last failed (background moves wait a day). */
18
+ moveFailedAt?: string;
19
+ /** The children the login lists; `role` says whose login it is for each (a guardian's, or the student's own). */
20
+ students?: SavedStudent[];
21
+ /** Student numbers of children unticked on the login page (a profile Wilma lists twice): not read. */
22
+ hidden?: string[];
12
23
  lastStudentNumber?: string | null;
13
24
  lastStudentName?: string | null;
14
25
  lastUsedAt: string;
15
26
  }
27
+ export interface SavedStudent {
28
+ studentNumber: string;
29
+ name: string;
30
+ role?: "guardian" | "student";
31
+ }
32
+ /** A child as saved with a login: the number, the name, and the role when Wilma says it. */
33
+ export declare function savedStudent(s: SavedStudent): SavedStudent;
16
34
  export interface CliConfig {
17
35
  profiles: StoredProfile[];
18
36
  lastProfileId?: string | null;
37
+ /** Where new secrets go: "file" (default), "auto" or "keychain" (`wilma config secrets`). */
38
+ secretStore?: string;
39
+ /** Every keychain entry this config made, by login id, written before the entry itself. */
40
+ keychainLogins?: Record<string, string>;
19
41
  }
20
42
  export declare function getConfigPath(): string;
21
43
  export declare function loadConfig(): Promise<CliConfig>;
22
44
  export declare function saveConfig(config: CliConfig): Promise<void>;
45
+ /**
46
+ * Change the saved config from a fresh read, so a save never overwrites what
47
+ * another process wrote meanwhile (moves, logins, refreshed child lists).
48
+ * `edit` returns false to leave the file as it is.
49
+ */
50
+ export declare function editConfig(edit: (config: CliConfig) => boolean | void | Promise<boolean | void>): Promise<CliConfig>;
51
+ /** The config as far as it can be read, or null (for `wilma config clear`, the way out of a damaged file). */
52
+ export declare function loadConfigLenient(): Promise<CliConfig | null>;
23
53
  export declare function clearConfig(): Promise<void>;
24
54
  export declare function obfuscateSecret(value: string): string;
25
- export declare function revealSecret(value: string): string | null;
55
+ export declare function revealSecret(value: string | null | undefined): string | null;
package/dist/config.js CHANGED
@@ -2,6 +2,10 @@ import { chmod, mkdir, readFile, rename, stat, writeFile, rm } from "node:fs/pro
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { homedir } from "node:os";
5
+ /** A child as saved with a login: the number, the name, and the role when Wilma says it. */
6
+ export function savedStudent(s) {
7
+ return { studentNumber: s.studentNumber, name: s.name, ...(s.role ? { role: s.role } : {}) };
8
+ }
5
9
  const SALT = "wilmai::";
6
10
  export function getConfigPath() {
7
11
  const override = process.env.WILMAI_CONFIG_PATH;
@@ -85,6 +89,26 @@ export async function saveConfig(config) {
85
89
  await writeFile(tmp, JSON.stringify(config, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
86
90
  await rename(tmp, path);
87
91
  }
92
+ /**
93
+ * Change the saved config from a fresh read, so a save never overwrites what
94
+ * another process wrote meanwhile (moves, logins, refreshed child lists).
95
+ * `edit` returns false to leave the file as it is.
96
+ */
97
+ export async function editConfig(edit) {
98
+ const config = await loadConfig();
99
+ if ((await edit(config)) !== false)
100
+ await saveConfig(config);
101
+ return config;
102
+ }
103
+ /** The config as far as it can be read, or null (for `wilma config clear`, the way out of a damaged file). */
104
+ export async function loadConfigLenient() {
105
+ try {
106
+ return await loadConfig();
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
88
112
  export async function clearConfig() {
89
113
  const path = getConfigPath();
90
114
  await rm(path, { force: true });
@@ -93,6 +117,8 @@ export function obfuscateSecret(value) {
93
117
  return Buffer.from(SALT + value, "utf-8").toString("base64");
94
118
  }
95
119
  export function revealSecret(value) {
120
+ if (!value)
121
+ return null;
96
122
  try {
97
123
  const decoded = Buffer.from(value, "base64").toString("utf-8");
98
124
  if (!decoded.startsWith(SALT)) {
@@ -1,5 +1,7 @@
1
1
  import { WilmaClient, type MfaCallback, type StudentInfo, type WilmaProfile } from "@wilm-ai/wilma-client";
2
2
  import { type CliConfig, type StoredProfile } from "./config.js";
3
+ import { type LoginSecrets } from "./secret-store.js";
4
+ import { type SessionKeys } from "./session-keys.js";
3
5
  /**
4
6
  * Environment variables for agents that keep secrets in their own store
5
7
  * (cloud VMs, CI-like sandboxes) instead of a config file.
@@ -11,8 +13,15 @@ export declare const ENV_VARS: {
11
13
  readonly totpSecret: "WILMA_TOTP_SECRET";
12
14
  };
13
15
  export interface ActiveAccount {
16
+ /** For a keychain login the password is empty here: `secrets` reads it, only when Wilma needs a new login. */
14
17
  profile: WilmaProfile;
15
18
  totpSecret?: string;
19
+ /** A keychain login's (or an unreadable login's) password and authenticator key, read only when needed. */
20
+ secrets?: () => Promise<LoginSecrets>;
21
+ /** Whether an authenticator key is saved (for `secrets` logins, without reading it). */
22
+ hasTotp?: boolean;
23
+ /** Where its session is saved and pooled (v1 for file and environment logins, v2 for keychain logins). */
24
+ keys: SessionKeys;
16
25
  /** Present when the account comes from the saved config. */
17
26
  stored?: StoredProfile;
18
27
  source: "config" | "env";
@@ -34,12 +43,18 @@ export declare function mfaCallbackFor(totpSecret?: string | null, clock?: {
34
43
  now: () => number;
35
44
  sleep: (ms: number) => Promise<void>;
36
45
  }): MfaCallback | undefined;
46
+ /** A saved login as an account: its password from the file, or (keychain logins) read only when needed. */
47
+ export declare function accountFor(stored: StoredProfile): ActiveAccount;
48
+ /** The password and authenticator key of an account now (reading the keychain for a keychain login). */
49
+ export declare function accountSecrets(account: ActiveAccount): Promise<LoginSecrets>;
37
50
  /** Environment variables win over the saved config when all three are set. */
38
51
  export declare function resolveAccount(config: CliConfig, env?: NodeJS.ProcessEnv): Promise<ActiveAccount | null>;
39
52
  /**
40
53
  * Every Wilma login the family has. Environment variables give exactly one;
41
54
  * otherwise all saved logins, the active one first. Families with children on
42
55
  * different Wilmas (e.g. a city school and a private school) save one login each.
56
+ * A login whose password can't be read is kept: it fails with a clear message
57
+ * when used, instead of looking like no login at all.
43
58
  */
44
59
  export declare function resolveAccounts(config: CliConfig, env?: NodeJS.ProcessEnv): Promise<ActiveAccount[]>;
45
60
  /**
@@ -55,7 +70,32 @@ export declare function verifyLoginSession(input: {
55
70
  client: WilmaClient;
56
71
  students: StudentInfo[];
57
72
  }>;
58
- /** Save a verified login as the active profile, replacing any profile with the same tenant and username. */
73
+ /** Where a login's secrets go, decided before Wilma is contacted (section 5 of docs/secret-store.md). */
74
+ export interface StoreChoice {
75
+ store: "file" | "keychain";
76
+ /** The keychain entry to use (a login's own, or one this config already reserved for it). */
77
+ secretId?: string;
78
+ /** Said to the user: e.g. saved in the file because no keychain can be used here. */
79
+ note?: string;
80
+ }
81
+ /**
82
+ * Decide where a login's secrets go and check the keychain can be used,
83
+ * before anything is sent to Wilma: a login that can't be saved then costs no
84
+ * Wilma login, no ended browser session and no used code. A login saved
85
+ * again keeps its store while that store can be used here; otherwise the
86
+ * setting decides (`auto` and `file`: the file; `keychain`: refused).
87
+ */
88
+ export declare function chooseSecretStore(config: CliConfig, tenantUrl: string, username: string): Promise<StoreChoice>;
89
+ /**
90
+ * Save a verified login as the active profile, replacing any profile with the
91
+ * same tenant and username, in the store chosen by chooseSecretStore. A
92
+ * keychain entry is recorded in `keychainLogins` before it is written (reusing
93
+ * one another process reserved meanwhile), and read back before the login
94
+ * points at it. Under `auto`, a keychain that fails here falls back to the
95
+ * file (the returned note says so). The keychain calls come first; the
96
+ * profile is then saved onto a fresh read of the config, so a move or a login
97
+ * another process saved meanwhile isn't undone. `config` is updated to match.
98
+ */
59
99
  export declare function saveLogin(config: CliConfig, input: {
60
100
  tenantUrl: string;
61
101
  tenantName?: string | null;
@@ -63,7 +103,14 @@ export declare function saveLogin(config: CliConfig, input: {
63
103
  password: string;
64
104
  totpSecret?: string | null;
65
105
  students: StudentInfo[];
66
- }): Promise<StoredProfile>;
106
+ }, choice?: StoreChoice): Promise<StoredProfile & {
107
+ note?: string;
108
+ }>;
109
+ /**
110
+ * Save an authenticator key for a saved login (the interactive "Save TOTP
111
+ * secret"): into its keychain entry for a keychain login, never the file.
112
+ */
113
+ export declare function saveTotpSecret(id: string, totpSecret: string): Promise<void>;
67
114
  /** The client reports a rejected one-time code as an AuthenticationError starting "MFA verification failed". */
68
115
  export declare function isMfaFailure(err: unknown): boolean;
69
116
  export { AuthenticationError } from "@wilm-ai/wilma-client";
@@ -1,5 +1,7 @@
1
1
  import { AuthenticationError, MfaRequiredError, WilmaClient, } from "@wilm-ai/wilma-client";
2
- import { obfuscateSecret, revealSecret, saveConfig, } from "./config.js";
2
+ import { editConfig, loadConfig, obfuscateSecret, revealSecret, savedStudent, } from "./config.js";
3
+ import { effectiveSecretStore, keychainProblem, newCredentialsStamp, newSecretId, readBackLoginSecrets, readLoginSecrets, SecretStoreError, secretErrorMessage, writeLoginSecrets, } from "./secret-store.js";
4
+ import { fileLoginKeys, keychainLoginKeys } from "./session-keys.js";
3
5
  import { generateTOTP, parseTotpSecret, TOTP_PERIOD_MS, totpCounter } from "./totp.js";
4
6
  import { normalizeTenantUrl, resolveTenant } from "./tenant-search.js";
5
7
  /**
@@ -67,43 +69,75 @@ export function mfaCallbackFor(totpSecret, clock = {
67
69
  function hasEnvAccount(env = process.env) {
68
70
  return Boolean(env[ENV_VARS.tenant] && env[ENV_VARS.username] && env[ENV_VARS.password]);
69
71
  }
72
+ /** A saved login as an account: its password from the file, or (keychain logins) read only when needed. */
73
+ export function accountFor(stored) {
74
+ const profile = {
75
+ baseUrl: stored.tenantUrl,
76
+ username: stored.username,
77
+ password: "",
78
+ studentNumber: stored.lastStudentNumber ?? undefined,
79
+ };
80
+ const wilma = stored.tenantName ?? stored.tenantUrl;
81
+ const base = { stored, source: "config" };
82
+ if (stored.secrets === "keychain" && stored.secretId) {
83
+ const ref = { secretId: stored.secretId, credentialsStamp: stored.credentialsStamp, wilma };
84
+ return {
85
+ ...base,
86
+ profile,
87
+ secrets: () => readLoginSecrets(ref),
88
+ hasTotp: Boolean(stored.hasTotp),
89
+ keys: keychainLoginKeys(stored.tenantUrl, stored.username, stored.credentialsStamp),
90
+ };
91
+ }
92
+ if (stored.secrets) {
93
+ // Kept somewhere this version doesn't know (a newer WilmAI saved it).
94
+ const error = new SecretStoreError("unavailable", secretErrorMessage("unavailable", wilma));
95
+ return { ...base, profile, secrets: () => Promise.reject(error), keys: keychainLoginKeys(stored.tenantUrl, stored.username, stored.credentialsStamp) };
96
+ }
97
+ const password = revealSecret(stored.passwordObfuscated);
98
+ if (!password) {
99
+ // Reported when used, not skipped: a skipped login looks like "not logged in" and invites a second login.
100
+ const error = new SecretStoreError("missing", `The saved password for ${wilma} can't be read. Log in again (\`wilma login\`).`);
101
+ return { ...base, profile, secrets: () => Promise.reject(error), keys: keychainLoginKeys(stored.tenantUrl, stored.username, null) };
102
+ }
103
+ const totpSecret = revealSecret(stored.totpSecretObfuscated) ?? undefined;
104
+ return {
105
+ ...base,
106
+ profile: { ...profile, password },
107
+ totpSecret,
108
+ hasTotp: Boolean(totpSecret),
109
+ keys: fileLoginKeys(stored.tenantUrl, stored.username, password),
110
+ };
111
+ }
112
+ /** The password and authenticator key of an account now (reading the keychain for a keychain login). */
113
+ export async function accountSecrets(account) {
114
+ if (account.secrets)
115
+ return account.secrets();
116
+ return { password: account.profile.password, totpSecret: account.totpSecret ?? null };
117
+ }
70
118
  /** Environment variables win over the saved config when all three are set. */
71
119
  export async function resolveAccount(config, env = process.env) {
72
120
  if (hasEnvAccount(env)) {
73
121
  const tenant = await resolveTenant(env[ENV_VARS.tenant]);
122
+ const username = env[ENV_VARS.username];
123
+ const password = env[ENV_VARS.password];
74
124
  return {
75
- profile: {
76
- baseUrl: tenant.url,
77
- username: env[ENV_VARS.username],
78
- password: env[ENV_VARS.password],
79
- },
125
+ profile: { baseUrl: tenant.url, username, password },
80
126
  totpSecret: env[ENV_VARS.totpSecret] || undefined,
127
+ hasTotp: Boolean(env[ENV_VARS.totpSecret]),
128
+ keys: fileLoginKeys(tenant.url, username, password),
81
129
  source: "env",
82
130
  };
83
131
  }
84
132
  const stored = config.profiles.find((p) => p.id === config.lastProfileId);
85
- if (!stored)
86
- return null;
87
- const password = revealSecret(stored.passwordObfuscated);
88
- if (!password) {
89
- throw new Error("Stored password could not be decoded. Run `wilma login` again.");
90
- }
91
- return {
92
- profile: {
93
- baseUrl: stored.tenantUrl,
94
- username: stored.username,
95
- password,
96
- studentNumber: stored.lastStudentNumber ?? undefined,
97
- },
98
- totpSecret: stored.totpSecretObfuscated ? revealSecret(stored.totpSecretObfuscated) ?? undefined : undefined,
99
- stored,
100
- source: "config",
101
- };
133
+ return stored ? accountFor(stored) : null;
102
134
  }
103
135
  /**
104
136
  * Every Wilma login the family has. Environment variables give exactly one;
105
137
  * otherwise all saved logins, the active one first. Families with children on
106
138
  * different Wilmas (e.g. a city school and a private school) save one login each.
139
+ * A login whose password can't be read is kept: it fails with a clear message
140
+ * when used, instead of looking like no login at all.
107
141
  */
108
142
  export async function resolveAccounts(config, env = process.env) {
109
143
  if (hasEnvAccount(env)) {
@@ -119,20 +153,7 @@ export async function resolveAccounts(config, env = process.env) {
119
153
  if (seen.has(key))
120
154
  continue;
121
155
  seen.add(key);
122
- const password = revealSecret(stored.passwordObfuscated);
123
- if (!password)
124
- continue;
125
- accounts.push({
126
- profile: {
127
- baseUrl: stored.tenantUrl,
128
- username: stored.username,
129
- password,
130
- studentNumber: stored.lastStudentNumber ?? undefined,
131
- },
132
- totpSecret: stored.totpSecretObfuscated ? revealSecret(stored.totpSecretObfuscated) ?? undefined : undefined,
133
- stored,
134
- source: "config",
135
- });
156
+ accounts.push(accountFor(stored));
136
157
  }
137
158
  return accounts;
138
159
  }
@@ -155,37 +176,151 @@ export async function verifyLoginSession(input) {
155
176
  throw err;
156
177
  }
157
178
  }
158
- /** Save a verified login as the active profile, replacing any profile with the same tenant and username. */
159
- export async function saveLogin(config, input) {
179
+ const sameAccountAs = (tenantUrl, username) => (p) => normalizeTenantUrl(p.tenantUrl) === normalizeTenantUrl(tenantUrl) && p.username.toLowerCase() === username.toLowerCase();
180
+ const FILE_FALLBACK_NOTE = "Saved in WilmAI's file on this computer: there is no keychain WilmAI can use here.";
181
+ /**
182
+ * Decide where a login's secrets go and check the keychain can be used,
183
+ * before anything is sent to Wilma: a login that can't be saved then costs no
184
+ * Wilma login, no ended browser session and no used code. A login saved
185
+ * again keeps its store while that store can be used here; otherwise the
186
+ * setting decides (`auto` and `file`: the file; `keychain`: refused).
187
+ */
188
+ export async function chooseSecretStore(config, tenantUrl, username) {
189
+ const setting = effectiveSecretStore(config.secretStore);
190
+ const previous = config.profiles.find(sameAccountAs(tenantUrl, username));
191
+ const id = previous?.id ?? `${normalizeTenantUrl(tenantUrl)}|${username}`;
192
+ const reserved = config.keychainLogins?.[id];
193
+ // A saved keychain login stays in the keychain; a saved file login moves only by the saved setting (the
194
+ // environment variable decides only where new logins go); a new login follows the setting in effect.
195
+ const wantsKeychain = previous?.secrets === "keychain" ||
196
+ (previous ? effectiveSecretStore(config.secretStore, {}) !== "file" : setting !== "file");
197
+ if (!wantsKeychain)
198
+ return { store: "file" };
199
+ const problem = await keychainProblem();
200
+ if (!problem)
201
+ return { store: "keychain", secretId: previous?.secretId ?? reserved ?? newSecretId() };
202
+ if (setting === "keychain") {
203
+ throw new SecretStoreError("unavailable", `WilmAI is set to keep passwords in the keychain, but there's no keychain it can use on this computer (${problem}). Nothing was sent to Wilma.`);
204
+ }
205
+ return { store: "file", note: FILE_FALLBACK_NOTE };
206
+ }
207
+ /** The authenticator key a login already has, so logging in again without one keeps it. */
208
+ async function previousTotp(previous) {
209
+ if (!previous)
210
+ return null;
211
+ if (previous.secrets === "keychain" && previous.secretId && previous.hasTotp) {
212
+ return (await readBackLoginSecrets(previous.secretId).catch(() => null))?.totpSecret ?? null;
213
+ }
214
+ return revealSecret(previous.totpSecretObfuscated);
215
+ }
216
+ /**
217
+ * Save a verified login as the active profile, replacing any profile with the
218
+ * same tenant and username, in the store chosen by chooseSecretStore. A
219
+ * keychain entry is recorded in `keychainLogins` before it is written (reusing
220
+ * one another process reserved meanwhile), and read back before the login
221
+ * points at it. Under `auto`, a keychain that fails here falls back to the
222
+ * file (the returned note says so). The keychain calls come first; the
223
+ * profile is then saved onto a fresh read of the config, so a move or a login
224
+ * another process saved meanwhile isn't undone. `config` is updated to match.
225
+ */
226
+ export async function saveLogin(config, input, choice = { store: "file" }) {
160
227
  const tenantUrl = normalizeTenantUrl(input.tenantUrl);
161
228
  // Wilma usernames are case-insensitive: "Matti.M" and "matti.m" are one account
162
229
  // (saving it twice would make two sessions that keep cancelling each other).
163
- const sameAccount = (p) => normalizeTenantUrl(p.tenantUrl) === tenantUrl && p.username.toLowerCase() === input.username.toLowerCase();
164
- const previous = config.profiles.find(sameAccount);
165
- const id = previous?.id ?? `${tenantUrl}|${input.username}`;
166
- const keepLast = previous?.lastStudentNumber
167
- ? input.students.find((s) => s.studentNumber === previous.lastStudentNumber)
168
- : undefined;
169
- const lastStudent = keepLast ?? input.students[0];
170
- const stored = {
171
- id,
172
- tenantUrl,
173
- tenantName: input.tenantName ?? previous?.tenantName ?? tenantUrl,
174
- username: input.username,
175
- passwordObfuscated: obfuscateSecret(input.password),
176
- totpSecretObfuscated: input.totpSecret ? obfuscateSecret(input.totpSecret.trim()) : previous?.totpSecretObfuscated ?? null,
177
- students: input.students.map((s) => ({ studentNumber: s.studentNumber, name: s.name })),
178
- lastStudentNumber: lastStudent?.studentNumber ?? null,
179
- lastStudentName: lastStudent?.name ?? null,
180
- lastUsedAt: new Date().toISOString(),
230
+ const sameAccount = sameAccountAs(tenantUrl, input.username);
231
+ const id = config.profiles.find(sameAccount)?.id ?? `${tenantUrl}|${input.username}`;
232
+ const totpSecret = input.totpSecret?.trim() || (await previousTotp(config.profiles.find(sameAccount)));
233
+ const secrets = { password: input.password, totpSecret };
234
+ let note = choice.note;
235
+ let secretFields;
236
+ let secretId;
237
+ if (choice.store === "keychain") {
238
+ try {
239
+ // Recorded before the entry exists, so no entry is ever unknown to the config.
240
+ await editConfig((fresh) => {
241
+ secretId = fresh.keychainLogins?.[id] ?? choice.secretId ?? newSecretId();
242
+ if (fresh.keychainLogins?.[id] === secretId)
243
+ return false;
244
+ fresh.keychainLogins = { ...fresh.keychainLogins, [id]: secretId };
245
+ });
246
+ await writeLoginSecrets(secretId, secrets);
247
+ const back = await readBackLoginSecrets(secretId);
248
+ if (back?.password !== secrets.password || (back?.totpSecret ?? null) !== (secrets.totpSecret ?? null)) {
249
+ throw new SecretStoreError("refused", "The keychain didn't keep the password as saved.");
250
+ }
251
+ secretFields = { secrets: "keychain", secretId, hasTotp: Boolean(totpSecret) };
252
+ }
253
+ catch (err) {
254
+ // `keychain` never quietly saves to the file; `auto` and `file` do, and say so.
255
+ if (effectiveSecretStore(config.secretStore) === "keychain")
256
+ throw err;
257
+ note = FILE_FALLBACK_NOTE;
258
+ secretFields = fileSecretFields(secrets);
259
+ }
260
+ }
261
+ else {
262
+ secretFields = fileSecretFields(secrets);
263
+ }
264
+ let stored;
265
+ const fresh = await editConfig((latest) => {
266
+ const previous = latest.profiles.find(sameAccount);
267
+ const keepLast = previous?.lastStudentNumber
268
+ ? input.students.find((s) => s.studentNumber === previous.lastStudentNumber)
269
+ : undefined;
270
+ const lastStudent = keepLast ?? input.students[0];
271
+ stored = {
272
+ id: previous?.id ?? id,
273
+ tenantUrl,
274
+ tenantName: input.tenantName ?? previous?.tenantName ?? tenantUrl,
275
+ username: input.username,
276
+ ...secretFields,
277
+ credentialsStamp: newCredentialsStamp(),
278
+ students: input.students.map(savedStudent),
279
+ lastStudentNumber: lastStudent?.studentNumber ?? null,
280
+ lastStudentName: lastStudent?.name ?? null,
281
+ lastUsedAt: new Date().toISOString(),
282
+ };
283
+ // Logging in again keeps the login's place in the list (`wilma accounts` numbers stay put).
284
+ const at = latest.profiles.findIndex(sameAccount);
285
+ const others = latest.profiles.filter((p) => !sameAccount(p));
286
+ latest.profiles = at === -1 ? [...others, stored] : [...others.slice(0, at), stored, ...others.slice(at)];
287
+ latest.lastProfileId = stored.id;
288
+ });
289
+ Object.assign(config, fresh);
290
+ return note ? { ...stored, note } : stored;
291
+ }
292
+ function fileSecretFields(secrets) {
293
+ return {
294
+ passwordObfuscated: obfuscateSecret(secrets.password),
295
+ totpSecretObfuscated: secrets.totpSecret ? obfuscateSecret(secrets.totpSecret.trim()) : null,
296
+ hasTotp: Boolean(secrets.totpSecret),
181
297
  };
182
- // Logging in again keeps the login's place in the list (`wilma accounts` numbers stay put).
183
- const at = config.profiles.findIndex(sameAccount);
184
- const others = config.profiles.filter((p) => !sameAccount(p));
185
- config.profiles = at === -1 ? [...others, stored] : [...others.slice(0, at), stored, ...others.slice(at)];
186
- config.lastProfileId = id;
187
- await saveConfig(config);
188
- return stored;
298
+ }
299
+ /**
300
+ * Save an authenticator key for a saved login (the interactive "Save TOTP
301
+ * secret"): into its keychain entry for a keychain login, never the file.
302
+ */
303
+ export async function saveTotpSecret(id, totpSecret) {
304
+ const key = totpSecret.trim();
305
+ const target = (await loadConfig()).profiles.find((p) => p.id === id);
306
+ if (target?.secrets === "keychain" && target.secretId) {
307
+ const current = await readBackLoginSecrets(target.secretId);
308
+ if (!current)
309
+ throw new SecretStoreError("missing", secretErrorMessage("missing", target.tenantName));
310
+ await writeLoginSecrets(target.secretId, { ...current, totpSecret: key });
311
+ }
312
+ await editConfig((config) => {
313
+ const profile = config.profiles.find((p) => p.id === id);
314
+ if (!profile)
315
+ return false;
316
+ if (profile.secrets === "keychain")
317
+ profile.hasTotp = true;
318
+ else {
319
+ profile.totpSecretObfuscated = obfuscateSecret(key);
320
+ profile.hasTotp = true;
321
+ }
322
+ profile.credentialsStamp = newCredentialsStamp();
323
+ });
189
324
  }
190
325
  /** The client reports a rejected one-time code as an AuthenticationError starting "MFA verification failed". */
191
326
  export function isMfaFailure(err) {