diff --git a/src/server/db/types.ts b/src/server/db/types.ts index 04f7b28..e7ae1ce 100644 --- a/src/server/db/types.ts +++ b/src/server/db/types.ts @@ -1,7 +1,7 @@ // Mirror the CHECK constraints in src/server/db/schema.ts so callers can rely // on these unions when wiring the wiki theme into views without re-narrowing. -export type WikiThemeMode = "reader" | "enforce"; -export type WikiThemeName = "light" | "dark"; +type WikiThemeMode = "reader" | "enforce"; +type WikiThemeName = "light" | "dark"; export interface WikiRow { slug: string; diff --git a/src/server/routes/og.ts b/src/server/routes/og.ts index eac10a8..e8f3e2e 100644 --- a/src/server/routes/og.ts +++ b/src/server/routes/og.ts @@ -27,6 +27,11 @@ export const ogRoutes = new Elysia({ prefix: "/og" }) const wiki = getWiki(ownerDid, wikiSlug); if (!wiki) return new Response("Wiki not found", { status: 404 }); + // OG cards are only meaningful for unauthenticated social crawlers; + // serving one for a private wiki would leak its name/description. + if (wiki.visibility !== "public") { + return new Response("Not found", { status: 404 }); + } const profile = await resolveProfile(wiki.did); const noteCount = getSidebarNotes(wiki.at_uri).length; diff --git a/src/shared/viz-types.ts b/src/shared/viz-types.ts index d50927e..1a9b687 100644 --- a/src/shared/viz-types.ts +++ b/src/shared/viz-types.ts @@ -4,7 +4,7 @@ export interface ForceGraphNode { group?: string; } -export interface ForceGraphLink { +interface ForceGraphLink { source: string; target: string; label?: string; diff --git a/tests/server/db/queries/revision.test.ts b/tests/server/db/queries/revision.test.ts index 72eb157..510d074 100644 --- a/tests/server/db/queries/revision.test.ts +++ b/tests/server/db/queries/revision.test.ts @@ -176,7 +176,7 @@ describe("backlinks", () => { "backlink-test-cross-wiki", "Cross Wiki", TEST_DID, - "See [[other-wiki/shared-note]].", + "See [[@mock.handle/other-wiki/shared-note]].", ); const links = getBacklinks("other-wiki", "shared-note"); expect(links.map((l) => l.source_note_uri)).toContain(nUri); diff --git a/tests/server/routes/helpers.ts b/tests/server/routes/helpers.ts index 25d52b2..b7ada03 100644 --- a/tests/server/routes/helpers.ts +++ b/tests/server/routes/helpers.ts @@ -49,6 +49,7 @@ const { wikiCreationRoutes, wikiRoutes } = await import( ); const { exploreRoutes } = await import("../../../src/server/routes/explore.ts"); const { localeRoutes } = await import("../../../src/server/routes/locale.ts"); +const { ogRoutes } = await import("../../../src/server/routes/og.ts"); export function createTestApp() { return new Elysia() @@ -67,7 +68,8 @@ export function createTestApp() { .use(wikiCreationRoutes) .use(wikiRoutes) .use(exploreRoutes) - .use(localeRoutes); + .use(localeRoutes) + .use(ogRoutes); } /** Create a Request with an authenticated session cookie. */ diff --git a/tests/server/routes/url-permissions.test.ts b/tests/server/routes/url-permissions.test.ts new file mode 100644 index 0000000..6e1d105 --- /dev/null +++ b/tests/server/routes/url-permissions.test.ts @@ -0,0 +1,146 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + upsertMembership, + upsertWiki, +} from "../../../src/server/db/queries/index.ts"; +import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; +import { authedRequest, createTestApp, TEST_DID } from "./helpers.ts"; + +// Covers the URL-scheme switch to /@:handle/:wikiSlug: +// - private-wiki OG card must not leak metadata (regression: og.ts skipped visibility) +// - cross-handle URLs (correct slug under a different DID) must 404 +// - edit-role members must not reach admin-only endpoints + +const app = createTestApp(); + +const OWNER = TEST_DID; +const OTHER = "did:plc:url-perm-other"; +const EDITOR = "did:plc:url-perm-editor"; + +const PUB_SLUG = "up-public"; +const PRIV_SLUG = "up-private"; +const OTHER_SLUG = "up-other-owned"; + +const PUB_URI = `at://${OWNER}/wiki.lichen.wiki/${PUB_SLUG}`; +const PRIV_URI = `at://${OWNER}/wiki.lichen.wiki/${PRIV_SLUG}`; +const OTHER_URI = `at://${OTHER}/wiki.lichen.wiki/${OTHER_SLUG}`; + +beforeAll(() => { + upsertWiki( + PUB_SLUG, + OWNER, + "Public", + "public", + PUB_URI, + new Date().toISOString(), + ); + upsertWiki( + PRIV_SLUG, + OWNER, + "Private", + "private", + PRIV_URI, + new Date().toISOString(), + ); + upsertWiki( + OTHER_SLUG, + OTHER, + "Other-Owned", + "public", + OTHER_URI, + new Date().toISOString(), + ); + // EDITOR is a contributor on the public wiki — has edit access, not admin. + upsertMembership( + PUB_URI, + PUB_SLUG, + EDITOR, + "contributor", + `at://${EDITOR}/wiki.lichen.membership/up1`, + new Date().toISOString(), + ); +}); + +afterAll(() => { + cleanupWikiAndDependents(PUB_SLUG); + cleanupWikiAndDependents(PRIV_SLUG); + cleanupWikiAndDependents(OTHER_SLUG); +}); + +describe("OG card visibility", () => { + test("private wiki OG card returns 404 (no metadata leak)", async () => { + const res = await app.handle( + new Request(`http://localhost/og/@${OWNER}/${PRIV_SLUG}`), + ); + expect(res.status).toBe(404); + }); + + test("nonexistent wiki OG card returns 404", async () => { + const res = await app.handle( + new Request(`http://localhost/og/@${OWNER}/does-not-exist`), + ); + expect(res.status).toBe(404); + }); +}); + +describe("cross-handle URL isolation", () => { + // OTHER_SLUG belongs to OTHER, not OWNER. Asking for it under OWNER's + // handle must 404 — getWiki(ownerDid, slug) is keyed by both. + test("wiki page under wrong owner handle returns 404", async () => { + const res = await app.handle( + new Request(`http://localhost/@${OWNER}/${OTHER_SLUG}`), + ); + expect(res.status).toBe(404); + }); + + test("note under wrong owner handle returns 404", async () => { + const res = await app.handle( + new Request(`http://localhost/@${OWNER}/${OTHER_SLUG}/anything`), + ); + expect(res.status).toBe(404); + }); +}); + +describe("edit-role cannot reach admin endpoints", () => { + const adminGets = [ + `/@${OWNER}/${PUB_SLUG}/-/settings`, + `/@${OWNER}/${PUB_SLUG}/-/export`, + ]; + const adminPosts = [ + `/@${OWNER}/${PUB_SLUG}/-/edit`, + `/@${OWNER}/${PUB_SLUG}/-/theme`, + `/@${OWNER}/${PUB_SLUG}/-/delete`, + `/@${OWNER}/${PUB_SLUG}/-/members/add`, + `/@${OWNER}/${PUB_SLUG}/-/members/${encodeURIComponent(OTHER)}/approve`, + `/@${OWNER}/${PUB_SLUG}/-/members/${encodeURIComponent(OTHER)}/remove`, + `/@${OWNER}/${PUB_SLUG}/-/members/${encodeURIComponent(OTHER)}/change-role`, + ]; + + for (const path of adminGets) { + test(`GET ${path} → 403 for contributor`, async () => { + const res = await app.handle( + authedRequest(`http://localhost${path}`, undefined, EDITOR), + ); + // /-/export is only reachable to readers (public wiki → 200 even for + // non-members). Settings is admin-only. + if (path.endsWith("/-/export")) { + expect(res.status).toBe(200); + } else { + expect(res.status).toBe(403); + } + }); + } + + for (const path of adminPosts) { + test(`POST ${path} → 403 for contributor`, async () => { + const res = await app.handle( + authedRequest( + `http://localhost${path}`, + { method: "POST", body: new FormData() }, + EDITOR, + ), + ); + expect(res.status).toBe(403); + }); + } +});