🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add membership management in settings

juprodh 00005584 544c7ef7

+527 -7
+6
src/lib/i18n/en.ts
··· 64 64 noMembers: "No members yet.", 65 65 noRequests: "No pending requests.", 66 66 cannotRemoveOwner: "Cannot remove the wiki owner.", 67 + changeRole: "Change role", 68 + saveRole: "Save", 69 + addMember: "Add member", 70 + addMemberLabel: "Handle or DID", 71 + addMemberPlaceholder: "@alice.bsky.social or did:plc:…", 72 + add: "Add", 67 73 }, 68 74 error: { 69 75 titleRequired: "Title is required.",
+6
src/lib/i18n/fr.ts
··· 64 64 noMembers: "Aucun membre pour le moment.", 65 65 noRequests: "Aucune demande en attente.", 66 66 cannotRemoveOwner: "Impossible de supprimer le propriétaire du wiki.", 67 + changeRole: "Changer le rôle", 68 + saveRole: "Enregistrer", 69 + addMember: "Ajouter un membre", 70 + addMemberLabel: "Identifiant ou DID", 71 + addMemberPlaceholder: "@alice.bsky.social ou did:plc:…", 72 + add: "Ajouter", 67 73 }, 68 74 error: { 69 75 titleRequired: "Le titre est requis.",
+6
src/lib/i18n/index.ts
··· 68 68 noMembers: string; 69 69 noRequests: string; 70 70 cannotRemoveOwner: string; 71 + changeRole: string; 72 + saveRole: string; 73 + addMember: string; 74 + addMemberLabel: string; 75 + addMemberPlaceholder: string; 76 + add: string; 71 77 }; 72 78 error: { 73 79 titleRequired: string;
+105
src/lib/orchestrators/membership.ts
··· 7 7 import { 8 8 deleteMembership, 9 9 deleteRequest, 10 + getMembership, 10 11 upsertMembership, 11 12 upsertRequest, 12 13 } from "../../server/db/queries/index.ts"; ··· 93 94 // DB write 94 95 deleteRequest(ctx.wiki.slug, memberDid); 95 96 upsertMembership(ctx.wiki.slug, memberDid, role, membershipAtUri, now); 97 + } 98 + 99 + /** 100 + * Change a member's role. PDS: delete old record (best-effort) + write new → DB update. 101 + * Throws ValidationError if trying to change owner's role, 102 + * NotFoundError if membership not found, 103 + * PdsWriteError on PDS write failure. 104 + */ 105 + export async function changeMemberRoleAction( 106 + ctx: WikiRequestContext, 107 + memberDid: string, 108 + newRole: "admin" | "contributor" | "viewer", 109 + ): Promise<void> { 110 + if (memberDid === ctx.wiki.did) { 111 + throw new ValidationError("Cannot change the wiki owner's role"); 112 + } 113 + 114 + const existing = getMembership(ctx.wiki.slug, memberDid); 115 + if (!existing) { 116 + throw new NotFoundError("Membership not found"); 117 + } 118 + 119 + const newTid = generateTid(); 120 + const newAtUri = `at://${ctx.effectiveDid}/pub.coral.membership/${newTid}`; 121 + 122 + if (ctx.session) { 123 + // Best-effort: delete old PDS record if we own it 124 + const parsed = parseAtUri(existing.at_uri); 125 + if (parsed && parsed.did === ctx.session.did) { 126 + try { 127 + const agent = await getAgent(ctx.session); 128 + await deleteRecord( 129 + agent, 130 + parsed.did, 131 + COLLECTIONS.membership, 132 + parsed.rkey, 133 + ); 134 + } catch { 135 + // ignore — old record stays on PDS but DB is authoritative 136 + } 137 + } 138 + 139 + // Write new PDS record with updated role 140 + try { 141 + const agent = await getAgent(ctx.session); 142 + await writeMembershipRecord( 143 + agent, 144 + ctx.session.did, 145 + newTid, 146 + memberDid, 147 + ctx.wiki.at_uri, 148 + newRole, 149 + existing.created_at, 150 + ); 151 + } catch (err) { 152 + throw new PdsWriteError( 153 + `Failed to write membership to PDS: ${formatError(err)}`, 154 + ); 155 + } 156 + } 157 + 158 + upsertMembership( 159 + ctx.wiki.slug, 160 + memberDid, 161 + newRole, 162 + newAtUri, 163 + existing.created_at, 164 + ); 165 + } 166 + 167 + /** 168 + * Add a member directly (admin-initiated, no request required). PDS write → DB write. 169 + * Idempotent: re-adding an existing member updates their role. 170 + * Throws PdsWriteError on PDS failure. 171 + */ 172 + export async function addMemberAction( 173 + ctx: WikiRequestContext, 174 + memberDid: string, 175 + role: "admin" | "contributor" | "viewer" = "contributor", 176 + ): Promise<void> { 177 + const now = new Date().toISOString(); 178 + const tid = generateTid(); 179 + const atUri = `at://${ctx.effectiveDid}/pub.coral.membership/${tid}`; 180 + 181 + if (ctx.session) { 182 + try { 183 + const agent = await getAgent(ctx.session); 184 + await writeMembershipRecord( 185 + agent, 186 + ctx.session.did, 187 + tid, 188 + memberDid, 189 + ctx.wiki.at_uri, 190 + role, 191 + now, 192 + ); 193 + } catch (err) { 194 + throw new PdsWriteError( 195 + `Failed to write membership to PDS: ${formatError(err)}`, 196 + ); 197 + } 198 + } 199 + 200 + upsertMembership(ctx.wiki.slug, memberDid, role, atUri, now); 96 201 } 97 202 98 203 /**
+20
src/lib/profile.ts
··· 9 9 const idResolver = new IdResolver(); 10 10 11 11 /** 12 + * Resolve a handle or DID string to a DID. 13 + * If input already starts with "did:", returns it unchanged. 14 + * Returns null if the handle cannot be resolved. 15 + */ 16 + export async function resolveHandleToDid( 17 + handleOrDid: string, 18 + ): Promise<string | null> { 19 + const normalized = handleOrDid.startsWith("@") 20 + ? handleOrDid.slice(1) 21 + : handleOrDid; 22 + if (normalized.startsWith("did:")) return normalized; 23 + try { 24 + const did = await idResolver.handle.resolve(normalized); 25 + return did ?? null; 26 + } catch { 27 + return null; 28 + } 29 + } 30 + 31 + /** 12 32 * Resolve a DID to profile info (handle, displayName, avatar). 13 33 * Fetches the DID document for the handle, then queries the Bluesky AppView 14 34 * for avatar and display name. Returns nulls on any failure — never throws.
+1
src/server/db/queries/index.ts
··· 17 17 deleteRequest, 18 18 deleteRequestByUri, 19 19 getMemberRole, 20 + getMembership, 20 21 getRequest, 21 22 listMembers, 22 23 listRequests,
+12
src/server/db/queries/membership.ts
··· 44 44 db.run("DELETE FROM requests WHERE at_uri = ?", [atUri]); 45 45 } 46 46 47 + export function getMembership( 48 + wikiSlug: string, 49 + did: string, 50 + ): MembershipRow | null { 51 + const db = getDb(); 52 + return ( 53 + (db 54 + .query("SELECT * FROM memberships WHERE wiki_slug = ? AND did = ?") 55 + .get(wikiSlug, did) as MembershipRow) ?? null 56 + ); 57 + } 58 + 47 59 export function getMemberRole(wikiSlug: string, did: string): string | null { 48 60 const db = getDb(); 49 61 const row = db
+41 -3
src/server/routes/membership.ts
··· 4 4 resolveWikiContext, 5 5 type WikiRequestContext, 6 6 } from "../../lib/access.ts"; 7 - import { NotFoundError } from "../../lib/errors.ts"; 7 + import { NotFoundError, ValidationError } from "../../lib/errors.ts"; 8 8 import { 9 + addMemberAction, 9 10 approveMemberAction, 11 + changeMemberRoleAction, 10 12 removeMemberAction, 11 13 requestAccessAction, 12 14 } from "../../lib/orchestrators/membership.ts"; 13 - import { resolveProfiles } from "../../lib/profile.ts"; 15 + import { resolveHandleToDid, resolveProfiles } from "../../lib/profile.ts"; 14 16 import { htmlResponse } from "../../lib/response.ts"; 15 17 import { membersUrl, redirect, wikiUrl } from "../../lib/urls.ts"; 16 18 import { membersPage } from "../../views/members.ts"; ··· 73 75 74 76 return redirect(membersUrl(params.wikiSlug)); 75 77 }, 76 - ); 78 + ) 79 + .post( 80 + "/:wikiSlug/-/members/:memberDid/change-role", 81 + async ({ params, request }) => { 82 + const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); 83 + const memberDid = decodeURIComponent(params.memberDid); 84 + const formData = await request.formData(); 85 + const roleRaw = (formData.get("role") as string | null) ?? "contributor"; 86 + const role = 87 + roleRaw === "admin" || roleRaw === "viewer" ? roleRaw : "contributor"; 88 + 89 + await changeMemberRoleAction(ctx, memberDid, role); 90 + 91 + return redirect(membersUrl(params.wikiSlug)); 92 + }, 93 + ) 94 + .post("/:wikiSlug/-/members/add", async ({ params, request }) => { 95 + const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); 96 + const formData = await request.formData(); 97 + const handleOrDid = ((formData.get("did") as string | null) ?? "").trim(); 98 + const roleRaw = (formData.get("role") as string | null) ?? "contributor"; 99 + const role = 100 + roleRaw === "admin" || roleRaw === "viewer" ? roleRaw : "contributor"; 101 + 102 + if (!handleOrDid) { 103 + throw new ValidationError("Handle or DID is required"); 104 + } 105 + 106 + const memberDid = await resolveHandleToDid(handleOrDid); 107 + if (!memberDid) { 108 + throw new ValidationError(`Cannot resolve "${handleOrDid}" to a DID`); 109 + } 110 + 111 + await addMemberAction(ctx, memberDid, role); 112 + 113 + return redirect(membersUrl(params.wikiSlug)); 114 + });
+40 -2
src/views/members.ts
··· 38 38 const locale = options.locale ?? "en"; 39 39 const msg = t(locale); 40 40 41 + const roleOptions = (current: string) => 42 + ["contributor", "admin", "viewer"] 43 + .map( 44 + (r) => 45 + `<option value="${r}"${r === current ? " selected" : ""}>${r}</option>`, 46 + ) 47 + .join(""); 48 + 41 49 const memberRows = members 42 50 .map((m) => { 43 51 const isOwner = m.did === options.wikiDid; 52 + const roleCell = isOwner 53 + ? `<span class="text-sm">${escapeHtml(m.role)}</span>` 54 + : `<form method="POST" action="/wiki/${wikiSlug}/-/members/${encodeURIComponent(m.did)}/change-role" class="inline-flex items-center gap-1"> 55 + <select name="role" class="text-xs border border-${THEME.borderDefault} rounded px-1 py-0.5"> 56 + ${roleOptions(m.role)} 57 + </select> 58 + <button type="submit" class="text-xs text-${THEME.accent} hover:underline">${msg.access.saveRole}</button> 59 + </form>`; 44 60 const removeButton = isOwner 45 61 ? `<span class="text-xs text-${THEME.textMuted}" title="${msg.access.cannotRemoveOwner}">—</span>` 46 62 : `<form method="POST" action="/wiki/${wikiSlug}/-/members/${encodeURIComponent(m.did)}/remove" class="inline"> ··· 48 64 </form>`; 49 65 return `<tr class="border-b border-${THEME.borderSubtle}"> 50 66 <td class="py-2 pr-4">${renderIdentity(m.did, profiles.get(m.did))}</td> 51 - <td class="py-2 pr-4 text-sm">${escapeHtml(m.role)}</td> 67 + <td class="py-2 pr-4">${roleCell}</td> 52 68 <td class="py-2 pr-4 text-sm text-${THEME.textMuted}">${escapeHtml(m.created_at)}</td> 53 69 <td class="py-2 text-sm">${removeButton}</td> 54 70 </tr>`; ··· 94 110 </table> 95 111 96 112 <h2 class="text-lg font-semibold mb-4">${msg.access.pendingRequests}</h2> 97 - <table class="w-full"> 113 + <table class="w-full mb-10"> 98 114 <thead> 99 115 <tr class="border-b border-${THEME.borderDefault} text-left text-xs font-semibold text-${THEME.textMuted} uppercase tracking-wide"> 100 116 <th class="pb-2 pr-4">${msg.access.member}</th> ··· 106 122 ${requestRows || `<tr><td colspan="3" class="py-4 text-sm text-${THEME.textMuted}">${msg.access.noRequests}</td></tr>`} 107 123 </tbody> 108 124 </table> 125 + 126 + <h2 class="text-lg font-semibold mb-4">${msg.access.addMember}</h2> 127 + <form method="POST" action="/wiki/${wikiSlug}/-/members/add" class="flex items-end gap-3 flex-wrap"> 128 + <div class="flex flex-col gap-1"> 129 + <label class="text-xs font-medium text-${THEME.textMuted}">${msg.access.addMemberLabel}</label> 130 + <input type="text" name="did" required autocomplete="off" 131 + placeholder="${msg.access.addMemberPlaceholder}" 132 + class="px-3 py-1.5 text-sm border border-${THEME.borderInput} rounded focus:outline-none focus:border-${THEME.accentBorder} w-72" /> 133 + </div> 134 + <div class="flex flex-col gap-1"> 135 + <label class="text-xs font-medium text-${THEME.textMuted}">${msg.access.role}</label> 136 + <select name="role" class="text-sm border border-${THEME.borderInput} rounded px-2 py-1.5 focus:outline-none focus:border-${THEME.accentBorder}"> 137 + <option value="contributor">contributor</option> 138 + <option value="admin">admin</option> 139 + <option value="viewer">viewer</option> 140 + </select> 141 + </div> 142 + <button type="submit" 143 + class="px-4 py-1.5 bg-${THEME.accent} text-white text-sm font-medium rounded hover:opacity-90"> 144 + ${msg.access.add} 145 + </button> 146 + </form> 109 147 `, 110 148 { ...options, wikiName, wikiSlug }, 111 149 );
+233 -2
tests/lib/orchestrators/membership.test.ts
··· 29 29 getAgent: mockGetAgent, 30 30 })); 31 31 32 - const { requestAccessAction, approveMemberAction, removeMemberAction } = 33 - await import("../../../src/lib/orchestrators/membership.ts"); 32 + const { 33 + requestAccessAction, 34 + approveMemberAction, 35 + changeMemberRoleAction, 36 + addMemberAction, 37 + removeMemberAction, 38 + } = await import("../../../src/lib/orchestrators/membership.ts"); 34 39 35 40 const { ForbiddenError, NotFoundError, PdsWriteError, ValidationError } = 36 41 await import("../../../src/lib/errors.ts"); ··· 175 180 176 181 expect( 177 182 approveMemberAction(makeCtx(), "did:plc:pdsfail"), 183 + ).rejects.toBeInstanceOf(PdsWriteError); 184 + }); 185 + }); 186 + 187 + describe("changeMemberRoleAction", () => { 188 + test("updates role in DB and writes new PDS record", async () => { 189 + const db = getDb(); 190 + db.run( 191 + `INSERT OR REPLACE INTO memberships (wiki_slug, did, role, at_uri, created_at) 192 + VALUES (?, ?, ?, ?, ?)`, 193 + [ 194 + WIKI_SLUG, 195 + "did:plc:role-change", 196 + "contributor", 197 + `at://${ADMIN_DID}/pub.coral.membership/role-change-tid`, 198 + "2026-01-01T00:00:00.000Z", 199 + ], 200 + ); 201 + 202 + mockWriteMembershipRecord.mockClear(); 203 + mockDeleteRecord.mockClear(); 204 + 205 + await changeMemberRoleAction(makeCtx(), "did:plc:role-change", "admin"); 206 + 207 + expect(mockWriteMembershipRecord).toHaveBeenCalledTimes(1); 208 + 209 + const row = db 210 + .query("SELECT role FROM memberships WHERE wiki_slug = ? AND did = ?") 211 + .get(WIKI_SLUG, "did:plc:role-change") as { role: string } | null; 212 + expect(row?.role).toBe("admin"); 213 + }); 214 + 215 + test("deletes old PDS record when owned by current admin", async () => { 216 + const db = getDb(); 217 + db.run( 218 + `INSERT OR REPLACE INTO memberships (wiki_slug, did, role, at_uri, created_at) 219 + VALUES (?, ?, ?, ?, ?)`, 220 + [ 221 + WIKI_SLUG, 222 + "did:plc:role-change-pds", 223 + "contributor", 224 + `at://${ADMIN_DID}/pub.coral.membership/owned-tid`, 225 + "2026-01-01T00:00:00.000Z", 226 + ], 227 + ); 228 + 229 + mockDeleteRecord.mockClear(); 230 + 231 + await changeMemberRoleAction( 232 + makeCtx(), 233 + "did:plc:role-change-pds", 234 + "viewer", 235 + ); 236 + 237 + // Old record belongs to ADMIN_DID so delete should be called 238 + expect(mockDeleteRecord).toHaveBeenCalledTimes(1); 239 + const deleteArgs = mockDeleteRecord.mock.calls[0] as unknown[]; 240 + expect(deleteArgs[3]).toBe("owned-tid"); 241 + }); 242 + 243 + test("skips PDS delete when old record belongs to different admin", async () => { 244 + const db = getDb(); 245 + db.run( 246 + `INSERT OR REPLACE INTO memberships (wiki_slug, did, role, at_uri, created_at) 247 + VALUES (?, ?, ?, ?, ?)`, 248 + [ 249 + WIKI_SLUG, 250 + "did:plc:role-change-other", 251 + "contributor", 252 + `at://did:plc:other-admin/pub.coral.membership/other-admin-tid`, 253 + "2026-01-01T00:00:00.000Z", 254 + ], 255 + ); 256 + 257 + mockDeleteRecord.mockClear(); 258 + 259 + await changeMemberRoleAction( 260 + makeCtx(), 261 + "did:plc:role-change-other", 262 + "viewer", 263 + ); 264 + 265 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 266 + }); 267 + 268 + test("throws ValidationError when changing owner's role", async () => { 269 + expect( 270 + changeMemberRoleAction(makeCtx(), OWNER_DID, "contributor"), 271 + ).rejects.toBeInstanceOf(ValidationError); 272 + }); 273 + 274 + test("throws NotFoundError when membership not found", async () => { 275 + expect( 276 + changeMemberRoleAction(makeCtx(), "did:plc:nobody", "admin"), 277 + ).rejects.toBeInstanceOf(NotFoundError); 278 + }); 279 + 280 + test("throws PdsWriteError on PDS write failure", async () => { 281 + const db = getDb(); 282 + db.run( 283 + `INSERT OR REPLACE INTO memberships (wiki_slug, did, role, at_uri, created_at) 284 + VALUES (?, ?, ?, ?, ?)`, 285 + [ 286 + WIKI_SLUG, 287 + "did:plc:role-pds-fail", 288 + "contributor", 289 + `at://${ADMIN_DID}/pub.coral.membership/role-pds-fail-tid`, 290 + "2026-01-01T00:00:00.000Z", 291 + ], 292 + ); 293 + 294 + mockWriteMembershipRecord.mockImplementationOnce(async () => { 295 + throw new Error("PDS down"); 296 + }); 297 + 298 + expect( 299 + changeMemberRoleAction(makeCtx(), "did:plc:role-pds-fail", "admin"), 300 + ).rejects.toBeInstanceOf(PdsWriteError); 301 + }); 302 + 303 + test("skips PDS when no session", async () => { 304 + const db = getDb(); 305 + db.run( 306 + `INSERT OR REPLACE INTO memberships (wiki_slug, did, role, at_uri, created_at) 307 + VALUES (?, ?, ?, ?, ?)`, 308 + [ 309 + WIKI_SLUG, 310 + "did:plc:role-no-session", 311 + "contributor", 312 + `at://${ADMIN_DID}/pub.coral.membership/role-no-session-tid`, 313 + "2026-01-01T00:00:00.000Z", 314 + ], 315 + ); 316 + 317 + mockWriteMembershipRecord.mockClear(); 318 + 319 + await changeMemberRoleAction( 320 + makeCtx({ session: null }), 321 + "did:plc:role-no-session", 322 + "admin", 323 + ); 324 + 325 + expect(mockWriteMembershipRecord).not.toHaveBeenCalled(); 326 + 327 + const row = db 328 + .query("SELECT role FROM memberships WHERE wiki_slug = ? AND did = ?") 329 + .get(WIKI_SLUG, "did:plc:role-no-session") as { role: string } | null; 330 + expect(row?.role).toBe("admin"); 331 + }); 332 + }); 333 + 334 + describe("addMemberAction", () => { 335 + test("writes PDS then DB", async () => { 336 + mockWriteMembershipRecord.mockClear(); 337 + 338 + await addMemberAction(makeCtx(), "did:plc:added-member", "contributor"); 339 + 340 + expect(mockWriteMembershipRecord).toHaveBeenCalledTimes(1); 341 + 342 + const db = getDb(); 343 + const row = db 344 + .query("SELECT role FROM memberships WHERE wiki_slug = ? AND did = ?") 345 + .get(WIKI_SLUG, "did:plc:added-member") as { role: string } | null; 346 + expect(row?.role).toBe("contributor"); 347 + }); 348 + 349 + test("defaults to contributor role", async () => { 350 + await addMemberAction(makeCtx(), "did:plc:added-default"); 351 + 352 + const db = getDb(); 353 + const row = db 354 + .query("SELECT role FROM memberships WHERE wiki_slug = ? AND did = ?") 355 + .get(WIKI_SLUG, "did:plc:added-default") as { role: string } | null; 356 + expect(row?.role).toBe("contributor"); 357 + }); 358 + 359 + test("can add with admin role", async () => { 360 + await addMemberAction(makeCtx(), "did:plc:added-admin", "admin"); 361 + 362 + const db = getDb(); 363 + const row = db 364 + .query("SELECT role FROM memberships WHERE wiki_slug = ? AND did = ?") 365 + .get(WIKI_SLUG, "did:plc:added-admin") as { role: string } | null; 366 + expect(row?.role).toBe("admin"); 367 + }); 368 + 369 + test("re-adding existing member updates role", async () => { 370 + const db = getDb(); 371 + db.run( 372 + `INSERT OR REPLACE INTO memberships (wiki_slug, did, role, at_uri, created_at) 373 + VALUES (?, ?, ?, ?, ?)`, 374 + [ 375 + WIKI_SLUG, 376 + "did:plc:readd-member", 377 + "viewer", 378 + `at://${ADMIN_DID}/pub.coral.membership/readd-tid`, 379 + "2026-01-01T00:00:00.000Z", 380 + ], 381 + ); 382 + 383 + await addMemberAction(makeCtx(), "did:plc:readd-member", "contributor"); 384 + 385 + const row = db 386 + .query("SELECT role FROM memberships WHERE wiki_slug = ? AND did = ?") 387 + .get(WIKI_SLUG, "did:plc:readd-member") as { role: string } | null; 388 + expect(row?.role).toBe("contributor"); 389 + }); 390 + 391 + test("skips PDS when no session", async () => { 392 + mockWriteMembershipRecord.mockClear(); 393 + 394 + await addMemberAction( 395 + makeCtx({ session: null }), 396 + "did:plc:added-no-session", 397 + ); 398 + 399 + expect(mockWriteMembershipRecord).not.toHaveBeenCalled(); 400 + }); 401 + 402 + test("throws PdsWriteError on PDS failure", async () => { 403 + mockWriteMembershipRecord.mockImplementationOnce(async () => { 404 + throw new Error("PDS down"); 405 + }); 406 + 407 + expect( 408 + addMemberAction(makeCtx(), "did:plc:add-pds-fail"), 178 409 ).rejects.toBeInstanceOf(PdsWriteError); 179 410 }); 180 411 });
+57
tests/server/db/queries/membership.test.ts
··· 2 2 import { getDb } from "../../../../src/server/db/index.ts"; 3 3 import { 4 4 getMemberRole, 5 + getMembership, 5 6 getRequest, 6 7 listMembers, 7 8 listRequests, ··· 43 44 VIEWER_DID, 44 45 ]); 45 46 db.run("DELETE FROM requests WHERE did = ?", [REQUESTER_DID]); 47 + }); 48 + 49 + describe("getMembership", () => { 50 + test("returns full row for existing member", () => { 51 + const m = getMembership(WIKI_SLUG, ADMIN_DID); 52 + expect(m).not.toBeNull(); 53 + expect(m?.did).toBe(ADMIN_DID); 54 + expect(m?.role).toBe("admin"); 55 + expect(m?.wiki_slug).toBe(WIKI_SLUG); 56 + expect(m?.at_uri).toBe("at://did:plc:member-admin/pub.coral.membership/m1"); 57 + }); 58 + 59 + test("returns null for non-member", () => { 60 + expect(getMembership(WIKI_SLUG, "did:plc:nobody")).toBeNull(); 61 + }); 62 + 63 + test("returns null for wrong wiki", () => { 64 + expect(getMembership("nonexistent-wiki", ADMIN_DID)).toBeNull(); 65 + }); 66 + }); 67 + 68 + describe("upsertMembership role update", () => { 69 + test("updates role on conflict", () => { 70 + upsertMembership( 71 + WIKI_SLUG, 72 + VIEWER_DID, 73 + "contributor", 74 + "at://did:plc:member-viewer/pub.coral.membership/m2", 75 + "2026-01-02T00:00:00.000Z", 76 + ); 77 + expect(getMemberRole(WIKI_SLUG, VIEWER_DID)).toBe("contributor"); 78 + 79 + // Restore 80 + upsertMembership( 81 + WIKI_SLUG, 82 + VIEWER_DID, 83 + "viewer", 84 + "at://did:plc:member-viewer/pub.coral.membership/m2", 85 + "2026-01-02T00:00:00.000Z", 86 + ); 87 + expect(getMemberRole(WIKI_SLUG, VIEWER_DID)).toBe("viewer"); 88 + }); 89 + 90 + test("contributor is a valid role", () => { 91 + const CONTRIB_DID = "did:plc:member-contrib"; 92 + upsertMembership( 93 + WIKI_SLUG, 94 + CONTRIB_DID, 95 + "contributor", 96 + "at://did:plc:member-contrib/pub.coral.membership/m3", 97 + "2026-01-04T00:00:00.000Z", 98 + ); 99 + expect(getMemberRole(WIKI_SLUG, CONTRIB_DID)).toBe("contributor"); 100 + const db = getDb(); 101 + db.run("DELETE FROM memberships WHERE did = ?", [CONTRIB_DID]); 102 + }); 46 103 }); 47 104 48 105 describe("getMemberRole", () => {