server/account-signin.ts
Rama main · réplica de lectura del repositorio original.
// Email sign-in through the OpenMausBot control plane, or through the server's own mail server when OMB_SIGNIN_SMTP_URL is
// set: the code exchange is server-side, so a browser only talks to this server, and a server with an empty allow-list
// does not expose the routes. The result is an ordinary local session.
import { resolveCompanionControlPlaneURL } from "../electron/companion-account-service.mjs";
import { ControlPlaneError, createControlPlaneClient, normalizeAccountEmail, type ControlPlaneClient } from "../electron/control-plane-client.mjs";
import type { Scope } from "./sessions.ts";
import { createSignInMail, SignInMailError, type MailTransport, type SignInMail } from "./sign-in-mail.ts";
import { userError, type UserError } from "./user-errors.ts";
/** Who may sign in. `a@b.com` is that address; `@b.com` is everyone at b.com. */
export interface SignInAllowList {
admins: string[];
members: string[];
}
/** Commas, spaces or newlines between entries; case does not matter. */
export function parseAllowList(value: string | undefined | null): string[] {
return (value ?? "")
.split(/[,\s]+/)
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
}
export function allowedScopes(rawEmail: string, list: SignInAllowList): Scope[] | null {
const email = rawEmail.trim().toLowerCase();
if (!email.includes("@")) return null;
const matches = (entry: string) => (entry.startsWith("@") ? email.endsWith(entry) && email.length > entry.length : entry === email);
if (list.admins.some(matches)) return ["admin", "client"];
if (list.members.some(matches)) return ["client"];
return null;
}
export function signInEnabled(list: SignInAllowList): boolean {
return list.admins.length + list.members.length > 0;
}
export type SignInFailure = { ok: false; status: number } & UserError;
export interface EmailSignIn {
enabled(): boolean;
/** `target`: the host the code signs in to, for the email's subject. */
start(email: string, target?: string | null): Promise<{ ok: true } | SignInFailure>;
verify(email: string, code: string): Promise<{ ok: true; email: string; userId: string; scopes: Scope[] } | SignInFailure>;
}
const NOT_WELCOME: SignInFailure = { ok: false, status: 403, ...userError("serverError.email.notAllowed") };
export function createEmailSignIn(options: {
allow: SignInAllowList | (() => SignInAllowList);
env?: NodeJS.ProcessEnv;
fetchImpl?: typeof fetch;
client?: ControlPlaneClient;
mailTransport?: MailTransport;
}): EmailSignIn {
const env = options.env ?? process.env;
const allow = () => (typeof options.allow === "function" ? options.allow() : options.allow);
const smtpUrl = env.OMB_SIGNIN_SMTP_URL?.trim();
if (smtpUrl) {
return mailedSignIn(allow, createSignInMail({ url: smtpUrl, from: env.OMB_SIGNIN_SMTP_FROM,
...(options.mailTransport ? { transport: options.mailTransport } : {}) }));
}
let client: ControlPlaneClient | null = options.client ?? null;
const controlPlane = (): ControlPlaneClient => {
if (client) return client;
const url = resolveCompanionControlPlaneURL({ isPackaged: true, environment: env });
if (!url) throw new Error("OMB_CONTROL_PLANE_URL is set but is not an https address");
client = createControlPlaneClient({ baseURL: url, fetchImpl: options.fetchImpl });
return client;
};
return {
enabled: () => signInEnabled(allow()),
async start(rawEmail) {
const email = normalizeAccountEmail(rawEmail);
if (!email) return { ok: false, status: 400, ...userError("serverError.email.invalid") };
if (!allowedScopes(email, allow())) return NOT_WELCOME;
try {
await controlPlane().requestOTP(email);
} catch (error) {
return unavailable(error);
}
return { ok: true };
},
async verify(rawEmail, code) {
const email = normalizeAccountEmail(rawEmail);
if (!email || !allowedScopes(email, allow())) return NOT_WELCOME;
let verified: { accountToken: string; user: { id: string; email: string } };
try {
verified = await controlPlane().verifyOTP(email, code);
} catch (error) {
// The client refuses a malformed code before any request (status 0);
// the control plane's own "invalid_otp" is a wrong or expired code.
if (error instanceof ControlPlaneError && error.code === "invalid_otp" && !error.status) {
return { ok: false, status: 400, ...userError("serverError.email.codeFormat") };
}
if (error instanceof ControlPlaneError && error.status === 429) {
return { ok: false, status: 429, ...userError("serverError.email.tooMany") };
}
if (error instanceof ControlPlaneError && (error.status === 400 || error.status === 401 || error.status === 403)) {
return { ok: false, status: 401, ...userError("serverError.email.codeWrong") };
}
return unavailable(error);
}
void controlPlane()
.signOut(verified.accountToken)
.catch(() => undefined);
const scopes = allowedScopes(verified.user.email, allow());
if (!scopes) return NOT_WELCOME;
return { ok: true, email: verified.user.email, userId: verified.user.id, scopes };
},
};
}
function mailedSignIn(allow: () => SignInAllowList, mail: SignInMail): EmailSignIn {
return {
enabled: () => signInEnabled(allow()),
async start(rawEmail, target) {
const email = normalizeAccountEmail(rawEmail);
if (!email) return { ok: false, status: 400, ...userError("serverError.email.invalid") };
if (!allowedScopes(email, allow())) return NOT_WELCOME;
try {
if ((await mail.send(email, target)) === "tooSoon") return { ok: false, status: 429, ...userError("serverError.email.tooMany") };
} catch (error) {
console.warn(`sign-in code not sent: ${error instanceof Error ? error.message : String(error)}`);
return { ok: false, status: 502, ...userError("serverError.email.unavailable", { detail: error instanceof SignInMailError ? error.message : "the mail server refused it" }) };
}
return { ok: true };
},
async verify(rawEmail, code) {
const email = normalizeAccountEmail(rawEmail);
if (!email || !allowedScopes(email, allow())) return NOT_WELCOME;
const checked = mail.check(email, code);
if (checked === "format") return { ok: false, status: 400, ...userError("serverError.email.codeFormat") };
if (checked === "tooMany") return { ok: false, status: 429, ...userError("serverError.email.tooMany") };
if (checked === "wrong") return { ok: false, status: 401, ...userError("serverError.email.codeWrong") };
const scopes = allowedScopes(email, allow());
return scopes ? { ok: true, email, userId: `email:${email}`, scopes } : NOT_WELCOME;
},
};
}
function unavailable(error: unknown): SignInFailure {
const detail = error instanceof ControlPlaneError ? `${error.code}${error.status ? ` (${error.status})` : ""}` : error instanceof Error ? error.message : String(error);
return { ok: false, status: 502, ...userError("serverError.email.unavailable", { detail }) };
}