diff --git a/docs/open-wiki-plan.md b/docs/open-wiki-plan.md index 5155faf..352c06b 100644 --- a/docs/open-wiki-plan.md +++ b/docs/open-wiki-plan.md @@ -142,14 +142,14 @@ Owner/admin-only (`canManage`): ## 8. Milestones / rollout -1. **Data model** — lexicon JSON (wiki flag + 2 new records), schema migration, handler validation. -2. **Access + submission** — `canSubmitContribution`, submission orchestrator + routes + DB write. -3. **Firehose ingest** — pending classification + contribution/approval ingestion. -4. **Review backend** — queue listing + approve/reject/promote orchestrators + routes. -5. **UI** — settings toggle, "Open Contribution" button + editor mode, admin queue views. -6. **i18n + polish.** -7. **Tests + guardrails** (rate limits, moderation hooks). -8. **E2E on this instance** — verify with real accounts on an open test wiki. +1. **[x] Data model** — lexicon JSON (wiki flag + 2 new records), schema migration, handler validation. +2. **[x] Access + submission** — `canSubmitContribution`, submission orchestrator + routes + DB write. +3. **[ ] Firehose ingest** — pending classification + contribution/approval ingestion. +4. **[ ] Review backend** — queue listing + approve/reject/promote orchestrators + routes. +5. **[ ] UI** — settings toggle, "Open Contribution" button + editor mode, admin queue views. +6. **[ ] i18n + polish.** +7. **[ ] 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/atproto/pds.ts b/src/atproto/pds.ts index e9058e0..5c2d979 100644 --- a/src/atproto/pds.ts +++ b/src/atproto/pds.ts @@ -141,6 +141,30 @@ export function writeCommunityBookmarkRecord( }); } +export interface StrongRef { + uri: string; + cid: string; +} + +export function writeContributionRecord( + rpc: Client, + did: string, + tid: string, + wikiRef: string, + kind: "create-note" | "edit-note", + note: StrongRef, + noteRevision: StrongRef, + createdAt: string, +): Promise { + return putRecord(rpc, did, COLLECTIONS.contribution, tid, { + wikiRef, + kind, + note: { $type: "com.atproto.repo.strongRef", ...note }, + noteRevision: { $type: "com.atproto.repo.strongRef", ...noteRevision }, + createdAt, + }); +} + export async function deleteRecord( rpc: Client, did: string, diff --git a/src/lib/access-resolve.ts b/src/lib/access-resolve.ts index b127961..14a1a7b 100644 --- a/src/lib/access-resolve.ts +++ b/src/lib/access-resolve.ts @@ -11,6 +11,7 @@ import { canEdit, canManage, canRead, + canSubmitContribution, getAccessLevel, type RequestContext, type WikiRequestContext, @@ -87,6 +88,7 @@ export async function resolveRequestContext( ...baseShared, wiki: null, access: "none", + canSubmitContribution: false, hasPendingRequest: false, }; } @@ -97,6 +99,7 @@ export async function resolveRequestContext( ...baseShared, wiki: null, access: "none", + canSubmitContribution: false, hasPendingRequest: false, }; } @@ -107,6 +110,7 @@ export async function resolveRequestContext( ...baseShared, wiki: null, access: "none", + canSubmitContribution: false, hasPendingRequest: false, }; } @@ -118,6 +122,7 @@ export async function resolveRequestContext( const role = did ? getMemberRole(wiki.at_uri, did) : null; const access = getAccessLevel(wiki, did, role); + const contributionOk = canSubmitContribution(wiki, did, role); const hasPendingRequest = access === "none" && did ? getRequest(wiki.at_uri, did) !== null : false; @@ -128,6 +133,7 @@ export async function resolveRequestContext( wiki, ownerHandle: profile.handle, access, + canSubmitContribution: contributionOk, hasPendingRequest, }; } diff --git a/src/lib/access.ts b/src/lib/access.ts index 6f6fd31..b32b25b 100644 --- a/src/lib/access.ts +++ b/src/lib/access.ts @@ -45,11 +45,28 @@ export function canManage(level: AccessLevel): boolean { return level === "admin"; } +// Open-wiki contributions: an authenticated non-contributor on a public, open wiki +// may submit a change for review rather than edit it live. Owners and admin/ +// contributor members always edit directly, so they return false here. +export function canSubmitContribution( + wiki: Pick, + userDid: string | null, + memberRole: string | null, +): boolean { + if (!userDid) return false; + if (wiki.did === userDid) return false; + if (memberRole === "admin" || memberRole === "contributor") return false; + if (wiki.visibility !== "public" || !wiki.contributions_open) return false; + return true; +} + export interface RequestContext { session: Session | null; wiki: WikiRow | null; did: string | null; access: AccessLevel; + /** True when this (authenticated) user may submit an open contribution on this wiki. */ + canSubmitContribution: boolean; locale: Locale; userTheme: UserTheme; hasPendingRequest: boolean; diff --git a/src/lib/collections.ts b/src/lib/collections.ts index 66f40e4..840d01e 100644 --- a/src/lib/collections.ts +++ b/src/lib/collections.ts @@ -10,6 +10,10 @@ export const COLLECTIONS = { // External NSID we don't own or publish (lexicon-community). All new bookmarks // are minted and ingested here. communityBookmark: "community.lexicon.bookmarks.bookmark", + // Open-wiki contribution review: the submitter's envelope and the owner/admin's + // decision. Web, PoC — enforcement lives in handler code and the appview queue. + contribution: "wiki.lichen.contribution", + contributionApproval: "wiki.lichen.contributionApproval", } as const; export const OAUTH_SCOPE = `atproto include:wiki.lichen.permissions include:community.lexicon.bookmarks.authManageBookmarks blob:*/*`; diff --git a/src/lib/orchestrators/note.ts b/src/lib/orchestrators/note.ts index 5b6adfe..dc3ab53 100644 --- a/src/lib/orchestrators/note.ts +++ b/src/lib/orchestrators/note.ts @@ -1,6 +1,10 @@ +import { ok } from "@atcute/client"; +import type { Did, Nsid } from "@atcute/lexicons/syntax"; import * as TID from "@atcute/tid"; import { deleteRecord, + type StrongRef, + writeContributionRecord, writeNoteRecord, writeRevisionRecord, } from "../../atproto/pds.ts"; @@ -10,6 +14,7 @@ import { deleteNoteByAtUri, getCurrentNote, getNoteBySlug, + insertPendingContribution, saveNoteEdit, } from "../../server/db/queries/index.ts"; import type { WikiRequestContext } from "../access.ts"; @@ -19,6 +24,7 @@ import { buildBlobsForContent, parseBlobMetadata, } from "../attachments.ts"; +import { resolvePdsClient } from "../backfill/pds-client.ts"; import { COLLECTIONS } from "../collections.ts"; import { createDiff } from "../diff.ts"; import { @@ -86,10 +92,10 @@ async function writePdsRevision( newContent: string, message: string | undefined, blobs: ReturnType, -): Promise { +): Promise<{ cid: string }> { const diff = createDiff(oldContent, newContent); const now = new Date().toISOString(); - await writeRevisionRecord( + return writeRevisionRecord( agent, did, revisionTid, @@ -102,6 +108,70 @@ async function writePdsRevision( ); } +// Resolve a note record's current cid from its PDS (public read). Used so an +// edit contribution's `note` strongRef is valid even when the note belongs to +// another author's repo. +async function resolveNoteStrongRef(noteAtUri: string): Promise { + const parsed = parseAtUri(noteAtUri); + if (!parsed || !parsed.rkey) { + throw new ValidationError("Invalid note at-uri"); + } + const resolved = await resolvePdsClient(parsed.did); + if (!resolved) { + throw new ValidationError("Could not resolve PDS for note"); + } + const res = await ok( + resolved.client.get("com.atproto.repo.getRecord", { + params: { + repo: parsed.did as Did, + collection: "wiki.lichen.note" as Nsid, + rkey: parsed.rkey, + }, + }), + ); + return { uri: noteAtUri, cid: res.cid ?? "" }; +} + +// Envelope a submitted change as a wiki.lichen.contribution on the submitter's +// PDS and add it to the open-contribution review queue. Never goes live. +async function submitContribution( + ctx: WikiRequestContext, + submitterDid: string, + createdAt: string, + kind: "create-note" | "edit-note", + note: StrongRef, + noteRevision: StrongRef, +): Promise { + const contributionTid = TID.now(); + const contributionAtUri = `at://${submitterDid}/wiki.lichen.contribution/${contributionTid}`; + + const agent = ctx.session ? getAgent(ctx.session) : null; + if (agent) { + await withPdsError("submit contribution", async () => { + await writeContributionRecord( + agent, + submitterDid, + contributionTid, + ctx.wiki.at_uri, + kind, + note, + noteRevision, + createdAt, + ); + }); + } + + insertPendingContribution( + contributionAtUri, + ctx.wiki.at_uri, + ctx.wiki.slug, + note.uri, + noteRevision.uri, + kind, + submitterDid, + ); +} + export async function createNoteAction( ctx: WikiRequestContext, fields: NoteFormFields, @@ -127,10 +197,13 @@ export async function createNoteAction( const revisionAtUri = `at://${did}/wiki.lichen.noteRevision/${revisionTid}`; const agent = ctx.session ? getAgent(ctx.session) : null; + let noteCid: string | null = null; + let revisionCid: string | null = null; + if (agent) { const now = new Date().toISOString(); await withPdsError("create note", async () => { - await writeNoteRecord( + const noteRes = await writeNoteRecord( agent, did, noteTid, @@ -139,7 +212,7 @@ export async function createNoteAction( ctx.wiki.at_uri, now, ); - await writePdsRevision( + const revRes = await writePdsRevision( agent, did, revisionTid, @@ -150,9 +223,26 @@ export async function createNoteAction( fields.message, blobs, ); + noteCid = noteRes.cid; + revisionCid = revRes.cid; }); } + // Open-wiki contribution (non-contributor on an open wiki): the note and its + // first revision are already on the submitter's PDS; envelope them and queue + // for review rather than publishing live. + if (ctx.canSubmitContribution) { + await submitContribution( + ctx, + did, + new Date().toISOString(), + "create-note", + { uri: noteAtUri, cid: noteCid ?? "" }, + { uri: revisionAtUri, cid: revisionCid ?? "" }, + ); + return { noteSlug }; + } + try { createNote( noteAtUri, @@ -213,9 +303,15 @@ export async function editNoteAction( const currentContent = currentNote?.content ?? ""; const agent = ctx.session ? getAgent(ctx.session) : null; + + // Contribution edits propose content only — the note usually belongs to + // another author's repo, so a non-contributor can't rewrite the note record. + const contributionMode = ctx.canSubmitContribution; + + let revisionCid: string | null = null; if (agent) { await withPdsError("edit note", async () => { - await writePdsRevision( + const revRes = await writePdsRevision( agent, did, revisionTid, @@ -226,8 +322,9 @@ export async function editNoteAction( fields.message, blobs, ); + revisionCid = revRes.cid; - if (newTitle) { + if (!contributionMode && newTitle) { const parsed = parseAtUri(note.at_uri); if (!parsed) { throw new Error("Invalid at_uri: cannot parse"); @@ -245,6 +342,19 @@ export async function editNoteAction( }); } + if (contributionMode) { + const noteRef = await resolveNoteStrongRef(note.at_uri); + await submitContribution( + ctx, + did, + new Date().toISOString(), + "edit-note", + noteRef, + { uri: revisionAtUri, cid: revisionCid ?? "" }, + ); + return; + } + saveNoteEdit( revisionAtUri, ctx.wiki.at_uri, diff --git a/src/server/db/queries/index.ts b/src/server/db/queries/index.ts index 2268f0c..4771b51 100644 --- a/src/server/db/queries/index.ts +++ b/src/server/db/queries/index.ts @@ -54,6 +54,12 @@ export { searchNotes, upsertNote, } from "./note.ts"; +export { + type ContributionKind, + type ContributionStatus, + insertPendingContribution, + type PendingContributionRow, +} from "./pending-contribution.ts"; export { expireCachedProfile, getCachedProfile, diff --git a/src/server/db/queries/pending-contribution.ts b/src/server/db/queries/pending-contribution.ts new file mode 100644 index 0000000..1182f5c --- /dev/null +++ b/src/server/db/queries/pending-contribution.ts @@ -0,0 +1,45 @@ +import { getDb } from "../index.ts"; + +export type ContributionKind = "create-note" | "edit-note"; +export type ContributionStatus = "pending" | "approved" | "rejected"; + +export interface PendingContributionRow { + id: number; + contribution_at_uri: string; + wiki_at_uri: string; + wiki_slug: string; + note_at_uri: string; + note_revision_at_uri: string; + kind: ContributionKind; + submitter_did: string; + status: ContributionStatus; + approved_at: string | null; + approved_by_did: string | null; + created_at: string; +} + +export function insertPendingContribution( + contributionAtUri: string, + wikiAtUri: string, + wikiSlug: string, + noteAtUri: string, + noteRevisionAtUri: string, + kind: ContributionKind, + submitterDid: string, +): void { + const db = getDb(); + db.run( + `INSERT INTO pending_contributions + (contribution_at_uri, wiki_at_uri, wiki_slug, note_at_uri, note_revision_at_uri, kind, submitter_did) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + contributionAtUri, + wikiAtUri, + wikiSlug, + noteAtUri, + noteRevisionAtUri, + kind, + submitterDid, + ], + ); +} diff --git a/src/server/routes/note.ts b/src/server/routes/note.ts index e0f6be9..9a6ed52 100644 --- a/src/server/routes/note.ts +++ b/src/server/routes/note.ts @@ -2,7 +2,11 @@ import { Elysia } from "elysia"; import { resolveWikiContext } from "../../lib/access-resolve.ts"; import { EDITOR_SCRIPTS, KATEX_STYLESHEETS } from "../../lib/assets.ts"; import { verifyCsrfForm } from "../../lib/csrf.ts"; -import { NotFoundError, ValidationError } from "../../lib/errors.ts"; +import { + ForbiddenError, + NotFoundError, + ValidationError, +} from "../../lib/errors.ts"; import { t } from "../../lib/i18n/index.ts"; import { exportNoteMd } from "../../lib/import-export/export.ts"; import { @@ -44,10 +48,30 @@ const EDITOR_LAYOUT_EXTRAS = { stylesheets: KATEX_STYLESHEETS, }; +// Resolve a wiki for editing-or-contribution: contributors/admins edit live, and +// an authenticated non-contributor may submit an open contribution on an open wiki. +// Everyone else gets a 403. +async function resolveEditOrContribute( + request: Request, + urlHandle: string, + wikiSlug: string, +): Promise>> { + const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "read"); + if (!ctx.session) throw new ForbiddenError(); + if ( + ctx.access === "admin" || + ctx.access === "edit" || + ctx.canSubmitContribution + ) { + return ctx; + } + throw new ForbiddenError(); +} + export const noteRoutes = new Elysia() .get("/@:handle/:wikiSlug/new", async ({ params, request }) => { const { handle: urlHandle, wikiSlug } = hp(params); - const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "edit"); + const ctx = await resolveEditOrContribute(request, urlHandle, wikiSlug); return htmlResponse( newNotePage(wikiIdentity(ctx), { @@ -58,7 +82,7 @@ export const noteRoutes = new Elysia() }) .post("/@:handle/:wikiSlug/new", async ({ params, request }) => { const { handle: urlHandle, wikiSlug } = hp(params); - const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "edit"); + const ctx = await resolveEditOrContribute(request, urlHandle, wikiSlug); const msg = t(ctx.locale); const formData = await verifyCsrfForm(request, ctx.did); @@ -66,6 +90,12 @@ export const noteRoutes = new Elysia() try { const { noteSlug } = await createNoteAction(ctx, fields, msg); + if (ctx.canSubmitContribution) { + // Submitted for review, not live yet. + return redirect( + `${wikiUrl(ctx.ownerHandle, ctx.wiki.slug)}?submitted=1`, + ); + } // Drop the author straight into the live collaborative editor: the note // now exists on the wiki, and its body is written there, not here. return redirect(editNoteUrl(ctx.ownerHandle, ctx.wiki.slug, noteSlug)); @@ -86,26 +116,29 @@ export const noteRoutes = new Elysia() }) .get("/@:handle/:wikiSlug/:noteSlug/edit", async ({ params, request }) => { const { handle: urlHandle, wikiSlug, noteSlug } = hwnp(params); - const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "edit"); + const ctx = await resolveEditOrContribute(request, urlHandle, wikiSlug); const data = getNoteWithCurrent(ctx.wiki.at_uri, noteSlug); if (!data) throw new NotFoundError("Note not found", { i18nKey: "noteNotFound" }); // Live collaboration is always on for anyone with edit access — there is - // no separate "solo" mode. The WS is access-checked again on connect, and - // the client falls back to a plain editor if the socket can't be opened. - const collab = ctx.did - ? { - enabled: true, - wsPath: collabWsPath(ctx.ownerHandle, ctx.wiki.slug, noteSlug), - did: ctx.did, - handle: ctx.session?.handle ?? ctx.did, - displayName: "", - avatar: ctx.session?.avatar ?? "", - noteUrl: noteUrl(ctx.ownerHandle, ctx.wiki.slug, noteSlug), - } - : undefined; + // no separate "solo" mode. Contributions submit for review, so they use a + // plain solo editor (no collab WS). The WS is access-checked again on + // connect, and the client falls back to a plain editor if it can't open. + const isLiveEdit = ctx.access === "admin" || ctx.access === "edit"; + const collab = + isLiveEdit && ctx.did + ? { + enabled: true, + wsPath: collabWsPath(ctx.ownerHandle, ctx.wiki.slug, noteSlug), + did: ctx.did, + handle: ctx.session?.handle ?? ctx.did, + displayName: "", + avatar: ctx.session?.avatar ?? "", + noteUrl: noteUrl(ctx.ownerHandle, ctx.wiki.slug, noteSlug), + } + : undefined; return htmlResponse( editNotePage( @@ -120,14 +153,15 @@ export const noteRoutes = new Elysia() }) .post("/@:handle/:wikiSlug/:noteSlug/edit", async ({ params, request }) => { const { handle: urlHandle, wikiSlug, noteSlug } = hwnp(params); - const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "edit"); + const ctx = await resolveEditOrContribute(request, urlHandle, wikiSlug); const msg = t(ctx.locale); const formData = await verifyCsrfForm(request, ctx.did); const fields = parseNoteFormFields(formData); await editNoteAction(ctx, noteSlug, fields, msg); - return redirect(noteUrl(ctx.ownerHandle, ctx.wiki.slug, noteSlug)); + const base = noteUrl(ctx.ownerHandle, ctx.wiki.slug, noteSlug); + return redirect(ctx.canSubmitContribution ? `${base}?submitted=1` : base); }) .get( "/@:handle/:wikiSlug/:noteSlug/-/export", diff --git a/tests/lib/import-export/export.test.ts b/tests/lib/import-export/export.test.ts index fce8832..dfee826 100644 --- a/tests/lib/import-export/export.test.ts +++ b/tests/lib/import-export/export.test.ts @@ -71,6 +71,7 @@ function makeCtx() { locale: "en" as const, userTheme: "system" as const, hasPendingRequest: false, + canSubmitContribution: false, csrfToken: null, }; } diff --git a/tests/lib/import-export/import.test.ts b/tests/lib/import-export/import.test.ts index 4a6f9db..da0b3f2 100644 --- a/tests/lib/import-export/import.test.ts +++ b/tests/lib/import-export/import.test.ts @@ -72,6 +72,7 @@ function makeCtx(overrides: Partial = {}): RequestContext { locale: "en" as const, userTheme: "system" as const, hasPendingRequest: false, + canSubmitContribution: false, csrfToken: null, ...overrides, }; diff --git a/tests/lib/orchestrators/membership.test.ts b/tests/lib/orchestrators/membership.test.ts index 8f48313..bb685d4 100644 --- a/tests/lib/orchestrators/membership.test.ts +++ b/tests/lib/orchestrators/membership.test.ts @@ -75,6 +75,7 @@ function makeCtx( locale: "en", userTheme: "system", hasPendingRequest: false, + canSubmitContribution: false, csrfToken: null, ...overrides, }; diff --git a/tests/lib/orchestrators/note.test.ts b/tests/lib/orchestrators/note.test.ts index c7935c7..a59a7f3 100644 --- a/tests/lib/orchestrators/note.test.ts +++ b/tests/lib/orchestrators/note.test.ts @@ -70,6 +70,7 @@ function makeCtx( locale: "en", userTheme: "system", hasPendingRequest: false, + canSubmitContribution: false, csrfToken: null, ...overrides, }; @@ -324,3 +325,55 @@ describe("deleteNoteAction", () => { ).rejects.toBeInstanceOf(NotFoundError); }); }); + +describe("open-contribution submission (create)", () => { + test("createNoteAction routes to pending_contributions, not live, for a non-contributor", async () => { + const db = getDb(); + const wikiAtUri = `at://${WIKI_DID}/wiki.lichen.wiki/${WIKI_SLUG}`; + + const { noteSlug } = await createNoteAction( + makeCtx({ + session: null, + access: "read", + canSubmitContribution: true, + }), + { title: "Open Contributed Note", content: "hello", blobMeta: {} }, + dummyMsg, + ); + + // The note is NOT live in the wiki. + const live = db + .query("SELECT * FROM notes WHERE wiki_at_uri = ? AND slug = ?") + .get(wikiAtUri, noteSlug); + expect(live).toBeNull(); + + // Instead it queued as a pending create-note contribution. + const pending = db + .query( + "SELECT * FROM pending_contributions WHERE wiki_at_uri = ? AND kind = 'create-note' AND status = 'pending'", + ) + .all(wikiAtUri) as { submitter_did: string; note_at_uri: string }[]; + expect(pending.length).toBe(1); + const first = pending[0]; + expect(first?.submitter_did).toBe(WIKI_DID); + expect( + first?.note_at_uri.startsWith(`at://${WIKI_DID}/wiki.lichen.note/`), + ).toBe(true); + }); + + test("a contributor still creates a live note (no pending row)", async () => { + const db = getDb(); + const wikiAtUri = `at://${WIKI_DID}/wiki.lichen.wiki/${WIKI_SLUG}`; + + const { noteSlug } = await createNoteAction( + makeCtx({ session: null, access: "edit", canSubmitContribution: false }), + { title: "Live Contributor Note", content: "hi", blobMeta: {} }, + dummyMsg, + ); + + const live = db + .query("SELECT * FROM notes WHERE wiki_at_uri = ? AND slug = ?") + .get(wikiAtUri, noteSlug); + expect(live).not.toBeNull(); + }); +}); diff --git a/tests/lib/orchestrators/wiki.test.ts b/tests/lib/orchestrators/wiki.test.ts index 90a82fe..a7c2623 100644 --- a/tests/lib/orchestrators/wiki.test.ts +++ b/tests/lib/orchestrators/wiki.test.ts @@ -79,6 +79,7 @@ function makeCtx(overrides: Partial = {}): RequestContext { locale: "en", userTheme: "system", hasPendingRequest: false, + canSubmitContribution: false, csrfToken: null, ...overrides, }; @@ -97,6 +98,7 @@ function makeWikiCtx( locale: "en", userTheme: "system", hasPendingRequest: false, + canSubmitContribution: false, csrfToken: null, ...overrides, };