From 0437ef7a56bae765b1edde58c6639e6836409778 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Tue, 25 Aug 2026 13:48:38 +0300 Subject: [PATCH] web: hydrate email commmit authors from verified email dids Adds an EMAIL_DID KV binding to Cloudflare and a read-only /_internal/email-did route, resolves emails and wires it into the commit list loader. Signed-off-by: Anirudh Oppiliappan --- web/src/app.d.ts | 2 + web/src/lib/api/emailDid.test.ts | 50 +++++++++ web/src/lib/api/emailDid.ts | 23 ++++ web/src/lib/api/repoIndex.test.ts | 105 +++++++++++++++++- web/src/lib/api/repoIndex.ts | 29 ++++- .../[handle]/[repo]/commits/[ref]/+page.ts | 10 +- web/src/routes/_internal/email-did/+server.ts | 40 +++++++ .../email-did/email-did.server.test.ts | 91 +++++++++++++++ web/wrangler.dev.jsonc | 6 + 9 files changed, 344 insertions(+), 12 deletions(-) create mode 100644 web/src/lib/api/emailDid.test.ts create mode 100644 web/src/lib/api/emailDid.ts create mode 100644 web/src/routes/_internal/email-did/+server.ts create mode 100644 web/src/routes/_internal/email-did/email-did.server.test.ts diff --git a/web/src/app.d.ts b/web/src/app.d.ts index d1ab4cd9..01d8faa0 100644 --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -16,6 +16,8 @@ declare global { // worker service bindings, only present when running on Cloudflare env?: { BOBBIN?: { fetch(request: Request): Promise }; + // verified email → did lookup, populated once by cmd/email-did-migrate + EMAIL_DID?: { get(key: string): Promise }; }; } } diff --git a/web/src/lib/api/emailDid.test.ts b/web/src/lib/api/emailDid.test.ts new file mode 100644 index 00000000..6b7cb153 --- /dev/null +++ b/web/src/lib/api/emailDid.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveEmailToDid } from "./emailDid"; + +describe("resolveEmailToDid", () => { + it("short-circuits on an empty list without fetching", async () => { + const fetchFn = vi.fn(); + await expect(resolveEmailToDid(fetchFn, [])).resolves.toEqual(new Map()); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("requests each email as an encoded repeated param", async () => { + const fetchFn = vi.fn(async () => new Response("{}")); + await resolveEmailToDid(fetchFn, ["alice@example.com", "a+b@example.com"]); + expect(fetchFn).toHaveBeenCalledWith( + "/_internal/email-did?emails=alice%40example.com&emails=a%2Bb%40example.com" + ); + }); + + it("keeps only emails that resolved to a did", async () => { + const fetchFn = vi.fn( + async () => + new Response( + JSON.stringify({ + "alice@example.com": "did:plc:alice", + "bob@example.com": null + }) + ) + ); + await expect( + resolveEmailToDid(fetchFn, ["alice@example.com", "bob@example.com"]) + ).resolves.toEqual(new Map([["alice@example.com", "did:plc:alice"]])); + }); + + it("returns an empty map when the route errors", async () => { + const fetchFn = vi.fn(async () => new Response("nope", { status: 500 })); + await expect(resolveEmailToDid(fetchFn, ["alice@example.com"])).resolves.toEqual(new Map()); + }); + + it("returns an empty map when the fetch rejects", async () => { + const fetchFn = vi.fn(async () => { + throw new TypeError("offline"); + }); + await expect(resolveEmailToDid(fetchFn, ["alice@example.com"])).resolves.toEqual(new Map()); + }); + + it("returns an empty map for a malformed body", async () => { + const fetchFn = vi.fn(async () => new Response("not json")); + await expect(resolveEmailToDid(fetchFn, ["alice@example.com"])).resolves.toEqual(new Map()); + }); +}); diff --git a/web/src/lib/api/emailDid.ts b/web/src/lib/api/emailDid.ts new file mode 100644 index 00000000..4f2c800b --- /dev/null +++ b/web/src/lib/api/emailDid.ts @@ -0,0 +1,23 @@ +// best-effort batch lookup of verified signup emails against the internal +// `/_internal/email-did` route. returns only the emails that resolved; a +// transport or parse failure answers an empty map so hydration never throws. +export const resolveEmailToDid = async ( + fetchFn: typeof globalThis.fetch, + emails: string[] +): Promise> => { + if (emails.length === 0) return new Map(); + const params = new URLSearchParams(); + for (const email of emails) params.append("emails", email); + try { + const res = await fetchFn(`/_internal/email-did?${params}`); + if (!res.ok) return new Map(); + const map: Record = await res.json(); + const result = new Map(); + for (const [email, did] of Object.entries(map)) { + if (did) result.set(email, did); + } + return result; + } catch { + return new Map(); + } +}; diff --git a/web/src/lib/api/repoIndex.test.ts b/web/src/lib/api/repoIndex.test.ts index 548dfea9..24c006c0 100644 --- a/web/src/lib/api/repoIndex.test.ts +++ b/web/src/lib/api/repoIndex.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ClientResponseError } from "./client"; -import { classifyRepoAvailability } from "./repoIndex"; +import { classifyRepoAvailability, withAuthorHandles } from "./repoIndex"; +import type { CommitSummary } from "$lib/components/repo/types"; const unsupported = () => new ClientResponseError({ status: 404, data: { error: "XRPCNotSupported" } }); @@ -13,7 +14,6 @@ describe("classifyRepoAvailability", () => { error: cause })) ); - expect(result).toBe("needs-upgrade"); }); @@ -24,7 +24,6 @@ describe("classifyRepoAvailability", () => { error: new TypeError("offline") })) ); - expect(result).toBe("unreachable"); }); @@ -34,7 +33,103 @@ describe("classifyRepoAvailability", () => { { value: null, error: unsupported() }, { value: { branches: [] }, error: null } ]); - expect(result).toBe("ok"); }); }); + +const commit = (c: Partial): CommitSummary => ({ + hash: "h", + shortHash: "h", + subject: "s", + body: "", + authorName: "", + authorEmail: "", + when: "2026-01-01T00:00:00Z", + ...c +}); + +// dispatches the two upstreams withAuthorHandles touches: the internal kv +// route (relative) and bobbin's resolveMiniDoc. +const fetchMock = vi.fn(async (input: string | URL, _init?: RequestInit) => { + const url = String(input); + if (url.startsWith("/_internal/email-did")) { + return new Response( + JSON.stringify({ + "alice@example.com": "did:plc:alice", + "bob@example.com": "did:plc:bob", + "MIXED@Example.com": "did:plc:mixed", + "ghost@example.com": null + }) + ); + } + if (url.includes("blue.microcosm.identity.resolveMiniDoc")) { + const did = new URL(url).searchParams.get("identifier") ?? ""; + return new Response(JSON.stringify({ did, handle: `${did.replace("did:plc:", "")}.test` })); + } + return new Response("unexpected", { status: 404 }); +}); + +describe("withAuthorHandles", () => { + it("resolves verified emails to a did before hydrating handles", async () => { + const commits = [ + commit({ authorName: "Alice", authorEmail: "alice@example.com" }), + commit({ + authorName: "Signed", + authorEmail: "did:plc:signed", + authorDid: "did:plc:signed" + }) + ]; + const out = await withAuthorHandles( + commits, + "https://bobbin.test", + fetchMock as typeof globalThis.fetch + ); + expect(out[0]).toMatchObject({ authorDid: "did:plc:alice", authorHandle: "alice.test" }); + expect(out[1]).toMatchObject({ authorDid: "did:plc:signed", authorHandle: "signed.test" }); + }); + + it("keeps the requested email case when looking up the kv result", async () => { + const out = await withAuthorHandles( + [commit({ authorEmail: "MIXED@Example.com" })], + "https://bobbin.test", + fetchMock as typeof globalThis.fetch + ); + expect(out[0]).toMatchObject({ authorDid: "did:plc:mixed", authorHandle: "mixed.test" }); + }); + + it("leaves unresolved emails as plain authors", async () => { + const out = await withAuthorHandles( + [commit({ authorEmail: "ghost@example.com" })], + "https://bobbin.test", + fetchMock as typeof globalThis.fetch + ); + expect(out[0].authorDid).toBeUndefined(); + expect(out[0].authorHandle).toBeUndefined(); + }); + + it("fails open when the kv route errors", async () => { + const broken = vi.fn(async () => new Response("nope", { status: 500 })); + const out = await withAuthorHandles( + [commit({ authorEmail: "alice@example.com" })], + "https://bobbin.test", + broken as typeof globalThis.fetch + ); + expect(out[0].authorDid).toBeUndefined(); + }); + + it("keeps a did from the email pass when handle resolution fails", async () => { + const brokenBobbin = vi.fn(async (input: string | URL) => { + if (String(input).startsWith("/_internal/email-did")) { + return new Response(JSON.stringify({ "alice@example.com": "did:plc:alice" })); + } + return new Response("nope", { status: 500 }); + }); + const out = await withAuthorHandles( + [commit({ authorEmail: "alice@example.com" })], + "https://bobbin.test", + brokenBobbin as typeof globalThis.fetch + ); + expect(out[0]).toMatchObject({ authorDid: "did:plc:alice" }); + expect(out[0].authorHandle).toBeUndefined(); + }); +}); diff --git a/web/src/lib/api/repoIndex.ts b/web/src/lib/api/repoIndex.ts index 561457bb..3fd79073 100644 --- a/web/src/lib/api/repoIndex.ts +++ b/web/src/lib/api/repoIndex.ts @@ -1,5 +1,6 @@ import { error } from "@sveltejs/kit"; import { ClientResponseError, createBobbinClient } from "$lib/api/client"; +import { resolveEmailToDid } from "$lib/api/emailDid"; import { resolveMiniDoc } from "$lib/api/identity"; import { branches as gitBranches, @@ -123,14 +124,32 @@ export interface RepoIndexOptions { } // a did-signed commit points at a real account, so trade the git author name -// for the handle and the row reads like the rest of the site. -const withAuthorHandles = async ( +// for the handle and the row reads like the rest of the site. a plain git +// email that belongs to a verified signup resolves to that same did first, so +// those rows get the identical treatment. +export const withAuthorHandles = async ( commits: CommitSummary[], bobbinUrl: string, fetch: typeof globalThis.fetch ): Promise => { - const dids = [...new Set(commits.flatMap((commit) => commit.authorDid ?? []))]; - if (dids.length === 0) return commits; + let hydrated = commits; + const emails = [ + ...new Set(commits.flatMap((commit) => (commit.authorDid ? [] : [commit.authorEmail]))) + ]; + if (emails.length > 0) { + // the lookups are best-effort: a kv/route failure keeps plain emails + const emailDids = (await orNull(resolveEmailToDid(fetch, emails))) ?? new Map(); + if (emailDids.size > 0) { + hydrated = commits.map((commit) => { + if (commit.authorDid) return commit; + const did = emailDids.get(commit.authorEmail); + return did ? { ...commit, authorDid: did } : commit; + }); + } + } + + const dids = [...new Set(hydrated.flatMap((commit) => commit.authorDid ?? []))]; + if (dids.length === 0) return hydrated; const ctx = createBobbinClient({ serviceUrl: bobbinUrl, fetch }); const docs = await Promise.all(dids.map((did) => orNull(resolveMiniDoc(ctx, did)))); @@ -139,7 +158,7 @@ const withAuthorHandles = async ( if (doc && !doc.handle.endsWith(".invalid")) handles.set(doc.did, doc.handle); } - return commits.map((commit) => + return hydrated.map((commit) => commit.authorDid ? { ...commit, authorHandle: handles.get(commit.authorDid) } : commit ); }; diff --git a/web/src/routes/[handle]/[repo]/commits/[ref]/+page.ts b/web/src/routes/[handle]/[repo]/commits/[ref]/+page.ts index 9a0c33e6..17081d22 100644 --- a/web/src/routes/[handle]/[repo]/commits/[ref]/+page.ts +++ b/web/src/routes/[handle]/[repo]/commits/[ref]/+page.ts @@ -1,5 +1,5 @@ import { branches, gitTarget, log, tags } from "$lib/api/gitclient"; -import { REF_LIMIT } from "$lib/api/repoIndex"; +import { REF_LIMIT, withAuthorHandles } from "$lib/api/repoIndex"; import { tagsByCommitHash, toBranchSummary, toCommitSummary, toTagSummary } from "$lib/api/repo"; import { stream } from "$lib/api/load"; import type { PageLoad } from "./$types"; @@ -21,7 +21,13 @@ export const load: PageLoad = async (event) => { return log(git, { ref, limit: COMMIT_LIMIT, cursor }); }); - const commits = logPromise.then((res) => (res.commits ?? []).map(toCommitSummary)); + const commits = logPromise.then((res) => + withAuthorHandles( + (res.commits ?? []).map(toCommitSummary), + parent.publicConfig.bobbinUrl, + event.fetch + ) + ); const totalCommits = logPromise.then((res) => res.total ?? 0); const pageCount = logPromise.then((res) => { const total = res.total ?? 0; diff --git a/web/src/routes/_internal/email-did/+server.ts b/web/src/routes/_internal/email-did/+server.ts new file mode 100644 index 00000000..e281a5d1 --- /dev/null +++ b/web/src/routes/_internal/email-did/+server.ts @@ -0,0 +1,40 @@ +import { json } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +// 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. +const MAX_LOOKUPS = 250; + +export const GET: RequestHandler = async (event) => { + 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({}); + + // lookups are case-insensitive, so read each distinct lowercased address + // once even when the caller mixes cases; every original-case key keeps its + // own response entry + const distinctEmails = [...new Set(emails.map((email) => email.toLowerCase()))]; + const byLower = new Map( + await Promise.all( + distinctEmails.map(async (key) => [key, await kv.get(key).catch(() => null)] as const) + ) + ); + + 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 new file mode 100644 index 00000000..f02ce07b --- /dev/null +++ b/web/src/routes/_internal/email-did/email-did.server.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from "vitest"; +import { GET } from "./+server"; + +// the handler only needs url + platform from the request event +const event = (url: string, kv?: { get: (key: string) => Promise }) => + ({ + url: new URL(url), + platform: kv ? { env: { EMAIL_DID: { get: kv.get } } } : {} + }) as Parameters[0]; + +describe("GET /_internal/email-did", () => { + 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 }) + ); + expect(get).toHaveBeenCalledWith("alice@example.com"); + await expect(res.json()).resolves.toEqual({ "Alice@Example.com": "did:plc:alice" }); + }); + + 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 }) + ); + 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 }) + ); + expect(get).toHaveBeenCalledWith("a+b@example.com"); + await expect(res.json()).resolves.toEqual({ "a+b@example.com": "did:plc:plus" }); + }); + + it("answers an empty map without kv (adapter-node dev)", async () => { + const res = await GET( + event("http://localhost/_internal/email-did?emails=alice%40example.com") + ); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({}); + }); + + 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 })); + expect(get).not.toHaveBeenCalled(); + await expect(res.json()).resolves.toEqual({}); + }); + + 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 }) + ); + expect(get).toHaveBeenCalledTimes(1); + expect(get).toHaveBeenCalledWith("a@b.com"); + await expect(res.json()).resolves.toEqual({ + "a@b.com": "did:plc:a", + "A@B.com": "did:plc:a" + }); + }); + + it("answers null for a kv error instead of failing the batch", async () => { + const get = vi.fn(async () => { + throw new Error("kv down"); + }); + const res = await GET( + event("http://localhost/_internal/email-did?emails=a%40b.com&emails=c%40b.com", { get }) + ); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ "a@b.com": null, "c@b.com": null }); + }); +}); diff --git a/web/wrangler.dev.jsonc b/web/wrangler.dev.jsonc index 5f57c8ef..1ffb6d22 100644 --- a/web/wrangler.dev.jsonc +++ b/web/wrangler.dev.jsonc @@ -22,6 +22,12 @@ "service": "bobbin-svfe-dev" } ], + "kv_namespaces": [ + { + "binding": "EMAIL_DID", + "id": "645c742c4c674357a9a7825bf4ba62ca" + } + ], "routes": [ { "pattern": "next.tangled.org", -- 2.51.2