From 08985d774e69d0ff9f91575183fc0388beb5d6e9 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 17 Feb 2026 14:31:48 -0700 Subject: [PATCH] feat: add membership endpoints and bidirectional shape sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds space_members table and CRUD endpoints to EncryptID server for centralized membership management. Extends Automerge CommunityDoc with members map and PATCH endpoint for module→canvas shape updates. Co-Authored-By: Claude Opus 4.6 --- server/community-store.ts | 88 ++++++++++++++++++++++++++++ server/index.ts | 53 +++++++++++++++++ src/encryptid/db.ts | 82 ++++++++++++++++++++++++++ src/encryptid/schema.sql | 13 +++++ src/encryptid/server.ts | 119 +++++++++++++++++++++++++++++++++++++- 5 files changed, 354 insertions(+), 1 deletion(-) diff --git a/server/community-store.ts b/server/community-store.ts index cf15bda..78b2e5a 100644 --- a/server/community-store.ts +++ b/server/community-store.ts @@ -27,11 +27,21 @@ export interface ShapeData { [key: string]: unknown; // Allow additional shape-specific properties } +export interface SpaceMember { + did: string; + role: 'viewer' | 'participant' | 'moderator' | 'admin'; + joinedAt: number; + displayName?: string; +} + export interface CommunityDoc { meta: CommunityMeta; shapes: { [id: string]: ShapeData; }; + members: { + [did: string]: SpaceMember; + }; } // Per-peer sync state for Automerge @@ -109,6 +119,10 @@ function jsonToAutomerge(data: CommunityDoc): Automerge.Doc { for (const [id, shape] of Object.entries(data.shapes || {})) { d.shapes[id] = { ...shape }; } + d.members = {}; + for (const [did, member] of Object.entries(data.members || {})) { + d.members[did] = { ...member }; + } }); return doc; } @@ -159,6 +173,15 @@ export async function createCommunity( ownerDID, }; d.shapes = {}; + d.members = {}; + // If owner is known, add them as admin member + if (ownerDID) { + d.members[ownerDID] = { + did: ownerDID, + role: 'admin', + joinedAt: Date.now(), + }; + } }); communities.set(slug, doc); @@ -413,6 +436,71 @@ export function deleteShape(slug: string, shapeId: string): void { } } +/** + * Update specific fields of a shape (for bidirectional module sync callbacks). + * Only updates the fields provided, preserving all other shape data. + */ +export function updateShapeFields( + slug: string, + shapeId: string, + fields: Record, +): boolean { + const doc = communities.get(slug); + if (!doc || !doc.shapes?.[shapeId]) return false; + + const newDoc = Automerge.change(doc, `Update shape ${shapeId} fields`, (d) => { + if (d.shapes[shapeId]) { + for (const [key, value] of Object.entries(fields)) { + (d.shapes[shapeId] as Record)[key] = value; + } + } + }); + communities.set(slug, newDoc); + saveCommunity(slug); + return true; +} + +/** + * Set a member in the community's Automerge doc + */ +export function setMember( + slug: string, + did: string, + role: SpaceMember['role'], + displayName?: string, +): void { + const doc = communities.get(slug); + if (!doc) return; + + const newDoc = Automerge.change(doc, `Set member ${did}`, (d) => { + if (!d.members) d.members = {}; + d.members[did] = { + did, + role, + joinedAt: d.members[did]?.joinedAt || Date.now(), + ...(displayName ? { displayName } : {}), + }; + }); + communities.set(slug, newDoc); + saveCommunity(slug); +} + +/** + * Remove a member from the community's Automerge doc + */ +export function removeMember(slug: string, did: string): void { + const doc = communities.get(slug); + if (!doc) return; + + const newDoc = Automerge.change(doc, `Remove member ${did}`, (d) => { + if (d.members && d.members[did]) { + delete d.members[did]; + } + }); + communities.set(slug, newDoc); + saveCommunity(slug); +} + /** * Clear all shapes from a community (for demo reset) */ diff --git a/server/index.ts b/server/index.ts index c166fd8..ff1ab97 100644 --- a/server/index.ts +++ b/server/index.ts @@ -12,6 +12,9 @@ import { receiveSyncMessage, removePeerSyncState, updateShape, + updateShapeFields, + setMember, + removeMember, } from "./community-store"; import { ensureDemoCommunity } from "./seed-demo"; import type { SpaceVisibility } from "./community-store"; @@ -661,6 +664,56 @@ async function handleAPI(req: Request, url: URL): Promise { } } + // PATCH /api/communities/:slug/shapes/:shapeId — Update shape fields (bidirectional sync) + const shapeUpdateMatch = url.pathname.match( + /^\/api\/communities\/([^/]+)\/shapes\/([^/]+)$/ + ); + if (shapeUpdateMatch && req.method === "PATCH") { + const slug = shapeUpdateMatch[1]; + const shapeId = shapeUpdateMatch[2]; + + // Allow internal service-to-service calls with shared key + const internalKey = req.headers.get("X-Internal-Key"); + const isInternalCall = INTERNAL_API_KEY && internalKey === INTERNAL_API_KEY; + + if (!isInternalCall) { + const token = extractToken(req.headers); + const access = await evaluateSpaceAccess(slug, token, "PATCH", { + getSpaceConfig, + }); + if (!access.allowed) { + return Response.json( + { error: access.reason }, + { status: access.claims ? 403 : 401, headers: corsHeaders } + ); + } + } + + try { + await loadCommunity(slug); + const body = (await req.json()) as Record; + const updated = updateShapeFields(slug, shapeId, body); + if (!updated) { + return Response.json( + { error: "Shape not found" }, + { status: 404, headers: corsHeaders } + ); + } + + // Broadcast to connected clients + broadcastAutomergeSync(slug); + broadcastJsonSnapshot(slug); + + return Response.json({ ok: true }, { headers: corsHeaders }); + } catch (e) { + console.error("Failed to update shape fields:", e); + return Response.json( + { error: "Failed to update shape" }, + { status: 500, headers: corsHeaders } + ); + } + } + return Response.json({ error: "Not found" }, { status: 404, headers: corsHeaders }); } diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index 36c4555..9bcd473 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -260,6 +260,88 @@ export async function cleanExpiredRecoveryTokens(): Promise { return result.count; } +// ============================================================================ +// SPACE MEMBERSHIP +// ============================================================================ + +export interface StoredSpaceMember { + spaceSlug: string; + userDID: string; + role: string; + joinedAt: number; + grantedBy?: string; +} + +export async function getSpaceMember( + spaceSlug: string, + userDID: string, +): Promise { + const rows = await sql` + SELECT * FROM space_members + WHERE space_slug = ${spaceSlug} AND user_did = ${userDID} + `; + if (rows.length === 0) return null; + const row = rows[0]; + return { + spaceSlug: row.space_slug, + userDID: row.user_did, + role: row.role, + joinedAt: new Date(row.joined_at).getTime(), + grantedBy: row.granted_by || undefined, + }; +} + +export async function listSpaceMembers( + spaceSlug: string, +): Promise { + const rows = await sql` + SELECT * FROM space_members + WHERE space_slug = ${spaceSlug} + ORDER BY joined_at ASC + `; + return rows.map((row) => ({ + spaceSlug: row.space_slug, + userDID: row.user_did, + role: row.role, + joinedAt: new Date(row.joined_at).getTime(), + grantedBy: row.granted_by || undefined, + })); +} + +export async function upsertSpaceMember( + spaceSlug: string, + userDID: string, + role: string, + grantedBy?: string, +): Promise { + const rows = await sql` + INSERT INTO space_members (space_slug, user_did, role, granted_by) + VALUES (${spaceSlug}, ${userDID}, ${role}, ${grantedBy ?? null}) + ON CONFLICT (space_slug, user_did) + DO UPDATE SET role = ${role}, granted_by = ${grantedBy ?? null} + RETURNING * + `; + const row = rows[0]; + return { + spaceSlug: row.space_slug, + userDID: row.user_did, + role: row.role, + joinedAt: new Date(row.joined_at).getTime(), + grantedBy: row.granted_by || undefined, + }; +} + +export async function removeSpaceMember( + spaceSlug: string, + userDID: string, +): Promise { + const result = await sql` + DELETE FROM space_members + WHERE space_slug = ${spaceSlug} AND user_did = ${userDID} + `; + return result.count > 0; +} + // ============================================================================ // HEALTH CHECK // ============================================================================ diff --git a/src/encryptid/schema.sql b/src/encryptid/schema.sql index b1d4606..794cffd 100644 --- a/src/encryptid/schema.sql +++ b/src/encryptid/schema.sql @@ -46,3 +46,16 @@ CREATE TABLE IF NOT EXISTS recovery_tokens ( CREATE INDEX IF NOT EXISTS idx_recovery_tokens_user_id ON recovery_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_recovery_tokens_expires_at ON recovery_tokens(expires_at); + +-- Space membership: source of truth for user roles across the r*.online ecosystem +CREATE TABLE IF NOT EXISTS space_members ( + space_slug TEXT NOT NULL, + user_did TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('viewer', 'participant', 'moderator', 'admin')), + joined_at TIMESTAMPTZ DEFAULT NOW(), + granted_by TEXT, + PRIMARY KEY (space_slug, user_did) +); + +CREATE INDEX IF NOT EXISTS idx_space_members_user_did ON space_members(user_did); +CREATE INDEX IF NOT EXISTS idx_space_members_space_slug ON space_members(space_slug); diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 42ed6e3..5418000 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -35,6 +35,10 @@ import { type StoredCredential, type StoredChallenge, type StoredRecoveryToken, + getSpaceMember, + listSpaceMembers, + upsertSpaceMember, + removeSpaceMember, } from './db.js'; // ============================================================================ @@ -182,7 +186,7 @@ const app = new Hono(); app.use('*', logger()); app.use('*', cors({ origin: CONFIG.allowedOrigins, - allowMethods: ['GET', 'POST', 'OPTIONS'], + allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], allowHeaders: ['Content-Type', 'Authorization'], credentials: true, })); @@ -824,6 +828,119 @@ app.get('/recover', (c) => { `); }); +// ============================================================================ +// SPACE MEMBERSHIP ROUTES +// ============================================================================ + +const VALID_ROLES = ['viewer', 'participant', 'moderator', 'admin']; + +// Helper: verify JWT and return claims, or null +async function verifyTokenFromRequest(authorization: string | undefined): Promise<{ + sub: string; did?: string; username?: string; +} | null> { + if (!authorization?.startsWith('Bearer ')) return null; + const token = authorization.slice(7); + try { + const payload = await verify(token, CONFIG.jwtSecret); + return payload as { sub: string; did?: string; username?: string }; + } catch { + return null; + } +} + +// GET /api/spaces/:slug/members — list all members +app.get('/api/spaces/:slug/members', async (c) => { + const { slug } = c.req.param(); + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) { + return c.json({ error: 'Authentication required' }, 401); + } + + const members = await listSpaceMembers(slug); + return c.json({ + members: members.map((m) => ({ + userDID: m.userDID, + spaceSlug: m.spaceSlug, + role: m.role, + joinedAt: m.joinedAt, + grantedBy: m.grantedBy, + })), + total: members.length, + }); +}); + +// GET /api/spaces/:slug/members/:did — get one member's role +app.get('/api/spaces/:slug/members/:did', async (c) => { + const { slug, did } = c.req.param(); + const member = await getSpaceMember(slug, decodeURIComponent(did)); + if (!member) { + return c.json({ error: 'Member not found' }, 404); + } + return c.json({ + userDID: member.userDID, + spaceSlug: member.spaceSlug, + role: member.role, + joinedAt: member.joinedAt, + grantedBy: member.grantedBy, + }); +}); + +// POST /api/spaces/:slug/members — add or update a member +app.post('/api/spaces/:slug/members', async (c) => { + const { slug } = c.req.param(); + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) { + return c.json({ error: 'Authentication required' }, 401); + } + + // Verify caller is admin in this space (or first member → becomes admin) + const callerMember = await getSpaceMember(slug, claims.sub); + const members = await listSpaceMembers(slug); + + // If space has no members, the first person to add becomes admin + const isFirstMember = members.length === 0; + if (!isFirstMember && (!callerMember || callerMember.role !== 'admin')) { + return c.json({ error: 'Admin role required' }, 403); + } + + const body = await c.req.json<{ userDID: string; role: string }>(); + if (!body.userDID || !body.role) { + return c.json({ error: 'userDID and role are required' }, 400); + } + if (!VALID_ROLES.includes(body.role)) { + return c.json({ error: `Invalid role. Must be one of: ${VALID_ROLES.join(', ')}` }, 400); + } + + const member = await upsertSpaceMember(slug, body.userDID, body.role, claims.sub); + return c.json({ + userDID: member.userDID, + spaceSlug: member.spaceSlug, + role: member.role, + joinedAt: member.joinedAt, + grantedBy: member.grantedBy, + }, 201); +}); + +// DELETE /api/spaces/:slug/members/:did — remove a member +app.delete('/api/spaces/:slug/members/:did', async (c) => { + const { slug, did } = c.req.param(); + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) { + return c.json({ error: 'Authentication required' }, 401); + } + + const callerMember = await getSpaceMember(slug, claims.sub); + if (!callerMember || callerMember.role !== 'admin') { + return c.json({ error: 'Admin role required' }, 403); + } + + const removed = await removeSpaceMember(slug, decodeURIComponent(did)); + if (!removed) { + return c.json({ error: 'Member not found' }, 404); + } + return c.json({ success: true }); +}); + // ============================================================================ // SERVE STATIC FILES // ============================================================================