feat: add membership endpoints and bidirectional shape sync

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-17 14:31:48 -07:00
parent e4bcc3f04a
commit 08985d774e
5 changed files with 354 additions and 1 deletions

View File

@ -27,11 +27,21 @@ export interface ShapeData {
[key: string]: unknown; // Allow additional shape-specific properties [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 { export interface CommunityDoc {
meta: CommunityMeta; meta: CommunityMeta;
shapes: { shapes: {
[id: string]: ShapeData; [id: string]: ShapeData;
}; };
members: {
[did: string]: SpaceMember;
};
} }
// Per-peer sync state for Automerge // Per-peer sync state for Automerge
@ -109,6 +119,10 @@ function jsonToAutomerge(data: CommunityDoc): Automerge.Doc<CommunityDoc> {
for (const [id, shape] of Object.entries(data.shapes || {})) { for (const [id, shape] of Object.entries(data.shapes || {})) {
d.shapes[id] = { ...shape }; d.shapes[id] = { ...shape };
} }
d.members = {};
for (const [did, member] of Object.entries(data.members || {})) {
d.members[did] = { ...member };
}
}); });
return doc; return doc;
} }
@ -159,6 +173,15 @@ export async function createCommunity(
ownerDID, ownerDID,
}; };
d.shapes = {}; 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); 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<string, unknown>,
): 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<string, unknown>)[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) * Clear all shapes from a community (for demo reset)
*/ */

View File

@ -12,6 +12,9 @@ import {
receiveSyncMessage, receiveSyncMessage,
removePeerSyncState, removePeerSyncState,
updateShape, updateShape,
updateShapeFields,
setMember,
removeMember,
} from "./community-store"; } from "./community-store";
import { ensureDemoCommunity } from "./seed-demo"; import { ensureDemoCommunity } from "./seed-demo";
import type { SpaceVisibility } from "./community-store"; import type { SpaceVisibility } from "./community-store";
@ -661,6 +664,56 @@ async function handleAPI(req: Request, url: URL): Promise<Response> {
} }
} }
// 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<string, unknown>;
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 }); return Response.json({ error: "Not found" }, { status: 404, headers: corsHeaders });
} }

View File

@ -260,6 +260,88 @@ export async function cleanExpiredRecoveryTokens(): Promise<number> {
return result.count; 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<StoredSpaceMember | null> {
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<StoredSpaceMember[]> {
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<StoredSpaceMember> {
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<boolean> {
const result = await sql`
DELETE FROM space_members
WHERE space_slug = ${spaceSlug} AND user_did = ${userDID}
`;
return result.count > 0;
}
// ============================================================================ // ============================================================================
// HEALTH CHECK // HEALTH CHECK
// ============================================================================ // ============================================================================

View File

@ -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_user_id ON recovery_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_recovery_tokens_expires_at ON recovery_tokens(expires_at); 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);

View File

@ -35,6 +35,10 @@ import {
type StoredCredential, type StoredCredential,
type StoredChallenge, type StoredChallenge,
type StoredRecoveryToken, type StoredRecoveryToken,
getSpaceMember,
listSpaceMembers,
upsertSpaceMember,
removeSpaceMember,
} from './db.js'; } from './db.js';
// ============================================================================ // ============================================================================
@ -182,7 +186,7 @@ const app = new Hono();
app.use('*', logger()); app.use('*', logger());
app.use('*', cors({ app.use('*', cors({
origin: CONFIG.allowedOrigins, origin: CONFIG.allowedOrigins,
allowMethods: ['GET', 'POST', 'OPTIONS'], allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'], allowHeaders: ['Content-Type', 'Authorization'],
credentials: true, 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 // SERVE STATIC FILES
// ============================================================================ // ============================================================================