Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 46 additions & 11 deletions src/chat/__tests__/email-login.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { revokeAllSessions } from "../../ui/session.ts";
import { clearRateLimits, handleEmailLogin, sanitizeLocalPart } from "../email-login.ts";
import { clearRateLimits, handleEmailLogin, sanitizeLocalPart, sendLoginEmail } from "../email-login.ts";
import { escapeHtml } from "../util/escape.ts";

const originalEnv = { ...process.env };
Expand Down Expand Up @@ -31,15 +31,15 @@ function makeRequest(body: Record<string, unknown>, ip = "127.0.0.1"): Request {
describe("handleEmailLogin", () => {
test("returns 200 with neutral response for valid email", async () => {
const req = makeRequest({ email: "owner@example.com" });
const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent");
const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent", "test.dev");
expect(res.status).toBe(200);
const data = (await res.json()) as { ok: boolean };
expect(data.ok).toBe(true);
});

test("returns 200 with neutral response for invalid email", async () => {
const req = makeRequest({ email: "wrong@example.com" });
const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent");
const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent", "test.dev");
expect(res.status).toBe(200);
const data = (await res.json()) as { ok: boolean };
expect(data.ok).toBe(true);
Expand All @@ -48,15 +48,15 @@ describe("handleEmailLogin", () => {
test("returns 200 with neutral response when OWNER_EMAIL is unset", async () => {
process.env.OWNER_EMAIL = undefined;
const req = makeRequest({ email: "someone@example.com" });
const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent");
const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent", "test.dev");
expect(res.status).toBe(200);
const data = (await res.json()) as { ok: boolean };
expect(data.ok).toBe(true);
});

test("returns 200 for missing email field", async () => {
const req = makeRequest({});
const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent");
const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent", "test.dev");
expect(res.status).toBe(200);
});

Expand All @@ -69,31 +69,31 @@ describe("handleEmailLogin", () => {
},
body: "not json",
});
const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent");
const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent", "test.dev");
expect(res.status).toBe(200);
});

test("rate limits to 1 per 60 seconds per IP", async () => {
// First request succeeds (triggers rate limit regardless of match)
const req1 = makeRequest({ email: "owner@example.com" }, "10.0.0.1");
const res1 = await handleEmailLogin(req1, "http://localhost:6666", "test-agent");
const res1 = await handleEmailLogin(req1, "http://localhost:6666", "test-agent", "test.dev");
expect(res1.status).toBe(200);

// Second request from same IP within 60 seconds
const req2 = makeRequest({ email: "owner@example.com" }, "10.0.0.1");
const res2 = await handleEmailLogin(req2, "http://localhost:6666", "test-agent");
const res2 = await handleEmailLogin(req2, "http://localhost:6666", "test-agent", "test.dev");
expect(res2.status).toBe(200);
// Still returns ok (neutral), but it was rate-limited internally

// Different IP is not rate-limited
const req3 = makeRequest({ email: "owner@example.com" }, "10.0.0.2");
const res3 = await handleEmailLogin(req3, "http://localhost:6666", "test-agent");
const res3 = await handleEmailLogin(req3, "http://localhost:6666", "test-agent", "test.dev");
expect(res3.status).toBe(200);
});

test("normalizes email comparison to lowercase", async () => {
const req = makeRequest({ email: "OWNER@Example.COM" });
const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent");
const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent", "test.dev");
expect(res.status).toBe(200);
const data = (await res.json()) as { ok: boolean };
expect(data.ok).toBe(true);
Expand Down Expand Up @@ -152,3 +152,38 @@ describe("escapeHtml", () => {
expect(escapeHtml("my-agent")).toBe("my-agent");
});
});

describe("sendLoginEmail", () => {
test("throws when Resend API returns an error response", async () => {
mock.module("resend", () => ({
Resend: class {
emails = {
send: async () => ({ error: { message: "domain not verified" } }),
};
},
}));
process.env.RESEND_API_KEY = "re_test_key";

await expect(sendLoginEmail("owner@example.com", "http://localhost/magic", "Phantom", "test.dev")).rejects.toThrow(
"Resend API error: domain not verified",
);
});

test("uses domain parameter for the sender domain", async () => {
let capturedFrom = "";
mock.module("resend", () => ({
Resend: class {
emails = {
send: async (opts: { from: string }) => {
capturedFrom = opts.from;
return { data: { id: "ok" }, error: null };
},
};
},
}));
process.env.RESEND_API_KEY = "re_test_key";

await sendLoginEmail("owner@example.com", "http://localhost/magic", "Phantom", "mycompany.com");
expect(capturedFrom).toContain("mycompany.com");
});
});
23 changes: 18 additions & 5 deletions src/chat/email-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ export function clearRateLimits(): void {
rateLimitMap.clear();
}

export async function handleEmailLogin(req: Request, publicUrl: string, agentName: string): Promise<Response> {
export async function handleEmailLogin(
req: Request,
publicUrl: string,
agentName: string,
domain: string,
): Promise<Response> {
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";

// Rate limit check
Expand Down Expand Up @@ -75,7 +80,7 @@ export async function handleEmailLogin(req: Request, publicUrl: string, agentNam
const { magicToken } = createSession();
const magicUrl = `${publicUrl}/ui/login?magic=${encodeURIComponent(magicToken)}&redirect=%2Fchat`;

await sendLoginEmail(ownerEmail, magicUrl, agentName);
await sendLoginEmail(ownerEmail, magicUrl, agentName, domain);

// Evict expired entries to prevent unbounded map growth
if (rateLimitMap.size > 1000) {
Expand All @@ -88,7 +93,12 @@ export async function handleEmailLogin(req: Request, publicUrl: string, agentNam
return Response.json({ ok: true });
}

export async function sendLoginEmail(email: string, magicLink: string, agentName: string): Promise<void> {
export async function sendLoginEmail(
email: string,
magicLink: string,
agentName: string,
domain: string,
): Promise<void> {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
console.log(`[email-login] No RESEND_API_KEY set. Magic link for ${email}:`);
Expand All @@ -99,7 +109,6 @@ export async function sendLoginEmail(email: string, magicLink: string, agentName
try {
const { Resend } = await import("resend");
const resend = new Resend(apiKey);
const domain = process.env.PHANTOM_EMAIL_DOMAIN ?? "ghostwright.dev";
const safeDisplayName = sanitizeHeader(agentName);
const safeLocalPart = sanitizeLocalPart(agentName);
const safeHtmlName = escapeHtml(agentName);
Expand All @@ -112,14 +121,18 @@ export async function sendLoginEmail(email: string, magicLink: string, agentName
<p style="color:#888;font-size:13px;">If you did not request this, you can ignore this email.</p>
`.trim();

await resend.emails.send({
const { error } = await resend.emails.send({
from,
to: [email],
subject: `${safeDisplayName} - Login link`,
html: htmlBody,
text: `Sign in to ${safeDisplayName}: ${magicLink}\n\nThis link expires in 10 minutes and can be used once.`,
});

if (error) {
throw new Error(`Resend API error: ${error.message}`);
}

console.log(`[email-login] Sent login email to ${email}`);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
Expand Down
7 changes: 5 additions & 2 deletions src/chat/first-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ function ensureFirstRunRow(db: Database): void {
db.run("INSERT OR IGNORE INTO first_run_state (id) VALUES (1)");
}

export async function handleFirstRun(db: Database, config: { name: string; public_url?: string }): Promise<void> {
export async function handleFirstRun(
db: Database,
config: { name: string; public_url?: string; domain?: string },
): Promise<void> {
const ownerEmail = process.env.OWNER_EMAIL;
if (!ownerEmail) {
console.warn("[first-run] No OWNER_EMAIL set and no Slack configured.");
Expand Down Expand Up @@ -53,7 +56,7 @@ export async function handleFirstRun(db: Database, config: { name: string; publi
db.run("UPDATE first_run_state SET bootstrap_magic_hash = ? WHERE id = 1", [hash]);

try {
await sendLoginEmail(ownerEmail, magicUrl, config.name);
await sendLoginEmail(ownerEmail, magicUrl, config.name, config.domain ?? "ghostwright.dev");
db.run("UPDATE first_run_state SET email_sent_at = datetime('now') WHERE id = 1");
console.log(`[first-run] Login email sent to ${ownerEmail}`);
} catch (err: unknown) {
Expand Down
2 changes: 1 addition & 1 deletion src/core/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp

if (url.pathname === "/login/email" && req.method === "POST") {
const publicUrl = config.public_url ?? `http://localhost:${config.port}`;
return handleEmailLogin(req, publicUrl, config.name);
return handleEmailLogin(req, publicUrl, config.name, config.domain ?? "ghostwright.dev");
}

// Public PWA/SW-scoped mirror of the operator avatar. Service
Expand Down
Loading