A narrow route to another signed-in account, for its push subscription #52

Merged
jcoffey-dev merged 1 commits from feat/other-accounts-push into main 2026-10-06 03:27:03 +00:00
5 changed files with 174 additions and 10 deletions

No files matched your search

+36
View File
@@ -17,6 +17,8 @@ process.env.MOCK_SECOND_USER = "[email protected]";
process.env.MOCK_SECOND_PASS = "second-password";
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-accounts";
// Every test here signs in several times from one address
process.env.LOGIN_RATE_LIMIT = "100";
const mock = await import("./mock/index.js");
const { createApp } = await import("./app.js");
@@ -154,3 +156,37 @@ test("inbuxa MA-8: the accounts not in front report their Inbox unread count", a
assert.equal(other.id, listed.id);
assert.equal(typeof other.unread, "number", JSON.stringify(res.body));
});
test("inbuxa MA-8: only push, mailboxes and marking mail reach an account not in front", async () => {
const { otherAccountCallAllowed } = await import("./app.js");
assert.equal(otherAccountCallAllowed(["PushSubscription/get", { ids: null }, "0"]), true);
assert.equal(otherAccountCallAllowed(["Mailbox/get", { accountId: "a" }, "0"]), true);
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { "keywords/$seen": true } } }, "0"]), true);
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { mailboxIds: { arch: true } } } }, "0"]), true);
// Anything else is refused
assert.equal(otherAccountCallAllowed(["Email/get", { accountId: "a" }, "0"]), false);
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", destroy: ["m1"] }, "0"]), false);
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", create: { x: {} } }, "0"]), false);
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { subject: "x" } } }, "0"]), false);
assert.equal(otherAccountCallAllowed(["EmailSubmission/set", {}, "0"]), false);
const b = new Browser();
await b.signIn("[email protected]", "first-password");
const added = await b.signIn("[email protected]", "second-password", true);
assert.equal(added.status, 200, JSON.stringify(added.body));
const other = (await b.accounts()).find((a) => !a.front)!;
const ok = await b.call(`/api/auth/accounts/${other.id}/jmap`, {
method: "POST",
body: { using: ["urn:ietf:params:jmap:core"], methodCalls: [["PushSubscription/get", { ids: null }, "0"]] },
});
assert.equal(ok.status, 200, JSON.stringify(ok.body));
assert.equal(ok.body.methodResponses[0][0], "PushSubscription/get");
const refused = await b.call(`/api/auth/accounts/${other.id}/jmap`, {
method: "POST",
body: { using: [], methodCalls: [["Email/get", { accountId: "x", ids: null }, "0"]] },
});
assert.equal(refused.status, 403);
// The account in front isn't reached this way, nor a session not held here
const front = (await b.accounts()).find((a) => a.front)!;
assert.equal((await b.call(`/api/auth/accounts/${front.id}/jmap`, { method: "POST", body: { methodCalls: [] } })).status, 404);
});
+62
View File
@@ -363,6 +363,35 @@ function liveOthers(c: Context): { cookie: string; session: LiveSession }[] {
return out;
}
/*
* inbuxa MA-8: what may be asked of a signed-in account that isn't in front.
*
* Its push subscription has to be registered, verified and renewed through
* its own session -- a JMAP push subscription belongs to whoever signs the
* request -- and a notification's Archive and Mark read act on its mail. Those
* four methods, and for Email/set only changes to keywords and mailboxes: the
* browser already holds the session, so this reaches nothing new, but it is
* kept to what the notifications need.
*/
const OTHER_ACCOUNT_METHODS = new Set(["PushSubscription/get", "PushSubscription/set", "Mailbox/get", "Email/set"]);
export function otherAccountCallAllowed(call: unknown): boolean {
if (!Array.isArray(call) || call.length !== 3) return false;
const [method, args] = call as [unknown, unknown, unknown];
if (typeof method !== "string" || !OTHER_ACCOUNT_METHODS.has(method)) return false;
if (typeof args !== "object" || args === null) return false;
if (method !== "Email/set") return true;
const set = args as { create?: unknown; destroy?: unknown; update?: unknown };
if (set.create !== undefined || set.destroy !== undefined) return false;
if (typeof set.update !== "object" || set.update === null) return false;
return Object.values(set.update as Record<string, unknown>).every(
(patch) =>
typeof patch === "object" &&
patch !== null &&
Object.keys(patch).every((k) => k === "keywords" || k === "mailboxIds" || k.startsWith("keywords/") || k.startsWith("mailboxIds/")),
);
}
/** inbuxa MA-8: Inbox unread counts of accounts not in front, briefly kept. */
const UNREAD_CACHE_MS = 60_000;
const unreadCache = new Map<string, { unread: number | null; at: number }>();
@@ -806,6 +835,39 @@ export function createApp(basePath = config.basePath): Hono<Env> {
return c.json({ accounts: answers });
});
/* inbuxa MA-8: a narrow JMAP route to a signed-in account not in front; see OTHER_ACCOUNT_METHODS. */
api.post("/auth/accounts/:id/jmap", requireSession, async (c) => {
const other = liveOthers(c).find((o) => o.session.id === c.req.param("id"));
if (!other) return c.json({ error: "not_found" }, 404);
let body: { using?: unknown; methodCalls?: unknown };
try {
body = await c.req.json();
} catch {
return c.json({ error: "bad_request" }, 400);
}
const calls = body.methodCalls;
if (!Array.isArray(calls) || calls.length === 0 || calls.length > 16 || !calls.every(otherAccountCallAllowed)) {
return c.json({ error: "forbidden", message: "Only push subscriptions, mailboxes and marking mail can be reached in another account." }, 403);
}
const using = Array.isArray(body.using) ? body.using.filter((u): u is string => typeof u === "string") : [];
let session: LiveSession | null = other.session;
if (session.tokens && needsRefresh(session.tokens)) session = await refreshSession(other.cookie, session);
if (!session) return c.json({ error: "unauthenticated" }, 401);
try {
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
method: "POST",
headers: { authorization: session.authorization, "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ using, methodCalls: calls }),
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (res.status === 401 || res.status === 403) return c.json({ error: "unauthenticated" }, 401);
return c.json(await res.json(), res.ok ? 200 : 502);
} catch (err) {
return upstreamFailure(c, err);
}
});
/* inbuxa MA-B: bring another signed-in account to the front. */
api.post("/auth/accounts/:id/front", requireSession, async (c) => {
const frontCookie = getCookie(c, config.cookieName)!;
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { otherAccountCall } from "@/lib/notify/otherAccount";
import { listSubscriptions } from "@/lib/notify/webpush";
/** inbuxa MA-8: push calls made as an account that isn't in front. */
afterEach(() => vi.unstubAllGlobals());
function stub(response: unknown) {
const seen: { url: string; body: any }[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) });
return { ok: true, status: 200, json: async () => response, text: async () => JSON.stringify(response) } as Response;
}),
);
return seen;
}
describe("otherAccountCall", () => {
it("goes through that account's route and answers the method's result", async () => {
const seen = stub({ methodResponses: [["PushSubscription/get", { list: [{ id: "p1", deviceClientId: "d" }] }, "0"]] });
const subs = await listSubscriptions(otherAccountCall("sess-2"));
expect(subs.map((s) => s.id)).toEqual(["p1"]);
expect(seen[0]!.url).toContain("/api/auth/accounts/sess-2/jmap");
expect(seen[0]!.body.methodCalls[0][0]).toBe("PushSubscription/get");
expect(seen[0]!.body.using).toContain("urn:ietf:params:jmap:core");
});
it("turns a JMAP error into a thrown one", async () => {
stub({ methodResponses: [["error", { type: "forbidden" }, "0"]] });
await expect(listSubscriptions(otherAccountCall("sess-2"))).rejects.toThrow("forbidden");
});
});
+22
View File
@@ -0,0 +1,22 @@
/**
* inbuxa MA-8: JMAP calls made as a signed-in account that isn't in front,
* through the webmail server's narrow route for it
* (POST /api/auth/accounts/<sessionId>/jmap). The server allows only what
* notifications need: push subscriptions, mailboxes, and marking or filing
* mail.
*/
import { apiFetch, CAP } from "@/jmap/client";
import type { JmapCall } from "@/lib/notify/webpush";
export function otherAccountCall(sessionId: string): JmapCall {
return async <T,>(method: string, args: Record<string, unknown>, using: string[]): Promise<T> => {
const res = await apiFetch<{ methodResponses?: [string, unknown, string][] }>(
`/api/auth/accounts/${encodeURIComponent(sessionId)}/jmap`,
{ method: "POST", body: JSON.stringify({ using: [...new Set([CAP.core, ...using])], methodCalls: [[method, args, "0"]] }) },
);
const [name, out] = res.methodResponses?.[0] ?? [];
if (!name) throw new Error("The mail server sent no response.");
if (name === "error") throw new Error(String((out as { type?: string } | undefined)?.type ?? "error"));
return out as T;
};
}
+19 -10
View File
@@ -22,6 +22,15 @@ import type { GetResponse, Id, SetResponse } from "@/jmap/types";
import { isDeviceTrusted } from "@/lib/storage";
export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid";
/**
* Who a push call is made as (inbuxa MA-8). A JMAP push subscription belongs
* to whoever signs the request, so registering one for an account that isn't
* in front goes through that account's own session (lib/notify/otherAccount).
* Everything here defaults to the account in front.
*/
export type JmapCall = <T>(method: string, args: Record<string, unknown>, using: string[]) => Promise<T>;
export const frontCall: JmapCall = (method, args, using) => client.call(method, args, using);
export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush";
/**
@@ -285,13 +294,13 @@ export function needsRenewal(subs: JmapPushSubscription[], deviceId: string, now
return at - now <= RENEW_WITHIN_MS;
}
export async function listSubscriptions(): Promise<JmapPushSubscription[]> {
const res = await client.call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]);
export async function listSubscriptions(call: JmapCall = frontCall): Promise<JmapPushSubscription[]> {
const res = await call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]);
return res.list;
}
export async function createSubscription(body: Record<string, unknown>): Promise<Id | null> {
const res = await client.call<SetResponse<JmapPushSubscription>>(
export async function createSubscription(body: Record<string, unknown>, call: JmapCall = frontCall): Promise<Id | null> {
const res = await call<SetResponse<JmapPushSubscription>>(
"PushSubscription/set",
{ create: { s: body } },
[CAP.core, VAPID_CAP, EMAILPUSH_CAP],
@@ -307,16 +316,16 @@ export async function createSubscription(body: Record<string, unknown>): Promise
* Seven days is JMAP's ceiling and what Stalwart grants a new one; the server
* may shorten what is asked for, and whatever it keeps is what counts.
*/
export async function extendSubscription(id: Id, now: number = Date.now()): Promise<void> {
export async function extendSubscription(id: Id, now: number = Date.now(), call: JmapCall = frontCall): Promise<void> {
const expires = new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString().replace(/\.\d+Z$/, "Z");
const res = await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]);
const res = await call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]);
const err = res.notUpdated?.[id];
if (err) throw new PushSetError(String(err.type), String(err.description ?? err.type));
}
export async function destroySubscriptions(ids: Id[]): Promise<void> {
export async function destroySubscriptions(ids: Id[], call: JmapCall = frontCall): Promise<void> {
if (!ids.length) return;
await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]);
await call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]);
}
/**
@@ -353,8 +362,8 @@ export function rememberEndpoint(endpoint: string | null): void {
* the client echoes it. A subscription left unverified looks registered and is
* silent, which is the confusing failure worth being explicit about.
*/
export async function verifySubscription(id: Id, verificationCode: string): Promise<void> {
const res = await client.call<SetResponse<JmapPushSubscription>>(
export async function verifySubscription(id: Id, verificationCode: string, call: JmapCall = frontCall): Promise<void> {
const res = await call<SetResponse<JmapPushSubscription>>(
"PushSubscription/set",
{ update: { [id]: { verificationCode } } },
[CAP.core, VAPID_CAP],