From d540cfe9c370359abd22669111fa56c56e70ffcf Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Sat, 29 Aug 2026 11:47:27 +0300 Subject: [PATCH] web: add auth to _internal/email-did route Signed-off-by: Anirudh Oppiliappan --- web/src/routes/_internal/email-did/+server.ts | 22 +++---- .../email-did/email-did.server.test.ts | 63 ++++++++++++------- 2 files changed, 51 insertions(+), 34 deletions(-) diff --git a/web/src/routes/_internal/email-did/+server.ts b/web/src/routes/_internal/email-did/+server.ts index e281a5d12..ad5034a72 100644 --- a/web/src/routes/_internal/email-did/+server.ts +++ b/web/src/routes/_internal/email-did/+server.ts @@ -1,23 +1,26 @@ import { json } from "@sveltejs/kit"; import type { RequestHandler } from "./$types"; +import { CURRENT_DID_KEY } from "$lib/auth/accounts"; // the verified email → did mapping lives in KV, populated once at cutover by -// cmd/email-did-migrate. plain git emails resolve to the signup did; the -// `__primary:` keys hold the reverse (did → primary email). best-effort: a -// missing binding (adapter-node, docker-compose), a missing key, or a kv -// error answers null and the frontend keeps the mailto link. +// cmd/email-did-migrate. plain git emails resolve to the signup did. +// best-effort: a missing binding (adapter-node, docker-compose), a missing +// key, or a kv error answers null and the frontend keeps the mailto link. const MAX_LOOKUPS = 250; export const GET: RequestHandler = async (event) => { + const did = event.cookies.get(CURRENT_DID_KEY); + if (!did || !did.startsWith("did:")) { + return json({ error: "unauthorized" }, { status: 401 }); + } + const kv = event.platform?.env?.EMAIL_DID; if (!kv) return json({}); // repeated params rather than a csv: query decoding happens before any // split could, and emails may legally contain commas const emails = event.url.searchParams.getAll("emails").filter(Boolean).slice(0, MAX_LOOKUPS); - const dids = event.url.searchParams.getAll("primary_for").filter(Boolean).slice(0, MAX_LOOKUPS); - - if (emails.length === 0 && dids.length === 0) return json({}); + if (emails.length === 0) return json({}); // lookups are case-insensitive, so read each distinct lowercased address // once even when the caller mixes cases; every original-case key keeps its @@ -31,10 +34,5 @@ export const GET: RequestHandler = async (event) => { const result: Record = {}; for (const email of emails) result[email] = byLower.get(email.toLowerCase()) ?? null; - await Promise.all( - dids.map(async (did) => { - result[did] = await kv.get(`__primary:${did}`).catch(() => null); - }) - ); return json(result); }; diff --git a/web/src/routes/_internal/email-did/email-did.server.test.ts b/web/src/routes/_internal/email-did/email-did.server.test.ts index f02ce07bf..575b727c8 100644 --- a/web/src/routes/_internal/email-did/email-did.server.test.ts +++ b/web/src/routes/_internal/email-did/email-did.server.test.ts @@ -1,20 +1,50 @@ import { describe, expect, it, vi } from "vitest"; import { GET } from "./+server"; +import { CURRENT_DID_KEY } from "$lib/auth/accounts"; -// the handler only needs url + platform from the request event -const event = (url: string, kv?: { get: (key: string) => Promise }) => - ({ +const event = ( + url: string, + opts: { kv?: { get: (key: string) => Promise }; did?: string | null } = {} +) => { + const { kv, did = "did:plc:alice" } = opts; + return { url: new URL(url), - platform: kv ? { env: { EMAIL_DID: { get: kv.get } } } : {} - }) as Parameters[0]; + platform: kv ? { env: { EMAIL_DID: { get: kv.get } } } : {}, + cookies: { get: (key: string) => (key === CURRENT_DID_KEY ? (did ?? undefined) : undefined) } + } as unknown as Parameters[0]; +}; describe("GET /_internal/email-did", () => { + it("rejects anonymous callers without touching kv", async () => { + const get = vi.fn(async (key: string) => "did:plc:alice"); + const res = await GET( + event("http://localhost/_internal/email-did?emails=alice%40example.com", { + kv: { get }, + did: null + }) + ); + expect(res.status).toBe(401); + expect(get).not.toHaveBeenCalled(); + }); + + it("rejects a session cookie that is not a did", async () => { + const get = vi.fn(async (key: string) => "did:plc:alice"); + const res = await GET( + event("http://localhost/_internal/email-did?emails=alice%40example.com", { + kv: { get }, + did: "not-a-did" + }) + ); + expect(res.status).toBe(401); + expect(get).not.toHaveBeenCalled(); + }); + it("resolves emails through kv with lowercased keys", async () => { const get = vi.fn(async (key: string) => key === "alice@example.com" ? "did:plc:alice" : null ); const res = await GET( - event("http://localhost/_internal/email-did?emails=Alice%40Example.com", { get }) + event("http://localhost/_internal/email-did?emails=Alice%40Example.com", { kv: { get } }) ); expect(get).toHaveBeenCalledWith("alice@example.com"); await expect(res.json()).resolves.toEqual({ "Alice@Example.com": "did:plc:alice" }); @@ -23,28 +53,17 @@ describe("GET /_internal/email-did", () => { it("maps missing keys to null", async () => { const get = vi.fn(async () => null); const res = await GET( - event("http://localhost/_internal/email-did?emails=nobody%40example.com", { get }) + event("http://localhost/_internal/email-did?emails=nobody%40example.com", { kv: { get } }) ); await expect(res.json()).resolves.toEqual({ "nobody@example.com": null }); }); - it("resolves primary emails for dids under the __primary: prefix", async () => { - const get = vi.fn(async (key: string) => - key === "__primary:did:plc:alice" ? "alice@example.com" : null - ); - const res = await GET( - event("http://localhost/_internal/email-did?primary_for=did%3Aplc%3Aalice", { get }) - ); - expect(get).toHaveBeenCalledWith("__primary:did:plc:alice"); - await expect(res.json()).resolves.toEqual({ "did:plc:alice": "alice@example.com" }); - }); - it("keeps a plus-address round-trip through query decoding", async () => { const get = vi.fn(async (key: string) => key === "a+b@example.com" ? "did:plc:plus" : null ); const res = await GET( - event("http://localhost/_internal/email-did?emails=a%2Bb%40example.com", { get }) + event("http://localhost/_internal/email-did?emails=a%2Bb%40example.com", { kv: { get } }) ); expect(get).toHaveBeenCalledWith("a+b@example.com"); await expect(res.json()).resolves.toEqual({ "a+b@example.com": "did:plc:plus" }); @@ -60,7 +79,7 @@ describe("GET /_internal/email-did", () => { it("answers an empty map for empty params", async () => { const get = vi.fn(async () => null); - const res = await GET(event("http://localhost/_internal/email-did", { get })); + const res = await GET(event("http://localhost/_internal/email-did", { kv: { get } })); expect(get).not.toHaveBeenCalled(); await expect(res.json()).resolves.toEqual({}); }); @@ -68,7 +87,7 @@ describe("GET /_internal/email-did", () => { it("reads a mixed-case duplicate email once and answers every case", async () => { const get = vi.fn(async (key: string) => (key === "a@b.com" ? "did:plc:a" : null)); const res = await GET( - event("http://localhost/_internal/email-did?emails=a%40b.com&emails=A%40B.com", { get }) + event("http://localhost/_internal/email-did?emails=a%40b.com&emails=A%40B.com", { kv: { get } }) ); expect(get).toHaveBeenCalledTimes(1); expect(get).toHaveBeenCalledWith("a@b.com"); @@ -83,7 +102,7 @@ describe("GET /_internal/email-did", () => { throw new Error("kv down"); }); const res = await GET( - event("http://localhost/_internal/email-did?emails=a%40b.com&emails=c%40b.com", { get }) + event("http://localhost/_internal/email-did?emails=a%40b.com&emails=c%40b.com", { kv: { get } }) ); expect(res.status).toBe(200); await expect(res.json()).resolves.toEqual({ "a@b.com": null, "c@b.com": null }); -- 2.51.2