diff --git a/src/firehose/handlers.ts b/src/firehose/handlers.ts index 87e444a..f613a12 100644 --- a/src/firehose/handlers.ts +++ b/src/firehose/handlers.ts @@ -1,3 +1,4 @@ +import { canEdit, getAccessLevel } from "../lib/access.ts"; import { COLLECTIONS, normalizeRole } from "../lib/collections.ts"; import { LIMITS } from "../lib/limits.ts"; import { @@ -7,6 +8,7 @@ import { deleteNoteByAtUri, deleteRequestByUri, deleteWikiByAtUri, + getMemberRole, getNoteByAtUri, getWikiByAtUri, setWikiTheme, @@ -16,6 +18,7 @@ import { upsertRequest, upsertWiki, } from "../server/db/queries/index.ts"; +import type { WikiRow } from "../server/db/types.ts"; import { themes } from "../views/theme/themes.ts"; interface WikiRecord { @@ -237,7 +240,22 @@ export function handleCommitEvent(evt: FirehoseCommit): void { } } -// Handlers — records are already validated by the type guards above. +// --- Authorization --- +// +// Jetstream commits are collection-filtered, not repo-filtered: any actor can +// write wiki.lichen.note / wiki.lichen.noteRevision records into their own repo +// pointing at someone else's wiki. The records are validly signed by their +// author, so the gap isn't signatures — it's that we never checked whether the +// author may edit the target wiki. Mirror the orchestrator rule: owner + admin +// + contributor may write; viewers and strangers are dropped. (handleMembership +// keeps its stricter owner-only check.) +function canDidEditWiki(wiki: WikiRow, did: string): boolean { + const role = getMemberRole(wiki.at_uri, did); + return canEdit(getAccessLevel(wiki, did, role)); +} + +// --- Handlers (validation already done by type guards) --- + function handleWiki( did: string, rkey: string, @@ -267,6 +285,7 @@ function handleWiki( function handleNote(did: string, atUri: string, record: NoteRecord): void { const wiki = getWikiByAtUri(record.wikiRef); if (!wiki) return; + if (!canDidEditWiki(wiki, did)) return; upsertNote( wiki.at_uri, @@ -286,6 +305,9 @@ function handleRevision( ): void { const note = getNoteByAtUri(record.noteRef); if (!note) return; + const wiki = getWikiByAtUri(note.wiki_at_uri); + if (!wiki) return; + if (!canDidEditWiki(wiki, did)) return; applyRevisionFromFirehose( note.at_uri, diff --git a/tests/integration/firehose-authz.test.ts b/tests/integration/firehose-authz.test.ts new file mode 100644 index 0000000..c640f0e --- /dev/null +++ b/tests/integration/firehose-authz.test.ts @@ -0,0 +1,121 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { + getCurrentNote, + getMemberRole, + getNoteBySlug, +} from "../../src/server/db/queries/index.ts"; +import { cleanupWikiAndDependents } from "../helpers/cleanup.ts"; +import { + ALICE, + BOB, + createDiff, + emitMembership, + emitNote, + emitRevision, + emitWiki, +} from "./helpers.ts"; + +// ALICE owns every wiki here; BOB is the actor whose authorization varies. +const createdWikiSlugs: string[] = []; + +afterAll(() => { + for (const slug of createdWikiSlugs) { + cleanupWikiAndDependents(slug); + } +}); + +function createTrackedWiki(name: string) { + const result = emitWiki(ALICE.did, name); + createdWikiSlugs.push(result.rkey); + return result; +} + +describe("firehose authz: note ingestion", () => { + test("note from a non-member is dropped (public wikis are not open-write)", () => { + const { uri: wikiUri } = createTrackedWiki("Authz Note Wiki A"); + + emitNote(BOB.did, "stranger-note", "Stranger", wikiUri); + + expect(getNoteBySlug(wikiUri, "stranger-note")).toBeNull(); + }); + + test("note from the wiki owner is applied", () => { + const { uri: wikiUri } = createTrackedWiki("Authz Note Wiki B"); + + emitNote(ALICE.did, "owner-note", "Owner", wikiUri); + + expect(getNoteBySlug(wikiUri, "owner-note")).not.toBeNull(); + }); + + test("note from a contributor member is applied", () => { + const { uri: wikiUri } = createTrackedWiki("Authz Note Wiki C"); + emitMembership(ALICE.did, BOB.did, wikiUri, "contributor"); + expect(getMemberRole(wikiUri, BOB.did)).toBe("contributor"); + + emitNote(BOB.did, "contributor-note", "Contributor", wikiUri); + + expect(getNoteBySlug(wikiUri, "contributor-note")).not.toBeNull(); + }); +}); + +describe("firehose authz: revision ingestion", () => { + // Owner seeds a note + first revision; BOB then attempts to revise it. + function seedNote(wikiName: string, slug: string) { + const { uri: wikiUri } = createTrackedWiki(wikiName); + const { uri: noteUri } = emitNote(ALICE.did, slug, "Seed", wikiUri); + const { uri: rev1Uri } = emitRevision( + ALICE.did, + noteUri, + createDiff("", "Alice content"), + "seed", + ); + return { wikiUri, noteUri, rev1Uri }; + } + + test("revision from a non-member is dropped (content unchanged)", () => { + const { wikiUri, noteUri, rev1Uri } = seedNote("Authz Rev Wiki A", "rev-a"); + + emitRevision( + BOB.did, + noteUri, + createDiff("Alice content", "Stranger overwrote this"), + "attack", + rev1Uri, + ); + + expect(getCurrentNote(wikiUri, "rev-a")?.content).toBe("Alice content"); + }); + + test("revision from a viewer member is dropped (content unchanged)", () => { + const { wikiUri, noteUri, rev1Uri } = seedNote("Authz Rev Wiki B", "rev-b"); + emitMembership(ALICE.did, BOB.did, wikiUri, "viewer"); + expect(getMemberRole(wikiUri, BOB.did)).toBe("viewer"); + + emitRevision( + BOB.did, + noteUri, + createDiff("Alice content", "Viewer overwrote this"), + "attack", + rev1Uri, + ); + + expect(getCurrentNote(wikiUri, "rev-b")?.content).toBe("Alice content"); + }); + + test("revision from a contributor member is applied", () => { + const { wikiUri, noteUri, rev1Uri } = seedNote("Authz Rev Wiki C", "rev-c"); + emitMembership(ALICE.did, BOB.did, wikiUri, "contributor"); + + emitRevision( + BOB.did, + noteUri, + createDiff("Alice content", "Alice and Bob content"), + "bob edit", + rev1Uri, + ); + + expect(getCurrentNote(wikiUri, "rev-c")?.content).toBe( + "Alice and Bob content", + ); + }); +});