Add Administration, starting with accounts

An account whose Stalwart role manages accounts now finds Administration in
the account menu. It lists, searches, creates and edits accounts -- display
name, other addresses, role, storage limit -- sets a new password, and
deletes, each offered only when the role holds the matching permission.

The server keeps the permissions list from GET /api/account, which it
already called for the edition and threw the rest away. Everything else is
JMAP x:Account, x:Domain and x:Role calls through the existing /api/jmap
proxy, so nothing new is stored and Stalwart decides every call.

Stalwart checks a grant against the caller's permissions but not a password
change or a delete, so an account that outranks the viewer is shown
read-only. Your own password is changed in Settings, which re-seals the
session; changing it here would strand it.

The mock server gains a directory behind the same permission names, with
MOCK_ROLE choosing admin, tenant-admin, helpdesk or user.

68 new strings, translated in all nine catalogues; strings falling back to
English stay at 16.
This commit is contained in:
2026-09-13 15:11:55 -07:00
parent b0564679e6
commit 82e217155b
34 changed files with 2684 additions and 22 deletions
+3 -3
View File
@@ -33,8 +33,8 @@ test("an account with no locale set yields none, rather than a guess", () => {
});
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 });
assert.deepEqual(interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]), { locale: null, edition: null, permissions: [] });
assert.deepEqual(interpretAccountInfo([]), { locale: null, edition: null, permissions: [] });
});
test("locales that carry no language are dropped, not passed through", () => {
@@ -48,7 +48,7 @@ test("a server without the registry is not asked for anything", async () => {
// 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-unsupported", "Basic x", session as never);
assert.deepEqual(info, { locale: null, edition: null });
assert.deepEqual(info, { locale: null, edition: null, permissions: [] });
});
test("no capabilities at all is treated the same way", async () => {
+6 -1
View File
@@ -824,7 +824,7 @@ function appPasswordName(c: Context): string {
return `${config.appName} (${browser})`;
}
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, edition: null }) {
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, edition: null, permissions: [] }) {
return {
ihasmail: {
appName: config.appName,
@@ -838,6 +838,11 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
userLocale: info.locale,
/** What the upstream server would tell us about itself. */
server: { edition: info.edition },
/**
* The account's permissions on that server, so the client can offer
* administration to those who have it. Stalwart still decides every call.
*/
permissions: info.permissions,
},
};
}
+67
View File
@@ -0,0 +1,67 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createDirectory, permissionsFor, type MockRole } from "./directory.js";
class Refused extends Error {
constructor(readonly type: string, description?: string) { super(description ?? type); }
}
const make = (role: MockRole) => createDirectory({ accountId: "a1", user: "[email protected]", locale: "en_US", role, fail: (t, d) => new Refused(t, d) });
/**
* The mock stands in for a server that decides what each account may do, so
* the client's administration can be developed against refusals as well as
* successes. These pin the refusals.
*/
test("an ordinary user is refused the directory outright", () => {
const dir = make("user");
assert.throws(() => dir.handlers["x:Account/query"]!({}), (e: Refused) => e.type === "forbidden");
assert.ok(!permissionsFor("user").some((p) => p.startsWith("sysAccountQuery")));
});
test("helpdesk may read and edit but not create or delete", () => {
const dir = make("helpdesk");
const { ids } = dir.handlers["x:Account/query"]!({ filter: { type: "User" } }) as { ids: string[] };
assert.ok(ids.length > 20);
assert.throws(() => dir.handlers["x:Account/set"]!({ create: { n: { name: "x", domainId: "d1" } } }), (e: Refused) => e.type === "forbidden");
assert.throws(() => dir.handlers["x:Account/set"]!({ destroy: [ids[0]] }), (e: Refused) => e.type === "forbidden");
});
test("queries page, count and match text the way the client asks", () => {
const dir = make("admin");
const all = dir.handlers["x:Account/query"]!({ filter: { type: "User" }, calculateTotal: true }) as { ids: string[]; total: number };
const page = dir.handlers["x:Account/query"]!({ filter: { type: "User" }, position: 10, limit: 5, calculateTotal: true }) as { ids: string[]; total: number };
assert.equal(page.total, all.total);
assert.deepEqual(page.ids, all.ids.slice(10, 15));
const ada = dir.handlers["x:Account/query"]!({ filter: { type: "User", text: "lovelace" } }) as { ids: string[] };
assert.equal(ada.ids.length, 1);
assert.throws(() => dir.handlers["x:Account/query"]!({ filter: { operator: "OR", conditions: [] } }), (e: Refused) => e.type === "unsupportedFilter");
});
test("an address already used as an alias cannot be taken", () => {
const dir = make("admin");
const res = dir.handlers["x:Account/set"]!({ create: { n: { "@type": "User", name: "postmaster", domainId: "d1", credentials: { "0": { "@type": "Password", secret: "long enough secret" } }, roles: { "@type": "User" } } } }) as { notCreated?: Record<string, { type: string }> };
assert.equal(res.notCreated?.n?.type, "primaryKeyViolation");
});
test("a password is set through its credential's pointer, and a weak one is refused", () => {
const dir = make("admin");
const set = dir.handlers["x:Account/set"]!;
assert.equal((set({ update: { a1: { "credentials/0/secret": "short" } } }) as { notUpdated?: Record<string, { properties: string[] }> }).notUpdated?.a1?.properties[0], "secret");
assert.deepEqual((set({ update: { a1: { "credentials/0/secret": "a much longer secret" } } }) as { updated: object }).updated, { a1: null });
const got = dir.handlers["x:Account/get"]!({ ids: ["a1"], properties: ["credentials"] }) as { list: Array<{ credentials: Record<string, { secret: string }> }> };
assert.equal(got.list[0]!.credentials["0"]!.secret, "[********]", "never echoed back");
});
test("a grant the caller does not hold is refused", () => {
const dir = make("helpdesk");
const res = dir.handlers["x:Account/set"]!({ update: { u101: { roles: { "@type": "Admin" } } } }) as { notUpdated?: Record<string, { type: string }> };
assert.equal(res.notUpdated?.u101?.type, "forbidden");
});
test("an administrator can delete an account, and a group with members is kept", () => {
const dir = make("admin");
const set = dir.handlers["x:Account/set"]!;
assert.deepEqual((set({ destroy: ["u101"] }) as { destroyed: string[] }).destroyed, ["u101"]);
assert.equal((set({ destroy: ["g1"] }) as { notDestroyed?: Record<string, { type: string }> }).notDestroyed?.g1?.type, "objectIsLinked");
});
+306
View File
@@ -0,0 +1,306 @@
/**
* Enough of Stalwart 0.16's directory registry to develop administration
* against: `x:Account`, `x:Domain` and `x:Role`, gated by permission names the
* way the real server gates them.
*
* Shapes follow the 0.16.22 source rather than the documentation, which has
* been wrong about both before:
*
* - a `List<T>` (credentials, aliases) is an object keyed by index -- `{"0": …}`
* -- and a `Set` (memberGroupIds, enabledPermissions) is `{"id": true}`;
* - an account's `name` is the local part only, and it lives on a domain by id;
* - secrets come back masked, and a new one is written through the password
* credential's own pointer, `credentials/<index>/secret`;
* - `x:Account/query` understands AND and nothing else.
*
* What it does not reproduce is tenancy: every caller sees every record. The
* real server scopes a tenant administrator's queries, and nothing in the client
* relies on seeing more or less than it is given.
*
* MOCK_ROLE picks who the demo user is: `admin` (the default), `tenant-admin`,
* `helpdesk` (a custom role that may view and edit accounts but not create or
* delete them) or `user`.
*/
type Obj = Record<string, unknown>;
export type MockRole = "admin" | "tenant-admin" | "helpdesk" | "user";
const OPS = ["Get", "Query", "Create", "Update", "Destroy"] as const;
const all = (...objects: string[]) => objects.flatMap((o) => OPS.map((op) => `sys${o}${op}`));
/** A few of the ordinary ones, so the list looks like what a server sends. */
const USER_PERMISSIONS = ["jmapEmailGet", "jmapEmailSet", "jmapMailboxGet", "sysAccountSettingsGet"];
export function permissionsFor(role: MockRole): string[] {
switch (role) {
case "admin":
return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer", "Tenant"), "impersonate"];
case "tenant-admin":
return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer")];
case "helpdesk":
return [...USER_PERMISSIONS, "sysAccountGet", "sysAccountQuery", "sysAccountUpdate"];
default:
return USER_PERMISSIONS;
}
}
export function mockRole(raw: string | undefined): MockRole {
return raw === "tenant-admin" || raw === "helpdesk" || raw === "user" ? raw : "admin";
}
const MASKED = "[********]";
const GIB = 1024 ** 3;
interface Options {
/** The demo user's JMAP account id, which is also its registry id. */
accountId: string;
/** The demo user's address. */
user: string;
locale: string;
role: MockRole;
/** Build the error a method fails with; the mock server owns the type. */
fail: (type: string, description?: string) => Error;
}
export function createDirectory(opts: Options) {
const permissions = new Set(permissionsFor(opts.role));
const [userLocal, userDomain] = splitAddress(opts.user);
let counter = 100;
const domains: Obj[] = [
{ id: "d1", name: userDomain, aliases: {}, description: null },
{ id: "d2", name: userDomain === "example.org" ? "example.net" : "example.org", aliases: {}, description: null },
];
const roles: Obj[] = [
{ id: "r1", description: "User", enabledPermissions: flags(USER_PERMISSIONS), disabledPermissions: {}, roleIds: {} },
{ id: "r2", description: "Helpdesk", enabledPermissions: flags(permissionsFor("helpdesk").filter((p) => p.startsWith("sys"))), disabledPermissions: {}, roleIds: { r1: true } },
{ id: "r3", description: "Directory manager", enabledPermissions: flags(all("Account")), disabledPermissions: {}, roleIds: { r1: true } },
];
const ownRoles = opts.role === "admin" || opts.role === "tenant-admin" ? { "@type": "Admin" } : opts.role === "helpdesk" ? { "@type": "Custom", roleIds: { r2: true } } : { "@type": "User" };
const accounts: Obj[] = [];
const user = (o: { id?: string; name: string; domain?: string; description: string; roles?: Obj; used?: number; quota?: number; aliases?: string[]; groups?: string[]; password?: boolean }) => {
const domainId = o.domain === "d2" ? "d2" : "d1";
const row: Obj = {
id: o.id ?? `u${counter++}`,
"@type": "User",
name: o.name,
domainId,
description: o.description,
credentials: o.password === false ? {} : { "0": { "@type": "Password", credentialId: "0", secret: MASKED, otpAuth: null, expiresAt: null, allowedIps: {} } },
createdAt: new Date(Date.now() - counter * 86_400_000).toISOString().replace(/\.\d{3}Z$/, "Z"),
memberGroupIds: flags(o.groups ?? []),
memberTenantId: null,
roles: o.roles ?? { "@type": "User" },
permissions: { "@type": "Inherit" },
quotas: o.quota ? { maxDiskQuota: o.quota * GIB } : {},
usedDiskQuota: Math.round((o.used ?? 0) * GIB),
aliases: Object.fromEntries((o.aliases ?? []).map((name, i) => [String(i), { enabled: true, name, domainId, description: null }])),
locale: opts.locale,
timeZone: null,
};
accounts.push(row);
return row;
};
const group = (id: string, name: string, description: string) =>
accounts.push({ id, "@type": "Group", name, domainId: "d1", description, memberTenantId: null, roles: { "@type": "User" }, permissions: { "@type": "Inherit" }, quotas: {}, usedDiskQuota: 0, aliases: {} });
group("g1", "support", "Support");
group("g2", "office", "Office");
user({ id: opts.accountId, name: userLocal, description: "Demo User", roles: ownRoles, used: 1.4, quota: 10, aliases: ["postmaster"], groups: ["g1"] });
user({ name: "ada", domain: "d2", description: "Ada Lovelace", used: 3.2, quota: 5, groups: ["g2"] });
user({ name: "grace", domain: "d2", description: "Grace Hopper", used: 4.7, quota: 5, groups: ["g2"] });
user({ name: "alan", domain: "d2", description: "Alan Turing", roles: { "@type": "Custom", roleIds: { r2: true } }, used: 0.8, quota: 5, groups: ["g1"] });
user({ name: "margaret", description: "Margaret Hamilton", roles: { "@type": "Admin" }, used: 2.1, quota: 20 });
user({ name: "katherine", description: "Katherine Johnson", roles: { "@type": "Custom", roleIds: { r3: true } }, used: 0.4, quota: 5 });
user({ name: "sso.only", description: "Signs in with SSO", password: false, used: 0.1 });
const people = ["Edsger Dijkstra", "Barbara Liskov", "Donald Knuth", "Frances Allen", "John Backus", "Radia Perlman", "Ken Thompson", "Hedy Lamarr", "Dennis Ritchie", "Karen Spärck Jones", "Tim Berners-Lee", "Sophie Wilson", "Niklaus Wirth", "Jean Sammet", "Leslie Lamport", "Mary Kenneth Keller", "Tony Hoare", "Evelyn Berezin", "Butler Lampson", "Shafi Goldwasser", "Whitfield Diffie", "Adele Goldberg", "Vint Cerf", "Anita Borg", "Bob Kahn", "Lynn Conway", "Charles Babbage", "Annie Easley"];
people.forEach((description, i) => {
const name = description.toLowerCase().split(" ")[0]!.normalize("NFD").replace(/[^a-z]/g, "");
user({ name, domain: i % 3 === 0 ? "d2" : "d1", description, used: (i % 7) * 0.6, quota: i % 4 === 0 ? 0 : 5 });
});
const demand = (perm: string) => {
if (!permissions.has(perm)) throw opts.fail("forbidden", `You do not have the ${perm} permission.`);
};
const domainName = (id: unknown) => domains.find((d) => d.id === id)?.name as string | undefined;
const addressOf = (o: Obj) => `${o.name}@${domainName(o.domainId) ?? "invalid"}`;
/** Every address in use, primary and alias, across accounts. */
const addressTaken = (address: string, except?: string) =>
accounts.some((a) => a.id !== except && (addressOf(a) === address || Object.values((a.aliases as Obj) ?? {}).some((al) => `${(al as Obj).name}@${domainName((al as Obj).domainId)}` === address)));
const view = (o: Obj, properties: unknown): Obj => {
const full: Obj = { ...o, emailAddress: addressOf(o) };
if (full.credentials) {
full.credentials = Object.fromEntries(Object.entries(full.credentials as Obj).map(([k, c]) => [k, { ...(c as Obj), secret: MASKED }]));
}
if (!Array.isArray(properties)) return full;
const out: Obj = { id: o.id };
for (const p of properties as string[]) if (p in full) out[p] = full[p];
return out;
};
const get = (list: Obj[], perm: string) => (a: Obj) => {
demand(perm);
const ids = a.ids as string[] | null | undefined;
const found = ids ? list.filter((x) => ids.includes(x.id as string)) : list;
return { accountId: opts.accountId, state: "1", list: found.map((x) => view(x, a.properties)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
};
const query = (list: () => Obj[], perm: string, match: (o: Obj, filter: Obj) => boolean) => (a: Obj) => {
demand(perm);
const filter = (a.filter as Obj | undefined) ?? {};
if ("operator" in filter) throw opts.fail("unsupportedFilter", "Only AND is supported in filters");
// Stalwart's default order is newest first, by id.
const rows = list().filter((o) => match(o, filter)).sort((x, y) => String(y.id).localeCompare(String(x.id), undefined, { numeric: true }));
const position = Math.max(0, Number(a.position ?? 0));
const limit = a.limit == null ? rows.length : Number(a.limit);
return {
accountId: opts.accountId,
queryState: "1",
canCalculateChanges: false,
position,
ids: rows.slice(position, position + limit).map((o) => o.id),
...(a.calculateTotal ? { total: rows.length } : {}),
};
};
const matchText = (o: Obj, text: unknown) => {
if (typeof text !== "string" || !text.trim()) return true;
const needle = text.trim().toLowerCase();
return [o.name, o.description, addressOf(o)].some((v) => typeof v === "string" && v.toLowerCase().includes(needle));
};
const setError = (type: string, description: string, properties?: string[]) => ({ type, description, ...(properties ? { properties } : {}) });
/** The password checks, roughly as strict as a default Stalwart. */
const weakPassword = (secret: unknown) => (typeof secret !== "string" || secret.length < 8 ? "Password must be at least 8 characters long." : null);
/** Stalwart checks a grant against the caller's own permissions. */
const grantRefused = (roles: unknown): string | null => {
const r = roles as Obj | undefined;
if (!r) return null;
if (r["@type"] === "Admin" && opts.role !== "admin" && opts.role !== "tenant-admin") return "You are not authorized to grant permissions: administrator.";
if (r["@type"] === "Custom") {
for (const id of Object.keys((r.roleIds as Obj) ?? {})) {
const role = roles_(id);
if (!role) return "Role does not exist.";
const missing = Object.keys((role.enabledPermissions as Obj) ?? {}).filter((p) => !permissions.has(p));
if (missing.length) return `You are not authorized to grant permissions: ${missing.join(", ")}.`;
}
}
return null;
};
const roles_ = (id: string) => roles.find((r) => r.id === id);
const handlers: Record<string, (a: Obj) => Obj> = {
"x:Account/get": get(accounts, "sysAccountGet"),
"x:Account/query": query(() => accounts, "sysAccountQuery", (o, f) =>
(f.type === undefined || o["@type"] === f.type) && (f.domainId === undefined || o.domainId === f.domainId) && matchText(o, f.text) && matchText(o, f.name)),
"x:Account/set": (a) => {
const created: Obj = {};
const notCreated: Obj = {};
const updated: Obj = {};
const notUpdated: Obj = {};
const destroyed: string[] = [];
const notDestroyed: Obj = {};
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
demand("sysAccountCreate");
const o = { ...(raw as Obj) };
if (typeof o.name !== "string" || !/^[a-z0-9._-]+$/i.test(o.name)) { notCreated[cid] = setError("invalidProperties", "Invalid account name.", ["name"]); continue; }
if (!domainName(o.domainId)) { notCreated[cid] = setError("invalidForeignKey", "Domain does not exist.", ["domainId"]); continue; }
if (addressTaken(`${o.name}@${domainName(o.domainId)}`)) { notCreated[cid] = setError("primaryKeyViolation", "An account or alias with this email address already exists."); continue; }
const refused = grantRefused(o.roles);
if (refused) { notCreated[cid] = setError("forbidden", refused); continue; }
const password = Object.values((o.credentials as Obj) ?? {})[0] as Obj | undefined;
const weak = password ? weakPassword(password.secret) : null;
if (weak) { notCreated[cid] = setError("invalidProperties", weak, ["secret"]); continue; }
const id = `u${counter++}`;
accounts.push({ memberGroupIds: {}, aliases: {}, quotas: {}, permissions: { "@type": "Inherit" }, ...o, id, memberTenantId: null, usedDiskQuota: 0, createdAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), locale: opts.locale, timeZone: null });
created[cid] = { id, emailAddress: `${o.name}@${domainName(o.domainId)}` };
}
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
demand("sysAccountUpdate");
const target = accounts.find((x) => x.id === id);
if (!target) { notUpdated[id] = setError("notFound", "Account not found."); continue; }
const patch = raw as Obj;
const next = structuredClone(target);
let failure: Obj | null = null;
for (const [path, value] of Object.entries(patch)) {
if (path === "id" || path === "@type" || path === "usedDiskQuota" || path === "emailAddress") { failure = setError("invalidProperties", `Property ${path} cannot be changed.`, [path]); break; }
if (path.endsWith("/secret")) {
const weak = weakPassword(value);
if (weak) { failure = setError("invalidProperties", weak, ["secret"]); break; }
}
if (path.startsWith("credentials/") && value && typeof value === "object") {
const weak = weakPassword((value as Obj).secret);
if (weak) { failure = setError("invalidProperties", weak, ["secret"]); break; }
}
setPointer(next, path, value);
}
if (!failure && ("roles" in patch || "permissions" in patch)) {
const refused = grantRefused(next.roles);
if (refused) failure = setError("forbidden", refused);
}
if (!failure) {
for (const al of Object.values((next.aliases as Obj) ?? {})) {
const address = `${(al as Obj).name}@${domainName((al as Obj).domainId)}`;
if (!domainName((al as Obj).domainId)) { failure = setError("invalidForeignKey", "Domain does not exist.", ["aliases"]); break; }
if (addressTaken(address, id)) { failure = setError("primaryKeyViolation", "An account or alias with this email address already exists."); break; }
}
}
if (failure) { notUpdated[id] = failure; continue; }
// Secrets are stored hashed; the mock just stops echoing them.
for (const c of Object.values((next.credentials as Obj) ?? {})) (c as Obj).secret = MASKED;
Object.assign(target, next);
updated[id] = null;
}
for (const id of (a.destroy as string[]) ?? []) {
demand("sysAccountDestroy");
const i = accounts.findIndex((x) => x.id === id);
if (i < 0) { notDestroyed[id] = setError("notFound", "Account not found."); continue; }
if (accounts[i]!["@type"] === "Group" && accounts.some((x) => (x.memberGroupIds as Obj | undefined)?.[id])) {
notDestroyed[id] = { ...setError("objectIsLinked", "Group still has members."), linkedObjects: {} };
continue;
}
accounts.splice(i, 1);
destroyed.push(id);
}
return { accountId: opts.accountId, oldState: "1", newState: "2", created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}), ...(Object.keys(notUpdated).length ? { notUpdated } : {}), ...(Object.keys(notDestroyed).length ? { notDestroyed } : {}) };
},
"x:Domain/get": get(domains, "sysDomainGet"),
"x:Domain/query": query(() => domains, "sysDomainQuery", (o, f) => matchText(o, f.text) && matchText(o, f.name)),
"x:Role/get": get(roles, "sysRoleGet"),
"x:Role/query": query(() => roles, "sysRoleQuery", (o, f) => matchText(o, f.description)),
};
return { handlers, permissions: [...permissions], accounts };
}
function flags(names: string[]): Obj {
return Object.fromEntries(names.map((n) => [n, true]));
}
function splitAddress(address: string): [string, string] {
const at = address.lastIndexOf("@");
return at < 0 ? [address, "example.com"] : [address.slice(0, at), address.slice(at + 1)];
}
/**
* Apply one JMAP patch entry. A path walks into nested objects; `null` at the
* end removes the key, which is how an alias or a quota is taken away.
*/
function setPointer(obj: Obj, path: string, value: unknown): void {
const parts = path.split("/").map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"));
let node = obj;
for (const part of parts.slice(0, -1)) {
if (!node[part] || typeof node[part] !== "object") node[part] = {};
node = node[part] as Obj;
}
const last = parts[parts.length - 1]!;
if (value === null) delete node[last];
else node[last] = value;
}
+15 -7
View File
@@ -9,6 +9,7 @@ import { signedMessage, type SIGNED_MESSAGES } from "./signedMessages.js";
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
import { createDirectory, mockRole } from "./directory.js";
const PORT = Number(process.env.MOCK_PORT ?? 8788);
/**
@@ -885,6 +886,15 @@ function matchSubmissionFilter(sub: Obj, f: Obj | undefined): boolean {
return true;
}
/** Who the demo user is, for administration. See mock/directory.ts. */
const directory = createDirectory({
accountId: ACCOUNT,
user: USER,
locale: MOCK_LOCALE,
role: mockRole(process.env.MOCK_ROLE),
fail: (type, description) => new MethodError(type, description),
});
const handlers: Record<string, Handler> = {
// 0.16 exposes the account locale here, under a permission ordinary users
// actually have (unlike x:Account below, which needs sysAccountGet).
@@ -893,12 +903,10 @@ const handlers: Record<string, Handler> = {
const list = ids.filter((id) => id === "singleton").map((id) => ({ id, locale: MOCK_LOCALE, timeZone: null, description: null }));
return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => pick(x, a.properties as string[] | null)), notFound: ids.filter((id) => id !== "singleton") };
},
// Stalwart's directory extension - the client reads the account locale from here.
"x:Account/get": (a) => {
const ids = (a.ids as string[] | null) ?? [ACCOUNT];
const list = ids.filter((id) => id === ACCOUNT).map((id) => ({ id, name: USER, locale: MOCK_LOCALE, timeZone: null }));
return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => id !== ACCOUNT) };
},
// Stalwart's directory registry: accounts, domains and roles, behind the
// same permissions as the real thing. The locale fallback reads x:Account
// too, and is refused here exactly when a real server would refuse it.
...directory.handlers,
"Mailbox/get": (a) => hideShareWithUnlessAsked(a, genericGet(mailboxes)(a) as { list: Obj[] }) as never,
"Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; },
"Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
@@ -1413,7 +1421,7 @@ export const server = createServer(async (req, res) => {
// 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 }));
return res.end(JSON.stringify({ permissions: directory.permissions, edition: "oss", locale: MOCK_LOCALE }));
}
if (url.pathname === "/jmap/" && req.method === "POST") {
const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][]; using?: string[] };
+27
View File
@@ -0,0 +1,27 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { interpretServerAccount, normalizePermission } from "./upstream.js";
/**
* `/api/account` is the only place Stalwart lists what an account may do, and
* ihasmail used to read the edition out of it and throw the rest away.
*/
test("the account's permissions are kept alongside the edition", () => {
const info = interpretServerAccount({ edition: "enterprise", permissions: ["sysAccountGet", "sysAccountQuery"], locale: "en_US" });
assert.deepEqual(info, { edition: "enterprise", permissions: ["sysAccountGet", "sysAccountQuery"] });
});
test("permission names read the same whichever case the server uses", () => {
// The source serialises camelCase; the documentation shows kebab-case.
assert.equal(normalizePermission("sys-account-get"), "sysAccountGet");
assert.equal(normalizePermission("sysAccountGet"), "sysAccountGet");
assert.equal(normalizePermission("sys-dkim-signature-create"), "sysDkimSignatureCreate");
assert.deepEqual(interpretServerAccount({ permissions: ["sys-account-get", "sysAccountGet"] }).permissions, ["sysAccountGet"]);
});
test("a body without a usable list yields no permissions rather than failing", () => {
assert.deepEqual(interpretServerAccount({ edition: "oss" }), { edition: "oss", permissions: [] });
assert.deepEqual(interpretServerAccount({ permissions: "sysAccountGet" }), { edition: null, permissions: [] });
assert.deepEqual(interpretServerAccount({ permissions: [1, null, "sysDomainGet"] }).permissions, ["sysDomainGet"]);
assert.deepEqual(interpretServerAccount(null), { edition: null, permissions: [] });
});
+41 -10
View File
@@ -132,11 +132,21 @@ export interface AccountInfo {
locale: string | null;
/** "oss" | "community" | "enterprise", where the server reports it. */
edition: string | null;
/**
* The account's effective permissions, as Stalwart reports them for the
* credential in use. Empty when the server would not say.
*
* Carried to the browser so it can offer only what the account may do --
* administration above all. It is never a grant: Stalwart checks every call
* it is sent, and a list that is stale or wrong costs a refused request, not
* access.
*/
permissions: string[];
}
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
const INFO_CACHE_MS = 30 * 60_000;
const EMPTY_INFO: AccountInfo = { locale: null, edition: null };
const EMPTY_INFO: AccountInfo = { locale: null, edition: null, permissions: [] };
/**
* glibc modifiers that name a script rather than a dialect or a currency:
@@ -226,7 +236,7 @@ async function fetchAccountInfo(authorization: string, session: UpstreamSession)
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");
return { locale: localeOf(settings) ?? localeOf(account), edition: null };
return { locale: localeOf(settings) ?? localeOf(account), edition: null, permissions: [] };
}
function localeOf(call: [string, Record<string, unknown>, string] | undefined): string | null {
@@ -237,30 +247,51 @@ function localeOf(call: [string, Record<string, unknown>, string] | undefined):
}
/**
* Which edition the server is running. Stalwart deliberately does not publish
* its version number to clients, but 0.16 does report its edition here.
* Permission names in the form the source serialises them.
*
* Stalwart 0.16 builds `/api/account`'s list from the same enum as everything
* else, which serialises as camelCase (`sysAccountGet`). Its documentation and
* OpenAPI example show kebab-case (`sys-account-get`) instead. Until a live
* server settles which is true, both are read as the one form, so a check
* written against `sysAccountGet` holds either way.
*/
async function fetchEdition(authorization: string, base: string): Promise<string | null> {
export function normalizePermission(name: string): string {
return name.includes("-") ? name.replace(/-([a-z0-9])/g, (_m, c: string) => c.toUpperCase()) : name;
}
/**
* What the server says about the signed-in account: its edition and its
* effective permissions. Stalwart deliberately does not publish its version
* number to clients, but 0.16 reports both of these here.
*/
async function fetchServerAccount(authorization: string, base: string): Promise<Pick<AccountInfo, "edition" | "permissions">> {
try {
const res = await fetch(`${base}/api/account`, {
headers: { authorization, accept: "application/json" },
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (!res.ok) return null;
const body = (await res.json()) as { edition?: unknown };
return typeof body.edition === "string" ? body.edition : null;
if (!res.ok) return { edition: null, permissions: [] };
return interpretServerAccount(await res.json());
} catch {
return null;
return { edition: null, permissions: [] };
}
}
export function interpretServerAccount(body: unknown): Pick<AccountInfo, "edition" | "permissions"> {
const b = (body ?? {}) as { edition?: unknown; permissions?: unknown };
const permissions = Array.isArray(b.permissions)
? [...new Set(b.permissions.filter((p): p is string => typeof p === "string").map(normalizePermission))]
: [];
return { edition: typeof b.edition === "string" ? b.edition : null, permissions };
}
export async function getAccountInfo(sessionId: string, authorization: string, session: UpstreamSession): Promise<AccountInfo> {
const cached = infoCache.get(sessionId);
if (cached && Date.now() - cached.fetchedAt < INFO_CACHE_MS) return cached.info;
let info = EMPTY_INFO;
try {
info = await fetchAccountInfo(authorization, session);
info = { ...info, edition: await fetchEdition(authorization, session.baseUrl) };
info = { ...info, ...(await fetchServerAccount(authorization, session.baseUrl)) };
} catch {
/* all of this is a nicety - never fail the session over it */
}