diff --git a/docs/open-wiki-plan.md b/docs/open-wiki-plan.md index 218e763..88d2a38 100644 --- a/docs/open-wiki-plan.md +++ b/docs/open-wiki-plan.md @@ -148,7 +148,7 @@ Owner/admin-only (`canManage`): 4. **[x] Review backend** — queue listing + approve/reject/promote orchestrators + routes. 5. **[x] UI** — settings toggle, "Open Contribution" button + editor mode, admin queue views. 6. **[x] i18n + polish.** -7. **[ ] Tests + guardrails** (rate limits, moderation hooks). +7. **[x] Tests + guardrails** (rate limits, moderation hooks). 8. **[ ] E2E on this instance** — verify with real accounts on an open test wiki. ## 9. Decisions (confirmed) diff --git a/src/lib/limits.ts b/src/lib/limits.ts index 7c7489e..c080f7a 100644 --- a/src/lib/limits.ts +++ b/src/lib/limits.ts @@ -40,6 +40,7 @@ export const LIMITS = { blobProxy: { limit: 60, windowMs: 60_000 }, login: { limit: 5, windowMs: 60_000 }, search: { limit: 30, windowMs: 60_000 }, + contribute: { limit: 20, windowMs: 60_000 }, }, historyPageSize: 25, linkSuggestLimit: 20, diff --git a/src/server/routes/note.ts b/src/server/routes/note.ts index 9a6ed52..6f1d840 100644 --- a/src/server/routes/note.ts +++ b/src/server/routes/note.ts @@ -9,12 +9,14 @@ import { } from "../../lib/errors.ts"; import { t } from "../../lib/i18n/index.ts"; import { exportNoteMd } from "../../lib/import-export/export.ts"; +import { LIMITS } from "../../lib/limits.ts"; import { createNoteAction, deleteNoteAction, editNoteAction, parseNoteFormFields, } from "../../lib/orchestrators/note.ts"; +import { rateLimitByIp } from "../../lib/rate-limit.ts"; import { attachmentDisposition, htmlResponse } from "../../lib/response.ts"; import { collabWsPath, @@ -80,10 +82,20 @@ export const noteRoutes = new Elysia() }), ); }) - .post("/@:handle/:wikiSlug/new", async ({ params, request }) => { + .post("/@:handle/:wikiSlug/new", async ({ params, request, server }) => { const { handle: urlHandle, wikiSlug } = hp(params); const ctx = await resolveEditOrContribute(request, urlHandle, wikiSlug); + if (ctx.canSubmitContribution) { + const limited = rateLimitByIp( + "contribute", + request, + server, + LIMITS.rateLimit.contribute, + ); + if (limited) return limited; + } + const msg = t(ctx.locale); const formData = await verifyCsrfForm(request, ctx.did); const fields = parseNoteFormFields(formData); @@ -151,18 +163,32 @@ export const noteRoutes = new Elysia() ), ); }) - .post("/@:handle/:wikiSlug/:noteSlug/edit", async ({ params, request }) => { - const { handle: urlHandle, wikiSlug, noteSlug } = hwnp(params); - const ctx = await resolveEditOrContribute(request, urlHandle, wikiSlug); - const msg = t(ctx.locale); - const formData = await verifyCsrfForm(request, ctx.did); - const fields = parseNoteFormFields(formData); + .post( + "/@:handle/:wikiSlug/:noteSlug/edit", + async ({ params, request, server }) => { + const { handle: urlHandle, wikiSlug, noteSlug } = hwnp(params); + const ctx = await resolveEditOrContribute(request, urlHandle, wikiSlug); - await editNoteAction(ctx, noteSlug, fields, msg); + if (ctx.canSubmitContribution) { + const limited = rateLimitByIp( + "contribute", + request, + server, + LIMITS.rateLimit.contribute, + ); + if (limited) return limited; + } - const base = noteUrl(ctx.ownerHandle, ctx.wiki.slug, noteSlug); - return redirect(ctx.canSubmitContribution ? `${base}?submitted=1` : base); - }) + const msg = t(ctx.locale); + const formData = await verifyCsrfForm(request, ctx.did); + const fields = parseNoteFormFields(formData); + + await editNoteAction(ctx, noteSlug, fields, msg); + + const base = noteUrl(ctx.ownerHandle, ctx.wiki.slug, noteSlug); + return redirect(ctx.canSubmitContribution ? `${base}?submitted=1` : base); + }, + ) .get( "/@:handle/:wikiSlug/:noteSlug/-/export", async ({ params, request }) => { diff --git a/tests/lib/orchestrators/contribution.test.ts b/tests/lib/orchestrators/contribution.test.ts index 75ef150..bc25fe1 100644 --- a/tests/lib/orchestrators/contribution.test.ts +++ b/tests/lib/orchestrators/contribution.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; import type { WikiRequestContext } from "../../../src/lib/access.ts"; import { createDiff } from "../../../src/lib/diff.ts"; -import { ForbiddenError } from "../../../src/lib/errors.ts"; +import { ForbiddenError, ValidationError } from "../../../src/lib/errors.ts"; import { getDb } from "../../../src/server/db/index.ts"; import type { WikiRow } from "../../../src/server/db/queries/index.ts"; @@ -59,8 +59,12 @@ mock.module("../../../src/lib/backfill/pds-client.ts", () => ({ const { approveContributionAction, rejectContributionAction } = await import( "../../../src/lib/orchestrators/contribution.ts" ); -const { getCurrentNote, getNoteBySlug, upsertPendingContribution } = - await import("../../../src/server/db/queries/index.ts"); +const { + getCurrentNote, + getNoteBySlug, + setContributionDecision, + upsertPendingContribution, +} = await import("../../../src/server/db/queries/index.ts"); const wiki: WikiRow = { slug: WIKI_SLUG, @@ -97,16 +101,19 @@ function makeCtx( }; } -function seedPending(contributionRkey: string): number { - const contributionAtUri = `at://${SUBMITTER_DID}/wiki.lichen.contribution/${contributionRkey}`; +function seedPending( + contributionRkey: string, + submitter = SUBMITTER_DID, +): number { + const contributionAtUri = `at://${submitter}/wiki.lichen.contribution/${contributionRkey}`; upsertPendingContribution( contributionAtUri, WIKI_AT_URI, WIKI_SLUG, - `at://${SUBMITTER_DID}/wiki.lichen.note/${contributionRkey}n`, - `at://${SUBMITTER_DID}/wiki.lichen.noteRevision/${contributionRkey}r`, + `at://${submitter}/wiki.lichen.note/${contributionRkey}n`, + `at://${submitter}/wiki.lichen.noteRevision/${contributionRkey}r`, "create-note", - SUBMITTER_DID, + submitter, ); const row = db .query("SELECT id FROM pending_contributions WHERE contribution_at_uri = ?") @@ -210,4 +217,40 @@ describe("approveContributionAction", () => { ), ).rejects.toBeInstanceOf(ForbiddenError); }); + + test("approving without promote does not grant a membership", async () => { + storeRecord("wiki.lichen.note", "c-approvenopromoten", { + slug: "plain-note", + title: "Plain", + wikiRef: WIKI_AT_URI, + createdAt: "2026-01-05T00:00:00.000Z", + }); + storeRecord("wiki.lichen.noteRevision", "c-approvenopromoter", { + noteRef: `at://${SUBMITTER_DID}/wiki.lichen.note/c-approvenopromoten`, + diff: createDiff("", "plain"), + diffFormat: "diff-match-patch", + createdAt: "2026-01-05T00:00:00.000Z", + }); + const id = seedPending("c-approvenopromote", "did:plc:submiter2"); + + await approveContributionAction(makeCtx(), id, { promote: false }); + + const member = db + .query("SELECT role FROM memberships WHERE wiki_at_uri = ? AND did = ?") + .get(WIKI_AT_URI, "did:plc:submiter2") as { role: string } | null; + expect(member).toBeNull(); + }); + + test("throws ValidationError once a contribution is already decided", async () => { + const id = seedPending("c-approve-decided"); + const contribAtUri = `at://${SUBMITTER_DID}/wiki.lichen.contribution/c-approve-decided`; + setContributionDecision(contribAtUri, "approved", OWNER_DID); + + await expect( + approveContributionAction(makeCtx(), id, { promote: false }), + ).rejects.toBeInstanceOf(ValidationError); + await expect( + rejectContributionAction(makeCtx(), id), + ).rejects.toBeInstanceOf(ValidationError); + }); });