diff --git a/web/src/lib/api/repo.test.ts b/web/src/lib/api/repo.test.ts index ab59535f0..b61fcc741 100644 --- a/web/src/lib/api/repo.test.ts +++ b/web/src/lib/api/repo.test.ts @@ -5,6 +5,7 @@ import { repoNameOf, resolveForkRepoLabels, resolveRepoByName, + resolveRepoByRepoDid, sortTreeEntries, toBranchSummary, toCommitDetail, @@ -350,3 +351,83 @@ describe("resolveForkRepoLabels", () => { expect(fetchMock).not.toHaveBeenCalled(); }); }); + +describe("resolveRepoByRepoDid", () => { + const enrichReply = jsonResponse({ + output: { + uri: "at://did:plc:owner/sh.tangled.repo/core", + value: { + $type: "sh.tangled.repo", + knot: "knot.test", + name: "core", + repoDid: "did:plc:j5hmlfdrwkvtxm7cjmu7j2is" + } + }, + data: { + "did:plc:owner": { + "sh.tangled.repo:.repo": { + "blue.microcosm.identity.resolveMiniDoc": { + did: "did:plc:owner", + handle: "tangled.org" + } + } + } + } + }); + + it("resolves owner handle and repo name from repo did", async () => { + const fetchMock = vi.fn().mockResolvedValue(enrichReply); + const result = await resolveRepoByRepoDid( + makeCtx(fetchMock), + "did:plc:j5hmlfdrwkvtxm7cjmu7j2is" + ); + expect(result).toEqual({ owner: "tangled.org", name: "core" }); + + const [url, request] = fetchMock.mock.calls[0]; + expect(String(url)).toContain("/xrpc/sh.tangled.query.enrichResponse"); + expect(JSON.parse(String(request?.body))).toMatchObject({ + xrpc: "sh.tangled.repo.getRepoByRepoDid", + params: { repoDid: "did:plc:j5hmlfdrwkvtxm7cjmu7j2is" } + }); + }); + + it("falls back to owner did when handle is missing or .invalid", async () => { + const enrichReplyNoHandle = jsonResponse({ + output: { + uri: "at://did:plc:owner/sh.tangled.repo/core", + value: { + $type: "sh.tangled.repo", + knot: "knot.test", + name: "core", + repoDid: "did:plc:j5hmlfdrwkvtxm7cjmu7j2is" + } + }, + data: { + "did:plc:owner": { + "sh.tangled.repo:.repo": { + "blue.microcosm.identity.resolveMiniDoc": { + did: "did:plc:owner", + handle: "handle.invalid" + } + } + } + } + }); + const fetchMock = vi.fn().mockResolvedValue(enrichReplyNoHandle); + const result = await resolveRepoByRepoDid( + makeCtx(fetchMock), + "did:plc:j5hmlfdrwkvtxm7cjmu7j2is" + ); + expect(result).toEqual({ owner: "did:plc:owner", name: "core" }); + }); + + it("returns null when bobbin has no such repo", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "RecordNotFound" }), { + status: 404, + headers: { "content-type": "application/json" } + }) + ); + expect(await resolveRepoByRepoDid(makeCtx(fetchMock), "did:plc:missing")).toBeNull(); + }); +}); diff --git a/web/src/lib/api/repo.ts b/web/src/lib/api/repo.ts index fffbe0ef3..bc9e2d1bf 100644 --- a/web/src/lib/api/repo.ts +++ b/web/src/lib/api/repo.ts @@ -10,7 +10,7 @@ import { tags as knotTags } from "$lib/api/knot"; import { httpStatusFor } from "$lib/api/load"; -import { rkeyFromUri } from "$lib/api/uri"; +import { didFromUri, rkeyFromUri } from "$lib/api/uri"; import type * as Tree from "$lib/api/lexicons/types/sh/tangled/repo/tree"; // log, branches and tags are `*/*` in the lexicons, so these shapes are copied @@ -142,6 +142,44 @@ export const resolveForkRepoLabels = async ( return labels; }; +export interface ResolvedRepoLocation { + owner: string; + name: string; +} + +// resolve a repoDid to its owner handle/did and repository name +export const resolveRepoByRepoDid = async ( + ctx: BobbinContext, + repoDid: string, + init?: XrpcRequestInit +): Promise => { + try { + const page = await enrich>( + ctx, + { + xrpc: "sh.tangled.repo.getRepoByRepoDid", + params: { repoDid }, + enrich: [target(REPO_OWNER_DOCS, ["uri"])] + }, + init + ); + const view = page.output; + if (!view?.uri) return null; + const owner = authorOf(page.data, view.uri, REPO_OWNER_DOCS); + const ownerIdentifier = + owner.handle && !owner.handle.endsWith(".invalid") + ? owner.handle + : didFromUri(view.uri); + return { + owner: ownerIdentifier, + name: repoNameOf(view) + }; + } catch (cause) { + if (cause instanceof ClientResponseError && httpStatusFor(cause) === 404) return null; + return null; + } +}; + // org.tangled.review.patch record export interface PullSubmission { head: string; diff --git a/web/src/routes/[handle]/+layout.ts b/web/src/routes/[handle]/+layout.ts index 73b3755bd..54d85e70a 100644 --- a/web/src/routes/[handle]/+layout.ts +++ b/web/src/routes/[handle]/+layout.ts @@ -16,6 +16,7 @@ import { import { toHttpError, httpStatusFor, stream } from "$lib/api/load"; import { ClientResponseError } from "$lib/api/client"; import { getViewerVouch } from "$lib/api/graph"; +import { resolveRepoByRepoDid } from "$lib/api/repo"; import type { ProfileCounts, DirectVouch } from "$lib/components/profile/types"; import type { LayoutLoad } from "./$types"; @@ -50,7 +51,15 @@ export const load: LayoutLoad = async (event) => { params: { identifier }, enrich: targetAll([...PROFILE_COUNTS, FOLLOW_VIEWER], ["did"]), viewer: viewerDid - }).catch((cause) => toHttpError(cause, "Could not resolve user")); + }).catch(async (cause) => { + if (identifier.startsWith("did:")) { + const repo = await resolveRepoByRepoDid(ctx, identifier); + if (repo) { + redirect(307, `/${repo.owner}/${repo.name}${event.url.search}`); + } + } + return toHttpError(cause, "Could not resolve user"); + }); const doc = enriched.output; const did = doc.did; @@ -106,7 +115,7 @@ export const load: LayoutLoad = async (event) => { ) ); - if (!browser) { + if (!browser || identifier.startsWith("did:")) { const { identity, canonical } = await resolved; if (canonical && identifier.toLowerCase() !== canonical.toLowerCase()) { redirect(307, `/${canonical}${event.url.search}`); diff --git a/web/src/routes/[handle]/[repo]/+layout.ts b/web/src/routes/[handle]/[repo]/+layout.ts index 23804d2be..a277092da 100644 --- a/web/src/routes/[handle]/[repo]/+layout.ts +++ b/web/src/routes/[handle]/[repo]/+layout.ts @@ -10,7 +10,7 @@ import { getStarRkey } from "$lib/api/graph"; import { resolveMiniDoc } from "$lib/api/identity"; import { parallel, stream, toHttpError } from "$lib/api/load"; import type { RecordView, RepoRecord } from "$lib/api/records"; -import { repoNameOf, resolveRepoByName } from "$lib/api/repo"; +import { repoNameOf, resolveRepoByName, resolveRepoByRepoDid } from "$lib/api/repo"; import { rkeyFromUri } from "$lib/api/uri"; import type { BobbinContext } from "$lib/api/client"; import type { RepoCounts, RepoInfo, RepoSource } from "$lib/components/repo/types"; @@ -60,9 +60,19 @@ export const load: LayoutLoad = async (event) => { // server awaits for 404s and redirects, client navigation streams for instant commit const resolved = (async () => { - const doc = await resolveMiniDoc(ctx, identifier).catch((cause) => - toHttpError(cause, "Could not resolve user") - ); + const doc = await resolveMiniDoc(ctx, identifier).catch(async (cause) => { + if (identifier.startsWith("did:")) { + const repo = await resolveRepoByRepoDid(ctx, identifier); + if (repo) { + const prefix = `/${event.params.handle}`; + const remainder = event.url.pathname.startsWith(prefix) + ? event.url.pathname.slice(prefix.length) + : ""; + redirect(307, `/${repo.owner}/${repo.name}${remainder}${event.url.search}`); + } + } + return toHttpError(cause, "Could not resolve user"); + }); const view = await resolveRepoByName(ctx, doc.did, name).catch((cause) => toHttpError(cause, "Could not load repository") ); @@ -144,7 +154,7 @@ export const load: LayoutLoad = async (event) => { resolved.then(({ repo }) => resolvePushAccess(ctx, repo, viewerDid).catch(() => false)) ); - if (!browser) { + if (!browser || identifier.startsWith("did:")) { const { repo, canonical } = await resolved; if (canonical && identifier.toLowerCase() !== canonical.toLowerCase()) { redirect(307, `/${canonical}/${event.params.repo}${event.url.search}`); diff --git a/web/src/routes/[handle]/handle.test.ts b/web/src/routes/[handle]/handle.test.ts new file mode 100644 index 000000000..8983c8821 --- /dev/null +++ b/web/src/routes/[handle]/handle.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from "vitest"; +import { load as handleLayoutLoad } from "$routes/[handle]/+layout"; +import { load as repoLayoutLoad } from "$routes/[handle]/[repo]/+layout"; +import { REPO_OWNER_DOCS } from "$lib/api/descriptors"; +import { TYPE_MINIDOC } from "$lib/api/enrich"; + +const REPO_DID = "did:plc:j5hmlfdrwkvtxm7cjmu7j2is"; +const OWNER_DID = "did:plc:wshs7t2adsemcrrd4snkeqli"; + +const jsonResponse = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); + +const makeMockFetch = () => + vi.fn().mockImplementation(async (input, init) => { + const str = String(input); + const bodyStr = init?.body ? String(init.body) : ""; + + // resolveMiniDoc fails for repo DID + if ( + str.includes("resolveMiniDoc") || + (bodyStr.includes("resolveMiniDoc") && !bodyStr.includes(REPO_OWNER_DOCS.source)) + ) { + return jsonResponse({ error: "InvalidRequest", message: "Failed to get DID doc" }, 400); + } + + // getRepoByRepoDid enrich response + if (bodyStr.includes("getRepoByRepoDid")) { + return jsonResponse({ + output: { + uri: `at://${OWNER_DID}/sh.tangled.repo/core`, + value: { + $type: "sh.tangled.repo", + name: "core", + repoDid: REPO_DID + } + }, + data: { + [OWNER_DID]: { + [REPO_OWNER_DOCS.source]: { + [TYPE_MINIDOC]: { did: OWNER_DID, handle: "tangled.org" } + } + } + } + }); + } + + return jsonResponse({}, 404); + }); + +describe("/[handle] repoDID navigation", () => { + it("redirects /[repoDID] to /[ownerHandle]/[repoName]", async () => { + const fetchMock = makeMockFetch(); + const event = { + url: new URL(`http://web.test/${REPO_DID}`), + params: { handle: REPO_DID }, + fetch: fetchMock, + parent: async () => ({ + publicConfig: { bobbinUrl: "https://bobbin.test" }, + auth: null + }), + route: { id: "/[handle]" } + } as never; + + await expect(handleLayoutLoad(event)).rejects.toMatchObject({ + status: 307, + location: "/tangled.org/core" + }); + }); + + it("preserves search params when redirecting /[repoDID]", async () => { + const fetchMock = makeMockFetch(); + const event = { + url: new URL(`http://web.test/${REPO_DID}?tab=stars`), + params: { handle: REPO_DID }, + fetch: fetchMock, + parent: async () => ({ + publicConfig: { bobbinUrl: "https://bobbin.test" }, + auth: null + }), + route: { id: "/[handle]" } + } as never; + + await expect(handleLayoutLoad(event)).rejects.toMatchObject({ + status: 307, + location: "/tangled.org/core?tab=stars" + }); + }); + + it("redirects /[repoDID]/[subpath] to /[ownerHandle]/[repoName]/[subpath]", async () => { + const fetchMock = makeMockFetch(); + const event = { + url: new URL(`http://web.test/${REPO_DID}/issues`), + params: { handle: REPO_DID, repo: "issues" }, + fetch: fetchMock, + parent: async () => ({ + publicConfig: { bobbinUrl: "https://bobbin.test" }, + auth: null + }), + route: { id: "/[handle]/[repo]/issues" } + } as never; + + await expect(repoLayoutLoad(event)).rejects.toMatchObject({ + status: 307, + location: "/tangled.org/core/issues" + }); + }); +});