Drop Stalwart 0.15 support
ihasmail spoke to two generations of Stalwart that are less alike than their version numbers suggest: 0.16 replaced the REST management API with JMAP registry objects, changed the shape of FileNode, split its rights up, and moved configuration into the store. Carrying both meant 34 branch points across nine files, a 92-line compatibility shim whose only job was telling them apart, a parallel REST implementation of every credential operation, and a mock that had to model both. The branches were not the real cost. The cost was that a wrong answer about which generation had answered always had somewhere to fall back to, so it failed quietly rather than loudly: one capability looked for in the wrong place downgraded every real 0.16 server onto the 0.15 path, which posted the current password to an endpoint 0.16 had removed, reported the wrong generation on About, and ran Files on the older code. It reached production and was recorded as verified when it was not. The mock mirrored the same wrong placement, which is why the tests agreed. Removed: the filenode compatibility shim, the dual "registry" | "legacy" backend in account.ts, the pre-0.16 generation in AccountInfo and everything that read it, the mock's LEGACY mode and dev:mock:legacy, and the three test files that existed only to pin 0.15 behaviour. Sign-in now refuses an older server by name, once, rather than letting Files, the account locale and credentials each fail in their own way with nothing connecting them. It says the credentials were fine -- someone hitting this has typed a correct password, and telling them otherwise sends them round in circles -- and names the tag to build from. Four tests cover it, including that no session cookie is minted and that bad credentials on such a server are still a plain 401. Two fallbacks went that were not strictly about 0.15, and both for the same reason the removal is happening. Files no longer answers a refused filter or sort by fetching every node in the account, which would hide a real fault behind a performance cliff nobody would notice. And the app folder lookups now filter on parentId/isTopLevel alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query rather than being ignored. The last release that runs on 0.15 is tagged stalwart-0.15-support. Verified against the mock end to end: sign-in, the Files tree on the 0.16 path with the app folder hidden, and self-service credentials over the registry. 226 web + 75 server tests pass; typecheck and build clean.
This commit is contained in:
@@ -1,183 +0,0 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* The same self-service flows, against a mock impersonating Stalwart 0.15.
|
||||
*
|
||||
* That generation has no registry: credentials live behind a REST endpoint,
|
||||
* `urn:stalwart:jmap` is not a capability it knows, and naming one it cannot
|
||||
* parse fails the whole request. Until now this adapter had no coverage at all
|
||||
* — it was the least-tested code in the project, verified only by hand.
|
||||
*/
|
||||
|
||||
const PORT = 18799;
|
||||
process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_STALWART = "0.15";
|
||||
process.env.MOCK_USER = "[email protected]";
|
||||
process.env.MOCK_PASS = "demo-password";
|
||||
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
|
||||
process.env.APP_SECRET = "test-secret-for-legacy-flows";
|
||||
|
||||
const mock = await import("./mock/index.js");
|
||||
const { createApp } = await import("./app.js");
|
||||
|
||||
const app = createApp();
|
||||
let cookie = "";
|
||||
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
|
||||
|
||||
async function call(path: string, init: RequestInit = {}): Promise<{ status: number; body: any }> {
|
||||
const res = await app.request(path, {
|
||||
...init,
|
||||
headers: { ...HEADERS, ...(init.headers as Record<string, string>), ...(cookie ? { cookie } : {}) },
|
||||
});
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
if (setCookie) cookie = setCookie.split(";")[0]!;
|
||||
const text = await res.text();
|
||||
return { status: res.status, body: text ? JSON.parse(text) : null };
|
||||
}
|
||||
|
||||
const post = (path: string, body: unknown) => call(path, { method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
before(async () => {
|
||||
const res = await post("/api/auth/login", { username: "[email protected]", password: "demo-password" });
|
||||
assert.equal(res.status, 200, "login should succeed against the legacy mock");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
(mock as { server?: { close(): void } }).server?.close();
|
||||
});
|
||||
|
||||
test("the older server is recognised, and reported as such", async () => {
|
||||
const res = await call("/api/auth/session");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ihasmail.server.generation, "pre-0.16");
|
||||
assert.equal(res.body.ihasmail.server.edition, null, "no edition is reported before 0.16");
|
||||
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "the capability does not exist here");
|
||||
});
|
||||
|
||||
test("credentials fall back to the REST endpoint", async () => {
|
||||
const res = await call("/api/account/security");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.backend, "legacy");
|
||||
assert.equal(res.body.otpEnabled, false);
|
||||
assert.equal(res.body.appPasswordsKeyedByName, true, "this generation has only names to go on");
|
||||
});
|
||||
|
||||
test("app passwords round-trip, keyed by their name", async () => {
|
||||
const created = await post("/api/account/app-passwords", { description: "Thunderbird" });
|
||||
assert.equal(created.status, 200);
|
||||
assert.ok(created.body.secret, "a secret is generated for the user to copy");
|
||||
assert.equal(created.body.id, "Thunderbird", "the name is the identifier here");
|
||||
|
||||
const listed = await call("/api/account/security");
|
||||
assert.deepEqual(listed.body.appPasswords.map((a: { description: string }) => a.description), ["Thunderbird"]);
|
||||
|
||||
await post("/api/account/app-passwords/revoke", { id: "Thunderbird" });
|
||||
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
|
||||
});
|
||||
|
||||
test("the current password is verified before it is changed", async () => {
|
||||
// The REST endpoint would take our word for it, so ihasmail proves it first.
|
||||
const wrong = await post("/api/account/password", { current: "not-my-password", next: "a-much-longer-password" });
|
||||
assert.equal(wrong.status, 403);
|
||||
assert.match(wrong.body.message, /incorrect/i);
|
||||
assert.equal((mock as { account: { password: string } }).account.password, "demo-password", "nothing was changed");
|
||||
});
|
||||
|
||||
test("changing the password keeps this session working", async () => {
|
||||
const res = await post("/api/account/password", { current: "demo-password", next: "a-brand-new-password" });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal((mock as { account: { password: string } }).account.password, "a-brand-new-password");
|
||||
assert.equal((await call("/api/auth/session")).status, 200, "the session was re-sealed");
|
||||
});
|
||||
|
||||
test("2FA is enabled with a code proved against the new secret", async () => {
|
||||
const { parseOtpauthUrl, totpCode } = await import("./totp.js");
|
||||
const begin = await post("/api/account/2fa/begin", {});
|
||||
const params = parseOtpauthUrl(begin.body.url);
|
||||
assert.ok(params);
|
||||
|
||||
const bad = await post("/api/account/2fa/enable", { url: begin.body.url, code: "000000", current: "a-brand-new-password" });
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal((mock as { account: { otpUrl: string | null } }).account.otpUrl, null, "nothing was stored");
|
||||
|
||||
const good = await post("/api/account/2fa/enable", { url: begin.body.url, code: totpCode(params), current: "a-brand-new-password" });
|
||||
assert.equal(good.status, 200);
|
||||
assert.equal(good.body.sessionKept, true, "the session moved onto an app password");
|
||||
assert.equal((await call("/api/account/security")).body.otpEnabled, true);
|
||||
});
|
||||
|
||||
test("2FA is switched off again", async () => {
|
||||
const { parseOtpauthUrl, totpCode } = await import("./totp.js");
|
||||
const stored = (mock as { account: { otpUrl: string | null } }).account.otpUrl;
|
||||
const params = parseOtpauthUrl(stored!);
|
||||
assert.ok(params);
|
||||
const res = await post("/api/account/2fa/disable", { current: "a-brand-new-password", code: totpCode(params) });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal((await call("/api/account/security")).body.otpEnabled, false);
|
||||
});
|
||||
|
||||
/**
|
||||
* The mock is only worth having if it is faithful, so these pin the specific
|
||||
* behaviours that cost us a live debugging session each. Every one of them was
|
||||
* invisible to the 0.16 mock, which is how the bugs shipped.
|
||||
*/
|
||||
|
||||
const jmap = (using: string[], methodCalls: unknown[]) => post("/api/jmap", { using, methodCalls });
|
||||
const CORE = "urn:ietf:params:jmap:core";
|
||||
const MAIL = "urn:ietf:params:jmap:mail";
|
||||
const FILES = "urn:ietf:params:jmap:filenode";
|
||||
|
||||
test("naming a capability it cannot parse fails the whole request", async () => {
|
||||
const res = await jmap([CORE, "urn:stalwart:jmap"], [["Mailbox/get", { accountId: "a1", ids: null }, "c0"]]);
|
||||
assert.notEqual(res.status, 200, "not one failed call - the entire request");
|
||||
});
|
||||
|
||||
test("x: methods do not exist, so they come back unknownMethod", async () => {
|
||||
const res = await jmap([CORE], [["x:AccountPassword/get", { accountId: "a1", ids: ["singleton"] }, "c0"]]);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.methodResponses[0][0], "error");
|
||||
assert.equal(res.body.methodResponses[0][1].type, "unknownMethod");
|
||||
});
|
||||
|
||||
test("FileNode/set refuses nodeType by name", async () => {
|
||||
const res = await jmap([CORE, FILES], [["FileNode/set", { accountId: "a1", create: { d: { parentId: null, name: "New", nodeType: "directory" } } }, "c0"]]);
|
||||
const set = res.body.methodResponses[0][1];
|
||||
assert.equal(set.notCreated.d.type, "invalidProperties");
|
||||
assert.deepEqual(set.notCreated.d.properties, ["nodeType"]);
|
||||
});
|
||||
|
||||
test("a directory is a node with no file properties, and query cannot see it", async () => {
|
||||
const made = await jmap([CORE, FILES], [["FileNode/set", { accountId: "a1", create: { d: { parentId: null, name: "Reports" } } }, "c0"]]);
|
||||
const id = made.body.methodResponses[0][1].created.d.id;
|
||||
assert.ok(id);
|
||||
|
||||
const queried = await jmap([CORE, FILES], [["FileNode/query", { accountId: "a1" }, "c0"]]);
|
||||
assert.equal(queried.body.methodResponses[0][1].ids.includes(id), false, "query masks out containers");
|
||||
|
||||
// get carries no such mask, which is the only way to find a folder here.
|
||||
const got = await jmap([CORE, FILES], [["FileNode/get", { accountId: "a1", ids: null }, "c0"]]);
|
||||
const list = got.body.methodResponses[0][1].list as { id: string; nodeType?: string; myRights: Record<string, boolean> }[];
|
||||
const dir = list.find((n) => n.id === id);
|
||||
assert.ok(dir, "get returns the directory");
|
||||
assert.equal(dir!.nodeType, undefined, "nodeType is not a property here");
|
||||
assert.deepEqual(Object.keys(dir!.myRights).sort(), ["mayRead", "mayShare", "mayWrite"], "the coarser rights");
|
||||
});
|
||||
|
||||
test("FileNode/query refuses the filters and sorts this generation lacks", async () => {
|
||||
const filtered = await jmap([CORE, FILES], [["FileNode/query", { accountId: "a1", filter: { isTopLevel: true } }, "c0"]]);
|
||||
assert.equal(filtered.body.methodResponses[0][1].type, "unsupportedFilter");
|
||||
const sorted = await jmap([CORE, FILES], [["FileNode/query", { accountId: "a1", sort: [{ property: "nodeType" }] }, "c0"]]);
|
||||
assert.equal(sorted.body.methodResponses[0][1].type, "unsupportedSort");
|
||||
});
|
||||
|
||||
test("an identity signature is capped in bytes, not characters", async () => {
|
||||
// 1200 CJK characters: comfortably under 2047 counted as characters, and
|
||||
// 3600 bytes once encoded.
|
||||
const tooBig = "日".repeat(1200);
|
||||
assert.ok(tooBig.length < 2047 && Buffer.byteLength(tooBig, "utf8") > 2047);
|
||||
const res = await jmap([CORE, MAIL], [["Identity/set", { accountId: "a1", update: { i1: { htmlSignature: tooBig } } }, "c0"]]);
|
||||
const set = res.body.methodResponses[0][1];
|
||||
assert.equal(set.notUpdated.i1.type, "invalidProperties");
|
||||
assert.deepEqual(set.notUpdated.i1.properties, ["htmlSignature"]);
|
||||
});
|
||||
@@ -47,27 +47,25 @@ after(() => {
|
||||
});
|
||||
|
||||
/**
|
||||
* What the About page reads. Stalwart advertises `urn:stalwart:jmap` only
|
||||
* per-account, so a session that looks for it at the top level reports a real
|
||||
* 0.16 server as older than 0.16 — the same mistake that sent credentials to
|
||||
* the removed REST endpoint.
|
||||
* Stalwart advertises `urn:stalwart:jmap` only per-account, never in the
|
||||
* session-level capabilities. Looking for it at the top level alone reported
|
||||
* every real 0.16 server as older than 0.16 — and now that the same check
|
||||
* decides whether a sign-in is allowed at all, that mistake would lock
|
||||
* everyone out rather than merely misroute credentials.
|
||||
*/
|
||||
test("the session reports the 0.16 generation the server actually is", async () => {
|
||||
test("the session is accepted on a server that advertises the registry per-account", async () => {
|
||||
const res = await call("/api/auth/session");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ihasmail.server.generation, "0.16+");
|
||||
assert.equal(res.body.ihasmail.server.edition, "oss");
|
||||
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "not where a client would first look");
|
||||
assert.ok("urn:stalwart:jmap" in res.body.primaryAccounts, "but here, as on a real server");
|
||||
});
|
||||
|
||||
test("the 0.16 registry backend is detected and reported empty", async () => {
|
||||
test("the registry reports an account with nothing set up yet", async () => {
|
||||
const res = await call("/api/account/security");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.backend, "registry");
|
||||
assert.equal(res.body.otpEnabled, false);
|
||||
assert.deepEqual(res.body.appPasswords, []);
|
||||
assert.equal(res.body.appPasswordsKeyedByName, false);
|
||||
});
|
||||
|
||||
test("app passwords are created, listed once with their secret, and revoked", async () => {
|
||||
|
||||
+44
-222
@@ -1,17 +1,15 @@
|
||||
import { config } from "./config.js";
|
||||
import { absoluteUpstream, hasStalwartRegistry, UpstreamError, type UpstreamSession } from "./upstream.js";
|
||||
import { absoluteUpstream, UpstreamError, type UpstreamSession } from "./upstream.js";
|
||||
import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Self-service credential management, across two incompatible Stalwart APIs.
|
||||
* Self-service credential management, over Stalwart's JMAP registry:
|
||||
* `x:AccountPassword` (a singleton holding the password and the otpauth URL)
|
||||
* and `x:AppPassword`.
|
||||
*
|
||||
* 0.16+ JMAP registry objects: x:AccountPassword (a singleton holding the
|
||||
* password and the otpauth URL) and x:AppPassword.
|
||||
* 0.15.x a REST endpoint, POST /api/account/auth, taking a list of actions.
|
||||
*
|
||||
* The registry crate does not exist before 0.16 and the REST endpoint is gone
|
||||
* after it, so which one answers is the only reliable way to tell them apart.
|
||||
* The registry crate arrived in 0.16, which is the oldest Stalwart ihasmail
|
||||
* supports. Sign-in refuses anything older, so by the time any of this runs
|
||||
* the registry is known to be there.
|
||||
*/
|
||||
|
||||
const STALWART_CAP = "urn:stalwart:jmap";
|
||||
@@ -21,10 +19,7 @@ const SINGLETON = "singleton";
|
||||
/** Returned in place of a stored secret; echo it back to leave one unchanged. */
|
||||
const MASKED = "[********]";
|
||||
|
||||
export type Backend = "registry" | "legacy";
|
||||
|
||||
export interface AppPasswordRow {
|
||||
/** Registry object id, or the name itself on legacy servers. */
|
||||
id: string;
|
||||
description: string;
|
||||
createdAt: string | null;
|
||||
@@ -32,14 +27,8 @@ export interface AppPasswordRow {
|
||||
}
|
||||
|
||||
export interface SecurityState {
|
||||
backend: Backend;
|
||||
otpEnabled: boolean;
|
||||
appPasswords: AppPasswordRow[];
|
||||
/**
|
||||
* Legacy servers key app passwords by name and hand back nothing else, so
|
||||
* the UI must keep names unique and cannot show when one was created.
|
||||
*/
|
||||
appPasswordsKeyedByName: boolean;
|
||||
}
|
||||
|
||||
/** An error with a message meant for the person using the app. */
|
||||
@@ -61,49 +50,7 @@ interface Ctx {
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Backend detection */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const backendCache = new Map<string, { backend: Backend; at: number }>();
|
||||
const BACKEND_CACHE_MS = 30 * 60_000;
|
||||
|
||||
export function forgetBackend(sessionId: string): void {
|
||||
backendCache.delete(sessionId);
|
||||
}
|
||||
|
||||
export async function detectBackend(sessionId: string, ctx: Ctx): Promise<Backend> {
|
||||
const cached = backendCache.get(sessionId);
|
||||
if (cached && Date.now() - cached.at < BACKEND_CACHE_MS) return cached.backend;
|
||||
const backend = await probeBackend(ctx);
|
||||
backendCache.set(sessionId, { backend, at: Date.now() });
|
||||
return backend;
|
||||
}
|
||||
|
||||
async function probeBackend(ctx: Ctx): Promise<Backend> {
|
||||
// A server with the registry answers x:AccountPassword/get; one without it
|
||||
// fails to parse the method name at all and returns unknownMethod.
|
||||
if (hasStalwartRegistry(ctx.session)) {
|
||||
try {
|
||||
const res = await jmap(ctx, [["x:AccountPassword/get", { accountId: accountId(ctx), ids: [SINGLETON] }, "p"]]);
|
||||
const [name, args] = res.methodResponses?.[0] ?? [];
|
||||
if (name && name !== "error") return "registry";
|
||||
const type = (args as { type?: string } | undefined)?.type;
|
||||
if (type && type !== "unknownMethod") return "registry"; // present, but refused us
|
||||
} catch {
|
||||
// The capability already told us this server has the registry, so a
|
||||
// request we could not read is a fault to surface, not evidence of an
|
||||
// older server. Falling back here would post the user's password to a
|
||||
// REST endpoint 0.16 removed and report the feature as unsupported.
|
||||
return "registry";
|
||||
}
|
||||
// It named the capability and then disowned the method: nothing else to try.
|
||||
return "registry";
|
||||
}
|
||||
return "legacy";
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Transports */
|
||||
/* Transport */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function accountId(ctx: Ctx): string {
|
||||
@@ -129,29 +76,6 @@ async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodRespon
|
||||
return (await res.json()) as { methodResponses?: [string, unknown, string][] };
|
||||
}
|
||||
|
||||
async function legacy<T>(ctx: Ctx, init: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${config.stalwartUrl}/api/account/auth`, {
|
||||
...init,
|
||||
headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" },
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401);
|
||||
if (res.status === 404) {
|
||||
throw new AccountError("This mail server does not offer self-service credential management.", 501, "unsupported");
|
||||
}
|
||||
if (!res.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const body = (await res.json()) as { error?: string; details?: string; reason?: string };
|
||||
detail = body.details ?? body.reason ?? body.error ?? "";
|
||||
} catch {
|
||||
/* fall through to the generic message */
|
||||
}
|
||||
throw new AccountError(detail || `The mail server rejected the change (${res.status}).`, 502, "upstream");
|
||||
}
|
||||
return ((await res.json()) as { data: T }).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the single result out of a /set, turning JMAP's several failure shapes
|
||||
* into one error carrying whatever the server was willing to explain.
|
||||
@@ -192,17 +116,7 @@ function describeSetError(err: { type?: string; description?: string; properties
|
||||
/* Operations */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function getState(sessionId: string, ctx: Ctx): Promise<SecurityState> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "legacy") {
|
||||
const data = await legacy<{ otpEnabled?: boolean; appPasswords?: string[] }>(ctx, { method: "GET" });
|
||||
return {
|
||||
backend,
|
||||
otpEnabled: Boolean(data.otpEnabled),
|
||||
appPasswords: (data.appPasswords ?? []).map((name) => ({ id: name, description: name, createdAt: null, expiresAt: null })),
|
||||
appPasswordsKeyedByName: true,
|
||||
};
|
||||
}
|
||||
export async function getState(ctx: Ctx): Promise<SecurityState> {
|
||||
const id = accountId(ctx);
|
||||
const res = await jmap(ctx, [
|
||||
["x:AccountPassword/get", { accountId: id, ids: [SINGLETON] }, "p"],
|
||||
@@ -211,7 +125,6 @@ export async function getState(sessionId: string, ctx: Ctx): Promise<SecuritySta
|
||||
const pass = firstListItem(res, "p") as { otpAuth?: { otpUrl?: string | null } } | null;
|
||||
const apps = listOf(res, "a");
|
||||
return {
|
||||
backend,
|
||||
// The URL itself is masked; its presence is what tells us 2FA is on.
|
||||
otpEnabled: Boolean(pass?.otpAuth?.otpUrl),
|
||||
appPasswords: apps.map((a) => ({
|
||||
@@ -220,7 +133,6 @@ export async function getState(sessionId: string, ctx: Ctx): Promise<SecuritySta
|
||||
createdAt: typeof a.createdAt === "string" ? a.createdAt : null,
|
||||
expiresAt: typeof a.expiresAt === "string" ? a.expiresAt : null,
|
||||
})),
|
||||
appPasswordsKeyedByName: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -235,56 +147,25 @@ function firstListItem(res: { methodResponses?: [string, unknown, string][] }, c
|
||||
return listOf(res, callId)[0] ?? null;
|
||||
}
|
||||
|
||||
export async function changePassword(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { current: string; next: string; otpCode?: string },
|
||||
): Promise<void> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
const update: Record<string, unknown> = { currentSecret: opts.current, secret: opts.next };
|
||||
if (opts.otpCode) update["otpAuth/otpCode"] = opts.otpCode;
|
||||
const res = await jmap(ctx, [["x:AccountPassword/set", { accountId: accountId(ctx), update: { [SINGLETON]: update } }, "s"]]);
|
||||
setResult(res, "updated");
|
||||
return;
|
||||
}
|
||||
// The legacy endpoint changes the password without asking for the old one,
|
||||
// so anyone holding a live session could set it. Prove it ourselves first.
|
||||
await assertCurrentPassword(ctx, opts.current, opts.otpCode);
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "setPassword", password: opts.next }]) });
|
||||
export async function changePassword(ctx: Ctx, opts: { current: string; next: string; otpCode?: string }): Promise<void> {
|
||||
const update: Record<string, unknown> = { currentSecret: opts.current, secret: opts.next };
|
||||
if (opts.otpCode) update["otpAuth/otpCode"] = opts.otpCode;
|
||||
const res = await jmap(ctx, [["x:AccountPassword/set", { accountId: accountId(ctx), update: { [SINGLETON]: update } }, "s"]]);
|
||||
setResult(res, "updated");
|
||||
}
|
||||
|
||||
export async function createAppPassword(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { description: string },
|
||||
): Promise<{ id: string; secret: string }> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
export async function createAppPassword(ctx: Ctx, opts: { description: string }): Promise<{ id: string; secret: string }> {
|
||||
const description = opts.description.trim() || "App password";
|
||||
if (backend === "registry") {
|
||||
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), create: { n: { description } } }, "s"]]);
|
||||
const created = setResult(res, "created");
|
||||
const secret = created && typeof created.secret === "string" ? created.secret : "";
|
||||
if (!secret) throw new AccountError("The mail server created the app password but did not return it.", 502, "upstream");
|
||||
return { id: String(created?.id ?? description), secret };
|
||||
}
|
||||
// Legacy servers take a secret of our choosing and key it by name.
|
||||
const secret = readableSecret();
|
||||
await legacy<unknown>(ctx, {
|
||||
method: "POST",
|
||||
body: JSON.stringify([{ type: "addAppPassword", name: description, password: secret }]),
|
||||
});
|
||||
return { id: description, secret };
|
||||
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), create: { n: { description } } }, "s"]]);
|
||||
const created = setResult(res, "created");
|
||||
const secret = created && typeof created.secret === "string" ? created.secret : "";
|
||||
if (!secret) throw new AccountError("The mail server created the app password but did not return it.", 502, "upstream");
|
||||
return { id: String(created?.id ?? description), secret };
|
||||
}
|
||||
|
||||
export async function revokeAppPassword(sessionId: string, ctx: Ctx, id: string): Promise<void> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), destroy: [id] }, "s"]]);
|
||||
setResult(res, "destroyed");
|
||||
return;
|
||||
}
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "removeAppPassword", name: id }]) });
|
||||
export async function revokeAppPassword(ctx: Ctx, id: string): Promise<void> {
|
||||
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), destroy: [id] }, "s"]]);
|
||||
setResult(res, "destroyed");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,89 +192,30 @@ export function assertEnrolmentCode(url: string, code: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableOtp(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { url: string; code: string; current: string },
|
||||
): Promise<void> {
|
||||
export async function enableOtp(ctx: Ctx, opts: { url: string; code: string; current: string }): Promise<void> {
|
||||
assertEnrolmentCode(opts.url, opts.code);
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
const res = await jmap(ctx, [
|
||||
[
|
||||
"x:AccountPassword/set",
|
||||
{ accountId: accountId(ctx), update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpUrl": opts.url } } },
|
||||
"s",
|
||||
],
|
||||
]);
|
||||
setResult(res, "updated");
|
||||
return;
|
||||
}
|
||||
await assertCurrentPassword(ctx, opts.current);
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "enableOtpAuth", url: opts.url }]) });
|
||||
const res = await jmap(ctx, [
|
||||
[
|
||||
"x:AccountPassword/set",
|
||||
{ accountId: accountId(ctx), update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpUrl": opts.url } } },
|
||||
"s",
|
||||
],
|
||||
]);
|
||||
setResult(res, "updated");
|
||||
}
|
||||
|
||||
export async function disableOtp(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { current: string; code: string },
|
||||
): Promise<void> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
const res = await jmap(ctx, [
|
||||
[
|
||||
"x:AccountPassword/set",
|
||||
{
|
||||
accountId: accountId(ctx),
|
||||
update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpCode": opts.code, "otpAuth/otpUrl": null } },
|
||||
},
|
||||
"s",
|
||||
],
|
||||
]);
|
||||
setResult(res, "updated");
|
||||
return;
|
||||
}
|
||||
await assertCurrentPassword(ctx, opts.current, opts.code);
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "disableOtpAuth", url: null }]) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a password by authenticating with it, for the legacy endpoint that
|
||||
* would otherwise take our word for it.
|
||||
*/
|
||||
async function assertCurrentPassword(ctx: Ctx, current: string, otpCode?: string): Promise<void> {
|
||||
const secret = otpCode ? `${current}$${otpCode}` : current;
|
||||
const authorization = `Basic ${Buffer.from(`${ctx.username}:${secret}`, "utf8").toString("base64")}`;
|
||||
const res = await fetch(`${config.stalwartUrl}/.well-known/jmap`, {
|
||||
headers: { authorization, accept: "application/json" },
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new AccountError("That password is incorrect.", 403, "bad_password");
|
||||
}
|
||||
if (!res.ok) throw new UpstreamError(`Could not verify the current password (${res.status})`, 502);
|
||||
}
|
||||
|
||||
/**
|
||||
* A legacy app password a person can read off a screen and type.
|
||||
*
|
||||
* Drawn by rejection sampling. Plain `% alphabet.length` would favour the
|
||||
* first 25 characters, because 256 is not a multiple of 33: each of those
|
||||
* would come up on 8 byte values and the remaining 8 on only 7.
|
||||
*/
|
||||
export function readableSecret(): string {
|
||||
const alphabet = "abcdefghijkmnopqrstuvwxyz23456789"; // no l/1/0 lookalikes
|
||||
const limit = 256 - (256 % alphabet.length);
|
||||
const chars: string[] = [];
|
||||
while (chars.length < 20) {
|
||||
for (const b of randomBytes(32)) {
|
||||
if (b >= limit) continue; // the tail that would skew the alphabet
|
||||
chars.push(alphabet[b % alphabet.length]!);
|
||||
if (chars.length === 20) break;
|
||||
}
|
||||
}
|
||||
return (chars.join("").match(/.{5}/g) ?? []).join("-");
|
||||
export async function disableOtp(ctx: Ctx, opts: { current: string; code: string }): Promise<void> {
|
||||
const res = await jmap(ctx, [
|
||||
[
|
||||
"x:AccountPassword/set",
|
||||
{
|
||||
accountId: accountId(ctx),
|
||||
update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpCode": opts.code, "otpAuth/otpUrl": null } },
|
||||
},
|
||||
"s",
|
||||
],
|
||||
]);
|
||||
setResult(res, "updated");
|
||||
}
|
||||
|
||||
export { MASKED };
|
||||
|
||||
@@ -7,7 +7,8 @@ import { getAccountInfo, hasStalwartRegistry, interpretAccountInfo } from "./ups
|
||||
* the `sysAccountGet` permission — one the built-in `user` role is not given.
|
||||
* Ordinary users therefore silently fell back to the browser locale. Stalwart
|
||||
* 0.16 exposes the same field on `x:AccountSettings`, which users *can* read,
|
||||
* so both are asked for and whichever answers wins.
|
||||
* so both are asked for and whichever answers wins. Both are 0.16 methods:
|
||||
* this is a permissions fallback, not a version one.
|
||||
*/
|
||||
|
||||
type Responses = [string, Record<string, unknown>, string][];
|
||||
@@ -19,7 +20,6 @@ const failed = (id: string, type: string): Responses[number] => ["error", { type
|
||||
test("prefers the locale a regular user is allowed to read", () => {
|
||||
const info = interpretAccountInfo([settingsOk("de_DE.UTF-8"), accountOk("fr_FR")]);
|
||||
assert.equal(info.locale, "de-DE");
|
||||
assert.equal(info.generation, "0.16+");
|
||||
});
|
||||
|
||||
test("falls back to x:Account when the settings object is forbidden", () => {
|
||||
@@ -27,22 +27,14 @@ test("falls back to x:Account when the settings object is forbidden", () => {
|
||||
assert.equal(info.locale, "sr-Latn-RS");
|
||||
});
|
||||
|
||||
test("an older server is recognised by its unknownMethod, and still yields a locale", () => {
|
||||
const info = interpretAccountInfo([failed("s", "unknownMethod"), accountOk("en_GB")]);
|
||||
assert.equal(info.generation, "pre-0.16");
|
||||
assert.equal(info.locale, "en-GB");
|
||||
});
|
||||
|
||||
test("a server answering the new method is 0.16+ even with no locale set", () => {
|
||||
test("an account with no locale set yields none, rather than a guess", () => {
|
||||
const info = interpretAccountInfo([["x:AccountSettings/get", { list: [] }, "s"], failed("a", "forbidden")]);
|
||||
assert.equal(info.generation, "0.16+");
|
||||
assert.equal(info.locale, null);
|
||||
});
|
||||
|
||||
test("neither answering leaves everything unknown rather than guessing", () => {
|
||||
const info = interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]);
|
||||
assert.deepEqual(info, { locale: null, generation: null, edition: null });
|
||||
assert.deepEqual(interpretAccountInfo([]), { locale: null, generation: null, edition: null });
|
||||
test("neither answering leaves the locale unknown", () => {
|
||||
assert.deepEqual(interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]), { locale: null, edition: null });
|
||||
assert.deepEqual(interpretAccountInfo([]), { locale: null, edition: null });
|
||||
});
|
||||
|
||||
test("locales that carry no language are dropped, not passed through", () => {
|
||||
@@ -50,20 +42,18 @@ test("locales that carry no language are dropped, not passed through", () => {
|
||||
assert.equal(interpretAccountInfo([settingsOk("POSIX")]).locale, null);
|
||||
});
|
||||
|
||||
test("a server that never heard of the Stalwart capability is reported as pre-0.16", async () => {
|
||||
// 0.16 always advertises urn:stalwart:jmap and nothing older knows it at all,
|
||||
// so its absence is the answer - and asking anyway would fail the whole
|
||||
// request on those servers. This is what the live 0.15.5 box hits.
|
||||
test("a server without the registry is not asked for anything", async () => {
|
||||
// Sign-in refuses these, so getAccountInfo should never reach the wire for
|
||||
// one - and must not, since a server that cannot parse `urn:stalwart:jmap`
|
||||
// fails the whole request rather than the one call.
|
||||
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
|
||||
const info = await getAccountInfo("session-pre-016", "Basic x", session as never);
|
||||
assert.equal(info.generation, "pre-0.16");
|
||||
assert.equal(info.locale, null);
|
||||
assert.equal(info.edition, null);
|
||||
const info = await getAccountInfo("session-unsupported", "Basic x", session as never);
|
||||
assert.deepEqual(info, { locale: null, edition: null });
|
||||
});
|
||||
|
||||
test("no capabilities at all leaves the generation unknown", async () => {
|
||||
test("no capabilities at all is treated the same way", async () => {
|
||||
const info = await getAccountInfo("session-no-caps", "Basic x", { accounts: {}, primaryAccounts: {} } as never);
|
||||
assert.equal(info.generation, null);
|
||||
assert.equal(info.locale, null);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -73,9 +63,12 @@ test("no capabilities at all leaves the generation unknown", async () => {
|
||||
* fixed list that has never carried this capability, in any 0.16.x. It is
|
||||
* handed out per-account instead, so it lands in `primaryAccounts` and in each
|
||||
* account's `accountCapabilities`. Looking only at the session level called
|
||||
* every real 0.16 server pre-0.16, which sent self-service credentials to a
|
||||
* every real 0.16 server too old, which sent self-service credentials to a
|
||||
* REST endpoint 0.16 had removed and made the About page report the wrong
|
||||
* generation.
|
||||
* thing.
|
||||
*
|
||||
* This check now decides whether a sign-in is allowed at all, so getting it
|
||||
* wrong would lock every user out of a perfectly good server.
|
||||
*/
|
||||
const STALWART = "urn:stalwart:jmap";
|
||||
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
|
||||
@@ -102,7 +95,7 @@ test("the session level still counts, for a server that ever advertises it there
|
||||
assert.equal(hasStalwartRegistry({ capabilities: { ...baseCaps, [STALWART]: {} }, accounts: {}, primaryAccounts: {} }), true);
|
||||
});
|
||||
|
||||
test("a server that advertises it nowhere is pre-0.16", () => {
|
||||
test("a server that advertises it nowhere is one we do not support", () => {
|
||||
assert.equal(hasStalwartRegistry({ capabilities: baseCaps, accounts: { a1: { accountCapabilities: baseCaps } }, primaryAccounts: { "urn:ietf:params:jmap:mail": "a1" } }), false);
|
||||
assert.equal(hasStalwartRegistry(undefined), false);
|
||||
});
|
||||
@@ -117,15 +110,3 @@ test("a shared account carrying the capability is enough to recognise the server
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("a locale request that fails does not talk us out of a generation we proved", () => {
|
||||
// The capability settled it. A forbidden reply costs the locale, nothing more.
|
||||
const info = interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")], "0.16+");
|
||||
assert.equal(info.generation, "0.16+");
|
||||
assert.equal(info.locale, null);
|
||||
});
|
||||
|
||||
test("a server that disowns the method is still older, whatever we came in believing", () => {
|
||||
const info = interpretAccountInfo([failed("s", "unknownMethod")], "0.16+");
|
||||
assert.equal(info.generation, "pre-0.16");
|
||||
});
|
||||
|
||||
+29
-17
@@ -12,6 +12,7 @@ import {
|
||||
absoluteUpstream,
|
||||
expandTemplate,
|
||||
fetchUpstreamSession,
|
||||
hasStalwartRegistry,
|
||||
forgetUpstreamSession,
|
||||
getAccountInfo,
|
||||
getUpstreamSession,
|
||||
@@ -25,7 +26,6 @@ import {
|
||||
createAppPassword,
|
||||
disableOtp,
|
||||
enableOtp,
|
||||
forgetBackend,
|
||||
getState,
|
||||
revokeAppPassword,
|
||||
} from "./account.js";
|
||||
@@ -180,6 +180,20 @@ export function createApp(): Hono<Env> {
|
||||
const authorization = `Basic ${Buffer.from(`${username}:${effectivePassword}`, "utf8").toString("base64")}`;
|
||||
try {
|
||||
const upstream = await fetchUpstreamSession(authorization);
|
||||
// ihasmail requires Stalwart 0.16 or newer. Refuse here, once and
|
||||
// clearly, rather than signing someone in and letting Files, the account
|
||||
// locale and self-service credentials each fail in their own way with
|
||||
// nothing to connect them. The credentials were good, so say so.
|
||||
if (!hasStalwartRegistry(upstream)) {
|
||||
return c.json(
|
||||
{
|
||||
error: "unsupported_server",
|
||||
message:
|
||||
"Your credentials are fine, but this mail server is older than Stalwart 0.16, which ihasmail needs. Upgrade the server, or run the release tagged stalwart-0.15-support.",
|
||||
},
|
||||
501,
|
||||
);
|
||||
}
|
||||
loginLimiter.reset(limitKey);
|
||||
const { cookie, session } = sessions.create({
|
||||
username,
|
||||
@@ -236,9 +250,8 @@ export function createApp(): Hono<Env> {
|
||||
// ---------- Self-service credentials ----------
|
||||
/**
|
||||
* Password, app passwords and 2FA. These live on the server rather than in
|
||||
* the browser because the pre-0.16 API is REST rather than JMAP (the browser
|
||||
* only ever sees /api/jmap), and because changing a credential means
|
||||
* re-sealing the session cookie that holds it.
|
||||
* the browser because changing a credential means re-sealing the session
|
||||
* cookie that holds it, and because the browser only ever sees /api/jmap.
|
||||
*/
|
||||
const accountCtx = async (c: Context<Env>) => {
|
||||
const session = c.get("session");
|
||||
@@ -264,7 +277,7 @@ export function createApp(): Hono<Env> {
|
||||
api.get("/account/security", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
try {
|
||||
return c.json(await getState(session.id, await accountCtx(c)));
|
||||
return c.json(await getState(await accountCtx(c)));
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
@@ -284,7 +297,7 @@ export function createApp(): Hono<Env> {
|
||||
return c.json({ error: "unchanged", message: "The new password matches the old one." }, 400);
|
||||
}
|
||||
try {
|
||||
await changePassword(session.id, await accountCtx(c), { current, next, otpCode: body.otpCode?.trim() || undefined });
|
||||
await changePassword(await accountCtx(c), { current, next, otpCode: body.otpCode?.trim() || undefined });
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
@@ -300,8 +313,8 @@ export function createApp(): Hono<Env> {
|
||||
api.get("/account/app-passwords", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
try {
|
||||
const state = await getState(session.id, await accountCtx(c));
|
||||
return c.json({ appPasswords: state.appPasswords, keyedByName: state.appPasswordsKeyedByName });
|
||||
const state = await getState(await accountCtx(c));
|
||||
return c.json({ appPasswords: state.appPasswords });
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
@@ -314,7 +327,7 @@ export function createApp(): Hono<Env> {
|
||||
const description = (body.description ?? "").trim().slice(0, 120);
|
||||
if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400);
|
||||
try {
|
||||
return c.json(await createAppPassword(session.id, await accountCtx(c), { description }));
|
||||
return c.json(await createAppPassword(await accountCtx(c), { description }));
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
@@ -325,7 +338,7 @@ export function createApp(): Hono<Env> {
|
||||
const body = await readJson<{ id?: string }>(c);
|
||||
if (!body?.id) return c.json({ error: "bad_request" }, 400);
|
||||
try {
|
||||
await revokeAppPassword(session.id, await accountCtx(c), body.id);
|
||||
await revokeAppPassword(await accountCtx(c), body.id);
|
||||
return c.json({ ok: true });
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
@@ -366,18 +379,18 @@ export function createApp(): Hono<Env> {
|
||||
}
|
||||
let app: { id: string; secret: string } | null = null;
|
||||
try {
|
||||
app = await createAppPassword(session.id, ctx, { description: appPasswordName(c) });
|
||||
app = await createAppPassword(ctx, { description: appPasswordName(c) });
|
||||
} catch (err) {
|
||||
// Out of app-password quota, say. 2FA is still worth having; the user
|
||||
// just has to sign in again afterwards.
|
||||
console.warn("[ihasmail] could not mint a session app password:", (err as Error).message);
|
||||
}
|
||||
try {
|
||||
await enableOtp(session.id, ctx, { url: body.url, code, current: body.current });
|
||||
await enableOtp(ctx, { url: body.url, code, current: body.current });
|
||||
} catch (err) {
|
||||
if (app) {
|
||||
// Don't leave a credential behind for a change that never happened.
|
||||
await revokeAppPassword(session.id, ctx, app.id).catch(() => {});
|
||||
await revokeAppPassword(ctx, app.id).catch(() => {});
|
||||
}
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
@@ -398,7 +411,7 @@ export function createApp(): Hono<Env> {
|
||||
const body = await readJson<{ current?: string; code?: string }>(c);
|
||||
if (!body?.current || !body.code) return c.json({ error: "bad_request" }, 400);
|
||||
try {
|
||||
await disableOtp(session.id, await accountCtx(c), { current: body.current, code: body.code.trim() });
|
||||
await disableOtp(await accountCtx(c), { current: body.current, code: body.code.trim() });
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
@@ -406,7 +419,6 @@ export function createApp(): Hono<Env> {
|
||||
// the plain password works again now, so put it back.
|
||||
sessions.reseal(getCookie(c, config.cookieName), body.current);
|
||||
forgetUpstreamSession(session.id);
|
||||
forgetBackend(session.id);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -578,7 +590,7 @@ function appPasswordName(c: Context): string {
|
||||
return `${config.appName} (${browser})`;
|
||||
}
|
||||
|
||||
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, generation: null, edition: null }) {
|
||||
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, edition: null }) {
|
||||
return {
|
||||
ihasmail: {
|
||||
appName: config.appName,
|
||||
@@ -591,7 +603,7 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
||||
/** Locale configured for the account in Stalwart's directory, if readable. */
|
||||
userLocale: info.locale,
|
||||
/** What the upstream server would tell us about itself. */
|
||||
server: { generation: info.generation, edition: info.edition },
|
||||
server: { edition: info.edition },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* ihasmail requires Stalwart 0.16 or newer. Sign-in is where that is enforced,
|
||||
* and it matters that it is enforced *there*: the alternative is signing
|
||||
* someone in and letting Files, the account locale and self-service
|
||||
* credentials each fail in their own way, with nothing to connect the three or
|
||||
* to say what the real problem is.
|
||||
*
|
||||
* The refusal also has to keep two things apart that look the same from the
|
||||
* outside. Bad credentials are a 401 the user can fix by typing again; an
|
||||
* unsupported server is not, and telling someone their password is wrong when
|
||||
* it is not would send them round in circles.
|
||||
*/
|
||||
|
||||
const PORT = 18799;
|
||||
process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_USER = "[email protected]";
|
||||
process.env.MOCK_PASS = "demo-password";
|
||||
process.env.MOCK_NO_REGISTRY = "1"; // a server without urn:stalwart:jmap
|
||||
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
|
||||
process.env.APP_SECRET = "test-secret-for-login-guard";
|
||||
|
||||
const mock = await import("./mock/index.js");
|
||||
const { createApp } = await import("./app.js");
|
||||
|
||||
const app = createApp();
|
||||
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
|
||||
|
||||
async function login(body: unknown): Promise<{ status: number; body: any; setCookie: string | null }> {
|
||||
const res = await app.request("/api/auth/login", { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
|
||||
const text = await res.text();
|
||||
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get("set-cookie") };
|
||||
}
|
||||
|
||||
before(() => {
|
||||
assert.equal(process.env.MOCK_NO_REGISTRY, "1");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
(mock as { server?: { close(): void } }).server?.close();
|
||||
});
|
||||
|
||||
test("a server without the registry is refused, with good credentials", async () => {
|
||||
const res = await login({ username: "[email protected]", password: "demo-password" });
|
||||
assert.equal(res.status, 501);
|
||||
assert.equal(res.body.error, "unsupported_server");
|
||||
});
|
||||
|
||||
test("the message says the credentials were fine, and names the way out", async () => {
|
||||
const { body } = await login({ username: "[email protected]", password: "demo-password" });
|
||||
// Someone hitting this has typed a correct password. Saying so is the
|
||||
// difference between "upgrade your server" and "try your password again".
|
||||
assert.match(body.message, /credentials are fine/i);
|
||||
assert.match(body.message, /0\.16/);
|
||||
assert.match(body.message, /stalwart-0\.15-support/, "the tag to build from if they cannot upgrade");
|
||||
});
|
||||
|
||||
test("no session is minted for a server we cannot talk to", async () => {
|
||||
// A cookie here would leave a signed-in session against a server every
|
||||
// other request is going to fail on.
|
||||
const res = await login({ username: "[email protected]", password: "demo-password" });
|
||||
assert.equal(res.setCookie, null);
|
||||
});
|
||||
|
||||
test("bad credentials on such a server are still a 401, not the server error", async () => {
|
||||
// The upstream session request fails first, and that answer is the honest
|
||||
// one: we never got far enough to learn what the server supports.
|
||||
const res = await login({ username: "[email protected]", password: "wrong-password" });
|
||||
assert.equal(res.status, 401);
|
||||
assert.notEqual(res.body.error, "unsupported_server");
|
||||
});
|
||||
+13
-69
@@ -10,14 +10,12 @@ import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
||||
|
||||
const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
||||
/**
|
||||
* Which Stalwart generation to impersonate. "0.16" (the default) has the
|
||||
* registry — the `x:` methods, `nodeType` on FileNode, the finer-grained
|
||||
* rights. "0.15" is the older shape, and differs in ways that mostly do not
|
||||
* announce themselves: its FileNode/query cannot see directories at all, it
|
||||
* refuses a `using` naming a capability it does not know, and self-service
|
||||
* credentials live behind a REST endpoint instead.
|
||||
* Omit `urn:stalwart:jmap` from the session, so a sign-in can be tested
|
||||
* against a server ihasmail does not support. This is only that: the rest of
|
||||
* the mock still behaves like 0.16. Emulating 0.15 properly went with the
|
||||
* support for it.
|
||||
*/
|
||||
const LEGACY = process.env.MOCK_STALWART === "0.15";
|
||||
const NO_REGISTRY = process.env.MOCK_NO_REGISTRY === "1";
|
||||
/**
|
||||
* Stalwart advertises FUTURERELEASE in the session but only honours it when
|
||||
* the MTA's own `futureRelease` setting is on -- and that setting defaults to
|
||||
@@ -169,10 +167,7 @@ const fileNodes: Obj[] = [
|
||||
{ id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr() },
|
||||
];
|
||||
function fr() {
|
||||
// 0.16 split what used to be a single mayWrite into four.
|
||||
return LEGACY
|
||||
? { mayRead: true, mayWrite: true, mayShare: true }
|
||||
: { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
|
||||
return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
|
||||
}
|
||||
|
||||
function recount() {
|
||||
@@ -616,32 +611,11 @@ const handlers: Record<string, Handler> = {
|
||||
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
||||
"FileNode/query": (a) => {
|
||||
const f = (a.filter as Obj) ?? {};
|
||||
if (LEGACY) {
|
||||
// Sorting is refused outright, and isTopLevel / nodeType are not filters
|
||||
// this generation knows.
|
||||
if (a.sort) throw new MethodError("unsupportedSort", "Sorting is not supported on FileNode");
|
||||
if ("isTopLevel" in f || "nodeType" in f) throw new MethodError("unsupportedFilter", "Unsupported filter");
|
||||
}
|
||||
let list = fileNodes.filter((n) => (f.isTopLevel ? n.parentId == null : f.parentId ? n.parentId === f.parentId : true));
|
||||
// The pre-0.16 query masks its results to non-containers, so a directory
|
||||
// never comes back — with nothing to say it was left out.
|
||||
if (LEGACY) list = list.filter((n) => n.nodeType !== "directory");
|
||||
const list = fileNodes.filter((n) => (f.isTopLevel ? n.parentId == null : f.parentId ? n.parentId === f.parentId : true));
|
||||
return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length };
|
||||
},
|
||||
"FileNode/get": (a) => {
|
||||
const res = genericGet(fileNodes)(a);
|
||||
// nodeType does not exist before 0.16; the shape is all the client gets.
|
||||
if (LEGACY) res.list = (res.list as Obj[]).map((n) => { const { nodeType: _drop, ...rest } = n; return rest; });
|
||||
return res;
|
||||
},
|
||||
"FileNode/get": genericGet(fileNodes),
|
||||
"FileNode/set": (a) => {
|
||||
if (LEGACY) {
|
||||
for (const obj of [...Object.values((a.create as Obj) ?? {}), ...Object.values((a.update as Obj) ?? {})]) {
|
||||
if (obj && typeof obj === "object" && "nodeType" in (obj as Obj)) {
|
||||
return setResp({ notCreated: Object.fromEntries(Object.keys((a.create as Obj) ?? {}).map((k) => [k, { type: "invalidProperties", properties: ["nodeType"], description: "Invalid property." }])), notUpdated: Object.fromEntries(Object.keys((a.update as Obj) ?? {}).map((k) => [k, { type: "invalidProperties", properties: ["nodeType"], description: "Invalid property." }])) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return genericSet(fileNodes, "f", (o) => {
|
||||
Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o });
|
||||
// Without nodeType, a node is a directory precisely when it carries no
|
||||
@@ -685,8 +659,8 @@ function readBody(req: IncomingMessage): Promise<Buffer> {
|
||||
|
||||
const session = () => ({
|
||||
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} },
|
||||
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(LEGACY ? {} : { "urn:stalwart:jmap": {} }) } } },
|
||||
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(LEGACY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
|
||||
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } },
|
||||
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
|
||||
username: USER,
|
||||
apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
|
||||
downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
|
||||
@@ -709,37 +683,8 @@ export const server = createServer(async (req, res) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify(session()));
|
||||
}
|
||||
// Before 0.16, self-service credentials are a REST endpoint rather than
|
||||
// registry objects: GET reports the state, POST takes a list of actions.
|
||||
if (LEGACY && url.pathname === "/api/account/auth") {
|
||||
if (req.method === "GET") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify({ data: { otpEnabled: Boolean(account.otpUrl), appPasswords: account.appPasswords.map((a) => a.description) } }));
|
||||
}
|
||||
if (req.method === "POST") {
|
||||
const actions = JSON.parse((await readBody(req)).toString()) as { type: string; password?: string; url?: string | null; name?: string }[];
|
||||
// Password and OTP changes are only accepted over Basic auth.
|
||||
if (actions.some((a) => ["setPassword", "enableOtpAuth", "disableOtpAuth"].includes(a.type)) && !(req.headers.authorization ?? "").startsWith("Basic ")) {
|
||||
res.writeHead(400, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify({ error: "unauthorized", details: "Password changes only allowed using Basic auth" }));
|
||||
}
|
||||
for (const a of actions) {
|
||||
if (a.type === "setPassword") account.password = a.password ?? account.password;
|
||||
else if (a.type === "enableOtpAuth") account.otpUrl = a.url ?? null;
|
||||
else if (a.type === "disableOtpAuth") account.otpUrl = null;
|
||||
else if (a.type === "addAppPassword") account.appPasswords.push({ id: `ap${randomUUID().slice(0, 6)}`, description: a.name ?? "App password", secret: a.password ?? "", createdAt: new Date().toISOString(), expiresAt: null });
|
||||
else if (a.type === "removeAppPassword") {
|
||||
const i = account.appPasswords.findIndex((p) => p.description === a.name);
|
||||
if (i >= 0) account.appPasswords.splice(i, 1);
|
||||
}
|
||||
}
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify({ data: null }));
|
||||
}
|
||||
}
|
||||
|
||||
// 0.16's account info endpoint; the only place a server reports its edition.
|
||||
if (!LEGACY && url.pathname === "/api/account" && req.method === "GET") {
|
||||
// The account info endpoint; the only place a server reports its edition.
|
||||
if (url.pathname === "/api/account" && req.method === "GET") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify({ permissions: ["jmapEmailGet", "sysAccountSettingsGet"], edition: "oss", locale: MOCK_LOCALE }));
|
||||
}
|
||||
@@ -763,7 +708,7 @@ export const server = createServer(async (req, res) => {
|
||||
for (const [name, rawArgs, id] of body.methodCalls) {
|
||||
const h = handlers[name];
|
||||
// The registry, and every x: method with it, arrived in 0.16.
|
||||
if (!h || (LEGACY && name.startsWith("x:"))) { responses.push(["error", { type: "unknownMethod" }, id]); continue; }
|
||||
if (!h) { responses.push(["error", { type: "unknownMethod" }, id]); continue; }
|
||||
try {
|
||||
const args = resolveRefs(rawArgs, responses, creations);
|
||||
enforceLimits(name, args);
|
||||
@@ -810,7 +755,6 @@ export const server = createServer(async (req, res) => {
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
}).listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`[mock-stalwart] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`);
|
||||
console.log(`[mock-stalwart] impersonating Stalwart ${LEGACY ? "0.15 (pre-registry)" : "0.16+"}`);
|
||||
console.log(`[mock-stalwart] run the app with: STALWART_URL=http://127.0.0.1:${PORT} npm run dev`);
|
||||
});
|
||||
|
||||
|
||||
@@ -63,37 +63,3 @@ test("normalizes Stalwart account locales to BCP-47 tags", () => {
|
||||
assert.equal(normalizeLocale({ locale: "de_DE" }), null);
|
||||
assert.equal(normalizeLocale("../etc/passwd"), null);
|
||||
});
|
||||
|
||||
test("generated app passwords are unbiased and long enough", async () => {
|
||||
const { readableSecret } = await import("./account.js");
|
||||
const alphabet = "abcdefghijkmnopqrstuvwxyz23456789";
|
||||
const counts = new Map<string, number>();
|
||||
let samples = 0;
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const secret = readableSecret();
|
||||
assert.match(secret, /^[a-z2-9]{5}-[a-z2-9]{5}-[a-z2-9]{5}-[a-z2-9]{5}$/, secret);
|
||||
for (const ch of secret.replace(/-/g, "")) {
|
||||
counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
||||
samples++;
|
||||
}
|
||||
}
|
||||
assert.equal(samples, 2000 * 20);
|
||||
|
||||
/*
|
||||
* `% 33` over a byte maps 25 characters onto 8 values each and the last 8
|
||||
* onto 7, so the digits — the tail of the alphabet — would come up about
|
||||
* 7/8 as often as they should. Testing each character on its own cannot see
|
||||
* a skew that size against the noise, so weigh the whole tail at once:
|
||||
* uniform puts 8/33 of the draw there, the biased version 7/8 of that, and
|
||||
* over 40,000 draws the two are more than four standard deviations apart.
|
||||
*/
|
||||
const tail = alphabet.slice(25); // "23456789"
|
||||
const tailSeen = [...tail].reduce((n, ch) => n + (counts.get(ch) ?? 0), 0);
|
||||
const p = tail.length / alphabet.length;
|
||||
const expected = samples * p;
|
||||
const sigma = Math.sqrt(samples * p * (1 - p));
|
||||
assert.ok(
|
||||
Math.abs(tailSeen - expected) < 4 * sigma,
|
||||
`digits appeared ${tailSeen} times, expected ~${Math.round(expected)} (sigma ${sigma.toFixed(1)}) - modulo bias?`,
|
||||
);
|
||||
});
|
||||
|
||||
+20
-41
@@ -78,10 +78,14 @@ const JMAP_CORE = "urn:ietf:params:jmap:core";
|
||||
* builds that list from a fixed set that has never included this capability;
|
||||
* it hands it out per-account instead, so it turns up in `primaryAccounts` and
|
||||
* in each account's `accountCapabilities`. Checking only the session level
|
||||
* therefore reports every real 0.16 server as pre-0.16 — which routed
|
||||
* therefore reported every real 0.16 server as older than 0.16 — which routed
|
||||
* self-service credentials to a REST endpoint 0.16 had removed, and told the
|
||||
* About page the wrong thing. The session level is still checked last, in case
|
||||
* a later release advertises it there as well.
|
||||
*
|
||||
* This is now what sign-in tests to decide whether a server is supported at
|
||||
* all, so the same mistake would lock every user out of a working server
|
||||
* rather than merely misroute them.
|
||||
*/
|
||||
export function hasStalwartRegistry(session: Pick<UpstreamSession, "capabilities" | "accounts" | "primaryAccounts"> | undefined): boolean {
|
||||
if (!session) return false;
|
||||
@@ -96,22 +100,13 @@ export function hasStalwartRegistry(session: Pick<UpstreamSession, "capabilities
|
||||
export interface AccountInfo {
|
||||
/** BCP-47 tag configured for the account, or null if unreadable. */
|
||||
locale: string | null;
|
||||
/**
|
||||
* Which generation of Stalwart's API answered: "0.16+" has the registry
|
||||
* (`x:AccountSettings`), older builds only have `x:Account`. Null when the
|
||||
* server is not Stalwart or told us nothing.
|
||||
*/
|
||||
generation: "0.16+" | "pre-0.16" | null;
|
||||
/** "oss" | "community" | "enterprise", where the server reports it. */
|
||||
edition: string | null;
|
||||
}
|
||||
|
||||
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
||||
const INFO_CACHE_MS = 30 * 60_000;
|
||||
const EMPTY_INFO: AccountInfo = { locale: null, generation: null, edition: null };
|
||||
/** A server that has never heard of the registry: nothing to read, but dated. */
|
||||
const PRE_REGISTRY_INFO: AccountInfo = { locale: null, generation: "pre-0.16", edition: null };
|
||||
const REGISTRY_INFO: AccountInfo = { locale: null, generation: "0.16+", edition: null };
|
||||
const EMPTY_INFO: AccountInfo = { locale: null, edition: null };
|
||||
|
||||
/**
|
||||
* glibc modifiers that name a script rather than a dialect or a currency:
|
||||
@@ -165,13 +160,9 @@ export function normalizeLocale(raw: unknown): string | null {
|
||||
* tells us which generation we are talking to.
|
||||
*/
|
||||
async function fetchAccountInfo(authorization: string, session: UpstreamSession): Promise<AccountInfo> {
|
||||
// Every 0.16 build advertises urn:stalwart:jmap, and no earlier one knows it
|
||||
// at all, so its absence already answers the question — and asking anyway
|
||||
// would fail the whole request, since those servers reject a `using` naming
|
||||
// a capability they cannot parse.
|
||||
// A session with no capabilities at all is not one we can read anything from.
|
||||
if (!session.capabilities) return EMPTY_INFO;
|
||||
if (!hasStalwartRegistry(session)) return PRE_REGISTRY_INFO;
|
||||
// Sign-in refuses a server without the registry, so this should not happen —
|
||||
// but a session we cannot read capabilities from is not one to ask.
|
||||
if (!session.capabilities || !hasStalwartRegistry(session)) return EMPTY_INFO;
|
||||
const accountId =
|
||||
session.primaryAccounts?.[STALWART_CAP] ??
|
||||
session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
|
||||
@@ -189,35 +180,23 @@ async function fetchAccountInfo(authorization: string, session: UpstreamSession)
|
||||
}),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
// The registry capability already settled the generation. A locale request
|
||||
// that fails — a permission we lack, a hiccup upstream — can only cost us the
|
||||
// locale; it must not talk us out of what we know.
|
||||
if (!res.ok) return REGISTRY_INFO;
|
||||
// A locale request that fails — a permission we lack, a hiccup upstream —
|
||||
// costs us the locale and nothing else.
|
||||
if (!res.ok) return EMPTY_INFO;
|
||||
const body = (await res.json()) as { methodResponses?: [string, Record<string, unknown>, string][] };
|
||||
return interpretAccountInfo(body.methodResponses ?? [], "0.16+");
|
||||
return interpretAccountInfo(body.methodResponses ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the pair of replies: prefer the locale from `x:AccountSettings`, fall
|
||||
* back to `x:Account` for servers (or permissions) where only that one works,
|
||||
* and note which generation answered.
|
||||
* Read the pair of replies: prefer the locale from `x:AccountSettings`, whose
|
||||
* permission the built-in user role has, and fall back to `x:Account` for the
|
||||
* accounts allowed the admin-only `sysAccountGet` instead. Both are 0.16
|
||||
* methods; this is a permissions fallback, not a version one.
|
||||
*/
|
||||
export function interpretAccountInfo(
|
||||
responses: [string, Record<string, unknown>, string][],
|
||||
known: AccountInfo["generation"] = null,
|
||||
): AccountInfo {
|
||||
export function interpretAccountInfo(responses: [string, Record<string, unknown>, string][]): AccountInfo {
|
||||
const settings = responses.find((r) => r[2] === "s");
|
||||
const account = responses.find((r) => r[2] === "a");
|
||||
// Only 0.16+ knows the method at all; older builds cannot even parse the name.
|
||||
// `known` is what the session capability already proved, and outranks a reply
|
||||
// that merely refused us.
|
||||
const generation: AccountInfo["generation"] =
|
||||
settings && settings[0] !== "error"
|
||||
? "0.16+"
|
||||
: (settings?.[1] as { type?: string } | undefined)?.type === "unknownMethod"
|
||||
? "pre-0.16"
|
||||
: known;
|
||||
return { locale: localeOf(settings) ?? localeOf(account), generation, edition: null };
|
||||
return { locale: localeOf(settings) ?? localeOf(account), edition: null };
|
||||
}
|
||||
|
||||
function localeOf(call: [string, Record<string, unknown>, string] | undefined): string | null {
|
||||
@@ -251,7 +230,7 @@ export async function getAccountInfo(sessionId: string, authorization: string, s
|
||||
let info = EMPTY_INFO;
|
||||
try {
|
||||
info = await fetchAccountInfo(authorization, session);
|
||||
if (info.generation === "0.16+") info = { ...info, edition: await fetchEdition(authorization) };
|
||||
info = { ...info, edition: await fetchEdition(authorization) };
|
||||
} catch {
|
||||
/* all of this is a nicety - never fail the session over it */
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user