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:
parent
e4bcc3f04a
commit
08985d774e
|
|
@ -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<CommunityDoc> {
|
|||
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<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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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<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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -260,6 +260,88 @@ export async function cleanExpiredRecoveryTokens(): Promise<number> {
|
|||
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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue