···6464 noMembers: "No members yet.",
6565 noRequests: "No pending requests.",
6666 cannotRemoveOwner: "Cannot remove the wiki owner.",
6767+ changeRole: "Change role",
6868+ saveRole: "Save",
6969+ addMember: "Add member",
7070+ addMemberLabel: "Handle or DID",
7171+ addMemberPlaceholder: "@alice.bsky.social or did:plc:…",
7272+ add: "Add",
6773 },
6874 error: {
6975 titleRequired: "Title is required.",
+6
src/lib/i18n/fr.ts
···6464 noMembers: "Aucun membre pour le moment.",
6565 noRequests: "Aucune demande en attente.",
6666 cannotRemoveOwner: "Impossible de supprimer le propriétaire du wiki.",
6767+ changeRole: "Changer le rôle",
6868+ saveRole: "Enregistrer",
6969+ addMember: "Ajouter un membre",
7070+ addMemberLabel: "Identifiant ou DID",
7171+ addMemberPlaceholder: "@alice.bsky.social ou did:plc:…",
7272+ add: "Ajouter",
6773 },
6874 error: {
6975 titleRequired: "Le titre est requis.",
···77import {
88 deleteMembership,
99 deleteRequest,
1010+ getMembership,
1011 upsertMembership,
1112 upsertRequest,
1213} from "../../server/db/queries/index.ts";
···9394 // DB write
9495 deleteRequest(ctx.wiki.slug, memberDid);
9596 upsertMembership(ctx.wiki.slug, memberDid, role, membershipAtUri, now);
9797+}
9898+9999+/**
100100+ * Change a member's role. PDS: delete old record (best-effort) + write new → DB update.
101101+ * Throws ValidationError if trying to change owner's role,
102102+ * NotFoundError if membership not found,
103103+ * PdsWriteError on PDS write failure.
104104+ */
105105+export async function changeMemberRoleAction(
106106+ ctx: WikiRequestContext,
107107+ memberDid: string,
108108+ newRole: "admin" | "contributor" | "viewer",
109109+): Promise<void> {
110110+ if (memberDid === ctx.wiki.did) {
111111+ throw new ValidationError("Cannot change the wiki owner's role");
112112+ }
113113+114114+ const existing = getMembership(ctx.wiki.slug, memberDid);
115115+ if (!existing) {
116116+ throw new NotFoundError("Membership not found");
117117+ }
118118+119119+ const newTid = generateTid();
120120+ const newAtUri = `at://${ctx.effectiveDid}/pub.coral.membership/${newTid}`;
121121+122122+ if (ctx.session) {
123123+ // Best-effort: delete old PDS record if we own it
124124+ const parsed = parseAtUri(existing.at_uri);
125125+ if (parsed && parsed.did === ctx.session.did) {
126126+ try {
127127+ const agent = await getAgent(ctx.session);
128128+ await deleteRecord(
129129+ agent,
130130+ parsed.did,
131131+ COLLECTIONS.membership,
132132+ parsed.rkey,
133133+ );
134134+ } catch {
135135+ // ignore — old record stays on PDS but DB is authoritative
136136+ }
137137+ }
138138+139139+ // Write new PDS record with updated role
140140+ try {
141141+ const agent = await getAgent(ctx.session);
142142+ await writeMembershipRecord(
143143+ agent,
144144+ ctx.session.did,
145145+ newTid,
146146+ memberDid,
147147+ ctx.wiki.at_uri,
148148+ newRole,
149149+ existing.created_at,
150150+ );
151151+ } catch (err) {
152152+ throw new PdsWriteError(
153153+ `Failed to write membership to PDS: ${formatError(err)}`,
154154+ );
155155+ }
156156+ }
157157+158158+ upsertMembership(
159159+ ctx.wiki.slug,
160160+ memberDid,
161161+ newRole,
162162+ newAtUri,
163163+ existing.created_at,
164164+ );
165165+}
166166+167167+/**
168168+ * Add a member directly (admin-initiated, no request required). PDS write → DB write.
169169+ * Idempotent: re-adding an existing member updates their role.
170170+ * Throws PdsWriteError on PDS failure.
171171+ */
172172+export async function addMemberAction(
173173+ ctx: WikiRequestContext,
174174+ memberDid: string,
175175+ role: "admin" | "contributor" | "viewer" = "contributor",
176176+): Promise<void> {
177177+ const now = new Date().toISOString();
178178+ const tid = generateTid();
179179+ const atUri = `at://${ctx.effectiveDid}/pub.coral.membership/${tid}`;
180180+181181+ if (ctx.session) {
182182+ try {
183183+ const agent = await getAgent(ctx.session);
184184+ await writeMembershipRecord(
185185+ agent,
186186+ ctx.session.did,
187187+ tid,
188188+ memberDid,
189189+ ctx.wiki.at_uri,
190190+ role,
191191+ now,
192192+ );
193193+ } catch (err) {
194194+ throw new PdsWriteError(
195195+ `Failed to write membership to PDS: ${formatError(err)}`,
196196+ );
197197+ }
198198+ }
199199+200200+ upsertMembership(ctx.wiki.slug, memberDid, role, atUri, now);
96201}
9720298203/**
+20
src/lib/profile.ts
···99const idResolver = new IdResolver();
10101111/**
1212+ * Resolve a handle or DID string to a DID.
1313+ * If input already starts with "did:", returns it unchanged.
1414+ * Returns null if the handle cannot be resolved.
1515+ */
1616+export async function resolveHandleToDid(
1717+ handleOrDid: string,
1818+): Promise<string | null> {
1919+ const normalized = handleOrDid.startsWith("@")
2020+ ? handleOrDid.slice(1)
2121+ : handleOrDid;
2222+ if (normalized.startsWith("did:")) return normalized;
2323+ try {
2424+ const did = await idResolver.handle.resolve(normalized);
2525+ return did ?? null;
2626+ } catch {
2727+ return null;
2828+ }
2929+}
3030+3131+/**
1232 * Resolve a DID to profile info (handle, displayName, avatar).
1333 * Fetches the DID document for the handle, then queries the Bluesky AppView
1434 * for avatar and display name. Returns nulls on any failure — never throws.