985 lines
26 KiB
TypeScript
985 lines
26 KiB
TypeScript
import { mkdir, readdir } from "node:fs/promises";
|
|
import * as Automerge from "@automerge/automerge";
|
|
|
|
const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities";
|
|
|
|
export type SpaceVisibility = 'public' | 'public_read' | 'authenticated' | 'members_only';
|
|
|
|
// ── Nest Permissions & Policy ──
|
|
|
|
export interface NestPermissions {
|
|
read: boolean;
|
|
write: boolean;
|
|
addShapes: boolean;
|
|
deleteShapes: boolean;
|
|
reshare: boolean;
|
|
expiry?: number; // unix timestamp — auto-revoke after this time
|
|
}
|
|
|
|
export interface NestNotifications {
|
|
onNestRequest: boolean;
|
|
onNestCreated: boolean;
|
|
onNestRevoked: boolean;
|
|
onReshare: boolean;
|
|
channel: 'inbox' | 'email' | 'both';
|
|
}
|
|
|
|
export interface NestPolicy {
|
|
consent: 'open' | 'members' | 'approval' | 'closed';
|
|
notifications: NestNotifications;
|
|
defaultPermissions: NestPermissions;
|
|
allowlist?: string[]; // slugs that bypass consent
|
|
blocklist?: string[]; // slugs that are always denied
|
|
}
|
|
|
|
// ── SpaceRef: Nesting Primitive ──
|
|
|
|
export interface SpaceRefFilter {
|
|
shapeTypes?: string[];
|
|
shapeIds?: string[];
|
|
tags?: string[];
|
|
moduleIds?: string[];
|
|
}
|
|
|
|
export interface SpaceRef {
|
|
id: string;
|
|
sourceSlug: string;
|
|
sourceDID?: string;
|
|
filter?: SpaceRefFilter;
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
rotation: number;
|
|
permissions: NestPermissions;
|
|
collapsed?: boolean;
|
|
label?: string;
|
|
createdAt: number;
|
|
createdBy: string; // DID of who created this nesting
|
|
}
|
|
|
|
export type NestRequestStatus = 'pending' | 'approved' | 'denied';
|
|
|
|
export interface PendingNestRequest {
|
|
id: string;
|
|
sourceSlug: string;
|
|
targetSlug: string;
|
|
requestedBy: string; // DID
|
|
requestedPermissions: NestPermissions;
|
|
message?: string;
|
|
status: NestRequestStatus;
|
|
createdAt: number;
|
|
resolvedAt?: number;
|
|
resolvedBy?: string;
|
|
modifiedPermissions?: NestPermissions;
|
|
}
|
|
|
|
// ── Default Nest Policies ──
|
|
|
|
export const DEFAULT_USER_NEST_POLICY: NestPolicy = {
|
|
consent: 'approval',
|
|
notifications: {
|
|
onNestRequest: true,
|
|
onNestCreated: true,
|
|
onNestRevoked: false,
|
|
onReshare: true,
|
|
channel: 'inbox',
|
|
},
|
|
defaultPermissions: {
|
|
read: true,
|
|
write: false,
|
|
addShapes: false,
|
|
deleteShapes: false,
|
|
reshare: false,
|
|
},
|
|
};
|
|
|
|
export const DEFAULT_COMMUNITY_NEST_POLICY: NestPolicy = {
|
|
consent: 'members',
|
|
notifications: {
|
|
onNestRequest: false,
|
|
onNestCreated: true,
|
|
onNestRevoked: true,
|
|
onReshare: false,
|
|
channel: 'inbox',
|
|
},
|
|
defaultPermissions: {
|
|
read: true,
|
|
write: true,
|
|
addShapes: true,
|
|
deleteShapes: false,
|
|
reshare: true,
|
|
},
|
|
};
|
|
|
|
export const DEFAULT_USER_MODULES = ['canvas', 'notes', 'files', 'wallet'];
|
|
|
|
// ── Core Types ──
|
|
|
|
export interface CommunityMeta {
|
|
name: string;
|
|
slug: string;
|
|
createdAt: string;
|
|
visibility: SpaceVisibility;
|
|
ownerDID: string | null;
|
|
enabledModules?: string[];
|
|
description?: string;
|
|
avatar?: string;
|
|
nestPolicy?: NestPolicy;
|
|
encrypted?: boolean;
|
|
encryptionKeyId?: string;
|
|
}
|
|
|
|
export interface ShapeData {
|
|
type: string;
|
|
id: string;
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
rotation: number;
|
|
content?: string;
|
|
sourceId?: string;
|
|
targetId?: string;
|
|
[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;
|
|
};
|
|
nestedSpaces: {
|
|
[refId: string]: SpaceRef;
|
|
};
|
|
}
|
|
|
|
// Per-peer sync state for Automerge
|
|
interface PeerState {
|
|
syncState: Automerge.SyncState;
|
|
lastActivity: number;
|
|
}
|
|
|
|
// In-memory cache of Automerge documents
|
|
const communities = new Map<string, Automerge.Doc<CommunityDoc>>();
|
|
|
|
// Track sync state per peer (WebSocket connection)
|
|
const peerSyncStates = new Map<string, Map<string, PeerState>>();
|
|
|
|
// Debounce save timers
|
|
const saveTimers = new Map<string, Timer>();
|
|
|
|
// Ensure storage directory exists
|
|
await mkdir(STORAGE_DIR, { recursive: true });
|
|
|
|
/**
|
|
* Load community document from disk
|
|
*/
|
|
export async function loadCommunity(slug: string): Promise<Automerge.Doc<CommunityDoc> | null> {
|
|
// Check cache first
|
|
if (communities.has(slug)) {
|
|
return communities.get(slug)!;
|
|
}
|
|
|
|
// Try to load Automerge binary first
|
|
const binaryPath = `${STORAGE_DIR}/${slug}.automerge`;
|
|
const binaryFile = Bun.file(binaryPath);
|
|
|
|
if (await binaryFile.exists()) {
|
|
try {
|
|
const buffer = await binaryFile.arrayBuffer();
|
|
let bytes = new Uint8Array(buffer);
|
|
|
|
// Check for encrypted magic bytes
|
|
if (bytes.length >= ENCRYPTED_MAGIC.length &&
|
|
bytes[0] === ENCRYPTED_MAGIC[0] &&
|
|
bytes[1] === ENCRYPTED_MAGIC[1] &&
|
|
bytes[2] === ENCRYPTED_MAGIC[2] &&
|
|
bytes[3] === ENCRYPTED_MAGIC[3]) {
|
|
// Encrypted file: extract keyId length (4 bytes), keyId, then ciphertext
|
|
const keyIdLen = new DataView(bytes.buffer, bytes.byteOffset + 4, 4).getUint32(0);
|
|
const keyId = new TextDecoder().decode(bytes.slice(8, 8 + keyIdLen));
|
|
const ciphertext = bytes.slice(8 + keyIdLen);
|
|
const key = await deriveSpaceKey(keyId);
|
|
bytes = new Uint8Array(await decryptBinary(ciphertext, key));
|
|
console.log(`[Store] Decrypted ${slug} (keyId: ${keyId})`);
|
|
}
|
|
|
|
const doc = Automerge.load<CommunityDoc>(bytes);
|
|
communities.set(slug, doc);
|
|
return doc;
|
|
} catch (e) {
|
|
console.error(`Failed to load Automerge doc for ${slug}:`, e);
|
|
}
|
|
}
|
|
|
|
// Fallback: try JSON format and migrate
|
|
const jsonPath = `${STORAGE_DIR}/${slug}.json`;
|
|
const jsonFile = Bun.file(jsonPath);
|
|
|
|
if (await jsonFile.exists()) {
|
|
try {
|
|
const data = (await jsonFile.json()) as CommunityDoc;
|
|
// Migrate JSON to Automerge
|
|
const doc = jsonToAutomerge(data);
|
|
communities.set(slug, doc);
|
|
// Save as Automerge binary
|
|
await saveCommunity(slug);
|
|
return doc;
|
|
} catch (e) {
|
|
console.error(`Failed to migrate JSON for ${slug}:`, e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Convert JSON document to Automerge document
|
|
*/
|
|
function jsonToAutomerge(data: CommunityDoc): Automerge.Doc<CommunityDoc> {
|
|
let doc = Automerge.init<CommunityDoc>();
|
|
doc = Automerge.change(doc, "Import from JSON", (d) => {
|
|
d.meta = { ...data.meta };
|
|
d.shapes = {};
|
|
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 };
|
|
}
|
|
d.nestedSpaces = {};
|
|
for (const [refId, ref] of Object.entries(data.nestedSpaces || {})) {
|
|
d.nestedSpaces[refId] = { ...ref };
|
|
}
|
|
});
|
|
return doc;
|
|
}
|
|
|
|
/**
|
|
* Save community document to disk (debounced)
|
|
*/
|
|
export async function saveCommunity(slug: string): Promise<void> {
|
|
const doc = communities.get(slug);
|
|
if (!doc) return;
|
|
|
|
// Clear existing timer
|
|
const existingTimer = saveTimers.get(slug);
|
|
if (existingTimer) {
|
|
clearTimeout(existingTimer);
|
|
}
|
|
|
|
// Debounce saves to avoid excessive disk writes
|
|
const timer = setTimeout(async () => {
|
|
const currentDoc = communities.get(slug);
|
|
if (!currentDoc) return;
|
|
|
|
const binary = Automerge.save(currentDoc);
|
|
const path = `${STORAGE_DIR}/${slug}.automerge`;
|
|
|
|
// Encrypt at rest if the space has encryption enabled
|
|
if (currentDoc.meta.encrypted && currentDoc.meta.encryptionKeyId) {
|
|
try {
|
|
const keyId = currentDoc.meta.encryptionKeyId;
|
|
const key = await deriveSpaceKey(keyId);
|
|
const ciphertext = await encryptBinary(binary, key);
|
|
const keyIdBytes = new TextEncoder().encode(keyId);
|
|
// Format: magic (4) + keyIdLen (4) + keyId + ciphertext
|
|
const header = new Uint8Array(8 + keyIdBytes.length + ciphertext.length);
|
|
header.set(ENCRYPTED_MAGIC, 0);
|
|
new DataView(header.buffer).setUint32(4, keyIdBytes.length);
|
|
header.set(keyIdBytes, 8);
|
|
header.set(ciphertext, 8 + keyIdBytes.length);
|
|
await Bun.write(path, header);
|
|
console.log(`[Store] Saved ${slug} encrypted (${header.length} bytes, keyId: ${keyId})`);
|
|
} catch (e) {
|
|
// Fallback to unencrypted if encryption fails
|
|
console.error(`[Store] Encryption failed for ${slug}, saving unencrypted:`, e);
|
|
await Bun.write(path, binary);
|
|
}
|
|
} else {
|
|
await Bun.write(path, binary);
|
|
console.log(`[Store] Saved ${slug} (${binary.length} bytes)`);
|
|
}
|
|
}, 2000);
|
|
|
|
saveTimers.set(slug, timer);
|
|
}
|
|
|
|
/**
|
|
* Create a new community/space
|
|
*/
|
|
export async function createCommunity(
|
|
name: string,
|
|
slug: string,
|
|
ownerDID: string | null = null,
|
|
visibility: SpaceVisibility = 'public_read',
|
|
options?: {
|
|
enabledModules?: string[];
|
|
nestPolicy?: NestPolicy;
|
|
description?: string;
|
|
},
|
|
): Promise<Automerge.Doc<CommunityDoc>> {
|
|
let doc = Automerge.init<CommunityDoc>();
|
|
doc = Automerge.change(doc, "Create community", (d) => {
|
|
d.meta = {
|
|
name,
|
|
slug,
|
|
createdAt: new Date().toISOString(),
|
|
visibility,
|
|
ownerDID,
|
|
};
|
|
if (options?.enabledModules) {
|
|
d.meta.enabledModules = options.enabledModules;
|
|
}
|
|
if (options?.nestPolicy) {
|
|
d.meta.nestPolicy = options.nestPolicy;
|
|
}
|
|
if (options?.description) {
|
|
d.meta.description = options.description;
|
|
}
|
|
d.shapes = {};
|
|
d.members = {};
|
|
d.nestedSpaces = {};
|
|
// 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);
|
|
await saveCommunity(slug);
|
|
return doc;
|
|
}
|
|
|
|
/**
|
|
* Check if community exists
|
|
*/
|
|
export async function communityExists(slug: string): Promise<boolean> {
|
|
if (communities.has(slug)) return true;
|
|
|
|
const binaryPath = `${STORAGE_DIR}/${slug}.automerge`;
|
|
const jsonPath = `${STORAGE_DIR}/${slug}.json`;
|
|
|
|
const binaryFile = Bun.file(binaryPath);
|
|
const jsonFile = Bun.file(jsonPath);
|
|
|
|
return (await binaryFile.exists()) || (await jsonFile.exists());
|
|
}
|
|
|
|
/**
|
|
* List all communities
|
|
*/
|
|
export async function listCommunities(): Promise<string[]> {
|
|
try {
|
|
const files = await readdir(STORAGE_DIR);
|
|
const slugs = new Set<string>();
|
|
|
|
for (const f of files) {
|
|
if (f.endsWith(".automerge")) {
|
|
slugs.add(f.replace(".automerge", ""));
|
|
} else if (f.endsWith(".json")) {
|
|
slugs.add(f.replace(".json", ""));
|
|
}
|
|
}
|
|
|
|
return Array.from(slugs);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get or create sync state for a peer
|
|
*/
|
|
export function getPeerSyncState(slug: string, peerId: string): PeerState {
|
|
if (!peerSyncStates.has(slug)) {
|
|
peerSyncStates.set(slug, new Map());
|
|
}
|
|
|
|
const communityPeers = peerSyncStates.get(slug)!;
|
|
|
|
if (!communityPeers.has(peerId)) {
|
|
communityPeers.set(peerId, {
|
|
syncState: Automerge.initSyncState(),
|
|
lastActivity: Date.now(),
|
|
});
|
|
}
|
|
|
|
const peerState = communityPeers.get(peerId)!;
|
|
peerState.lastActivity = Date.now();
|
|
return peerState;
|
|
}
|
|
|
|
/**
|
|
* Remove peer sync state (on disconnect)
|
|
*/
|
|
export function removePeerSyncState(slug: string, peerId: string): void {
|
|
const communityPeers = peerSyncStates.get(slug);
|
|
if (communityPeers) {
|
|
communityPeers.delete(peerId);
|
|
if (communityPeers.size === 0) {
|
|
peerSyncStates.delete(slug);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all peer IDs for a community
|
|
*/
|
|
export function getCommunityPeers(slug: string): string[] {
|
|
const communityPeers = peerSyncStates.get(slug);
|
|
return communityPeers ? Array.from(communityPeers.keys()) : [];
|
|
}
|
|
|
|
/**
|
|
* Process incoming sync message from a peer
|
|
* Returns response message and messages for other peers
|
|
*/
|
|
export function receiveSyncMessage(
|
|
slug: string,
|
|
peerId: string,
|
|
message: Uint8Array,
|
|
): {
|
|
response: Uint8Array | null;
|
|
broadcastToPeers: Map<string, Uint8Array>;
|
|
} {
|
|
const doc = communities.get(slug);
|
|
if (!doc) {
|
|
return { response: null, broadcastToPeers: new Map() };
|
|
}
|
|
|
|
const peerState = getPeerSyncState(slug, peerId);
|
|
|
|
// Apply incoming sync message
|
|
const result = Automerge.receiveSyncMessage(
|
|
doc,
|
|
peerState.syncState,
|
|
message
|
|
);
|
|
|
|
const newDoc = result[0];
|
|
const newSyncState = result[1];
|
|
|
|
communities.set(slug, newDoc);
|
|
peerState.syncState = newSyncState;
|
|
|
|
// Save if the document actually changed (Automerge 2.x receiveSyncMessage
|
|
// returns null for patches, so detect changes via object identity instead)
|
|
if (newDoc !== doc) {
|
|
saveCommunity(slug);
|
|
}
|
|
|
|
// Generate response for this peer
|
|
const [nextSyncState, responseMessage] = Automerge.generateSyncMessage(
|
|
newDoc,
|
|
peerState.syncState
|
|
);
|
|
peerState.syncState = nextSyncState;
|
|
|
|
// Generate messages for other peers
|
|
const broadcastToPeers = new Map<string, Uint8Array>();
|
|
const communityPeers = peerSyncStates.get(slug);
|
|
|
|
if (communityPeers && newDoc !== doc) {
|
|
for (const [otherPeerId, otherPeerState] of communityPeers) {
|
|
if (otherPeerId !== peerId) {
|
|
const [newOtherSyncState, otherMessage] = Automerge.generateSyncMessage(
|
|
newDoc,
|
|
otherPeerState.syncState
|
|
);
|
|
otherPeerState.syncState = newOtherSyncState;
|
|
|
|
if (otherMessage) {
|
|
broadcastToPeers.set(otherPeerId, otherMessage);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
response: responseMessage || null,
|
|
broadcastToPeers,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate initial sync message for a new peer
|
|
*/
|
|
export function generateSyncMessageForPeer(
|
|
slug: string,
|
|
peerId: string,
|
|
): Uint8Array | null {
|
|
const doc = communities.get(slug);
|
|
if (!doc) return null;
|
|
|
|
const peerState = getPeerSyncState(slug, peerId);
|
|
const [newSyncState, message] = Automerge.generateSyncMessage(
|
|
doc,
|
|
peerState.syncState
|
|
);
|
|
peerState.syncState = newSyncState;
|
|
|
|
return message || null;
|
|
}
|
|
|
|
/**
|
|
* Get document as plain object (for API responses)
|
|
*/
|
|
export function getDocumentData(slug: string): CommunityDoc | null {
|
|
const doc = communities.get(slug);
|
|
if (!doc) return null;
|
|
|
|
// Convert Automerge doc to plain object
|
|
return JSON.parse(JSON.stringify(doc));
|
|
}
|
|
|
|
/**
|
|
* Add multiple shapes in a single Automerge change (for external API calls)
|
|
*/
|
|
export function addShapes(
|
|
slug: string,
|
|
shapes: Record<string, unknown>[],
|
|
): string[] {
|
|
const doc = communities.get(slug);
|
|
if (!doc) return [];
|
|
|
|
const ids: string[] = [];
|
|
const newDoc = Automerge.change(doc, `Add ${shapes.length} shapes`, (d) => {
|
|
if (!d.shapes) d.shapes = {};
|
|
for (const shape of shapes) {
|
|
const id = (shape.id as string) || `shape-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
ids.push(id);
|
|
d.shapes[id] = {
|
|
type: (shape.type as string) || 'geo',
|
|
x: (shape.x as number) ?? 100,
|
|
y: (shape.y as number) ?? 100,
|
|
width: (shape.width as number) ?? 300,
|
|
height: (shape.height as number) ?? 200,
|
|
rotation: (shape.rotation as number) ?? 0,
|
|
...shape,
|
|
id,
|
|
} as ShapeData;
|
|
}
|
|
});
|
|
communities.set(slug, newDoc);
|
|
saveCommunity(slug);
|
|
return ids;
|
|
}
|
|
|
|
// Legacy functions for backward compatibility
|
|
|
|
export function updateShape(
|
|
slug: string,
|
|
shapeId: string,
|
|
data: ShapeData,
|
|
): void {
|
|
const doc = communities.get(slug);
|
|
if (doc) {
|
|
const newDoc = Automerge.change(doc, `Update shape ${shapeId}`, (d) => {
|
|
if (!d.shapes) d.shapes = {};
|
|
d.shapes[shapeId] = data;
|
|
});
|
|
communities.set(slug, newDoc);
|
|
saveCommunity(slug);
|
|
}
|
|
}
|
|
|
|
export function deleteShape(slug: string, shapeId: string): void {
|
|
const doc = communities.get(slug);
|
|
if (doc) {
|
|
const newDoc = Automerge.change(doc, `Delete shape ${shapeId}`, (d) => {
|
|
if (d.shapes && d.shapes[shapeId]) {
|
|
delete d.shapes[shapeId];
|
|
}
|
|
});
|
|
communities.set(slug, newDoc);
|
|
saveCommunity(slug);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Forget a shape — soft-delete. Shape stays in the doc but is hidden from view.
|
|
* Can be restored with rememberShape().
|
|
*/
|
|
export function forgetShape(slug: string, shapeId: string, forgottenBy?: string): void {
|
|
const doc = communities.get(slug);
|
|
if (!doc || !doc.shapes?.[shapeId]) return;
|
|
|
|
const newDoc = Automerge.change(doc, `Forget shape ${shapeId}`, (d) => {
|
|
if (d.shapes[shapeId]) {
|
|
(d.shapes[shapeId] as Record<string, unknown>).forgotten = true;
|
|
(d.shapes[shapeId] as Record<string, unknown>).forgottenAt = Date.now();
|
|
if (forgottenBy) {
|
|
(d.shapes[shapeId] as Record<string, unknown>).forgottenBy = forgottenBy;
|
|
}
|
|
}
|
|
});
|
|
communities.set(slug, newDoc);
|
|
saveCommunity(slug);
|
|
}
|
|
|
|
/**
|
|
* Remember a forgotten shape — restore it to the canvas.
|
|
*/
|
|
export function rememberShape(slug: string, shapeId: string): void {
|
|
const doc = communities.get(slug);
|
|
if (!doc || !doc.shapes?.[shapeId]) return;
|
|
|
|
const newDoc = Automerge.change(doc, `Remember shape ${shapeId}`, (d) => {
|
|
if (d.shapes[shapeId]) {
|
|
(d.shapes[shapeId] as Record<string, unknown>).forgotten = false;
|
|
(d.shapes[shapeId] as Record<string, unknown>).forgottenAt = 0;
|
|
(d.shapes[shapeId] as Record<string, unknown>).forgottenBy = '';
|
|
}
|
|
});
|
|
communities.set(slug, newDoc);
|
|
saveCommunity(slug);
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
*/
|
|
export function clearShapes(slug: string): void {
|
|
const doc = communities.get(slug);
|
|
if (doc) {
|
|
const newDoc = Automerge.change(doc, "Clear all shapes", (d) => {
|
|
d.shapes = {};
|
|
});
|
|
communities.set(slug, newDoc);
|
|
saveCommunity(slug);
|
|
}
|
|
}
|
|
|
|
// ── Nested Spaces CRUD ──
|
|
|
|
/**
|
|
* Add a SpaceRef (nest a space into another)
|
|
*/
|
|
export function addNestedSpace(slug: string, ref: SpaceRef): void {
|
|
const doc = communities.get(slug);
|
|
if (!doc) return;
|
|
|
|
const newDoc = Automerge.change(doc, `Nest space ${ref.sourceSlug}`, (d) => {
|
|
if (!d.nestedSpaces) d.nestedSpaces = {};
|
|
d.nestedSpaces[ref.id] = { ...ref };
|
|
});
|
|
communities.set(slug, newDoc);
|
|
saveCommunity(slug);
|
|
}
|
|
|
|
/**
|
|
* Update a SpaceRef (permissions, filter, position)
|
|
*/
|
|
export function updateNestedSpace(
|
|
slug: string,
|
|
refId: string,
|
|
fields: Partial<SpaceRef>,
|
|
): boolean {
|
|
const doc = communities.get(slug);
|
|
if (!doc || !doc.nestedSpaces?.[refId]) return false;
|
|
|
|
const newDoc = Automerge.change(doc, `Update nest ${refId}`, (d) => {
|
|
if (d.nestedSpaces[refId]) {
|
|
for (const [key, value] of Object.entries(fields)) {
|
|
if (key === 'id') continue; // never change ID
|
|
(d.nestedSpaces[refId] as unknown as Record<string, unknown>)[key] = value;
|
|
}
|
|
}
|
|
});
|
|
communities.set(slug, newDoc);
|
|
saveCommunity(slug);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Remove a SpaceRef (un-nest)
|
|
*/
|
|
export function removeNestedSpace(slug: string, refId: string): boolean {
|
|
const doc = communities.get(slug);
|
|
if (!doc || !doc.nestedSpaces?.[refId]) return false;
|
|
|
|
const newDoc = Automerge.change(doc, `Remove nest ${refId}`, (d) => {
|
|
if (d.nestedSpaces && d.nestedSpaces[refId]) {
|
|
delete d.nestedSpaces[refId];
|
|
}
|
|
});
|
|
communities.set(slug, newDoc);
|
|
saveCommunity(slug);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Get the NestPolicy for a space, with fallback to default
|
|
*/
|
|
export function getNestPolicy(slug: string): NestPolicy | null {
|
|
const doc = communities.get(slug);
|
|
if (!doc) return null;
|
|
return doc.meta.nestPolicy || DEFAULT_COMMUNITY_NEST_POLICY;
|
|
}
|
|
|
|
/**
|
|
* Update the NestPolicy for a space
|
|
*/
|
|
export function updateNestPolicy(slug: string, policy: Partial<NestPolicy>): boolean {
|
|
const doc = communities.get(slug);
|
|
if (!doc) return false;
|
|
|
|
const newDoc = Automerge.change(doc, `Update nest policy`, (d) => {
|
|
const current = d.meta.nestPolicy || { ...DEFAULT_COMMUNITY_NEST_POLICY };
|
|
d.meta.nestPolicy = {
|
|
...current,
|
|
...policy,
|
|
notifications: { ...current.notifications, ...(policy.notifications || {}) },
|
|
defaultPermissions: { ...current.defaultPermissions, ...(policy.defaultPermissions || {}) },
|
|
};
|
|
});
|
|
communities.set(slug, newDoc);
|
|
saveCommunity(slug);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Update enabled modules for a space
|
|
*/
|
|
export function setEnabledModules(slug: string, modules: string[]): boolean {
|
|
const doc = communities.get(slug);
|
|
if (!doc) return false;
|
|
|
|
const newDoc = Automerge.change(doc, `Set enabled modules`, (d) => {
|
|
d.meta.enabledModules = modules;
|
|
});
|
|
communities.set(slug, newDoc);
|
|
saveCommunity(slug);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Cap requested permissions against a policy's defaultPermissions ceiling
|
|
*/
|
|
export function capPermissions(
|
|
requested: NestPermissions,
|
|
ceiling: NestPermissions,
|
|
): NestPermissions {
|
|
return {
|
|
read: requested.read && ceiling.read,
|
|
write: requested.write && ceiling.write,
|
|
addShapes: requested.addShapes && ceiling.addShapes,
|
|
deleteShapes: requested.deleteShapes && ceiling.deleteShapes,
|
|
reshare: requested.reshare && ceiling.reshare,
|
|
expiry: requested.expiry
|
|
? (ceiling.expiry ? Math.min(requested.expiry, ceiling.expiry) : requested.expiry)
|
|
: ceiling.expiry,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Cascade permissions across a nesting chain (intersection — most restrictive wins)
|
|
*/
|
|
export function cascadePermissions(chain: NestPermissions[]): NestPermissions {
|
|
return {
|
|
read: chain.every(p => p.read),
|
|
write: chain.every(p => p.write),
|
|
addShapes: chain.every(p => p.addShapes),
|
|
deleteShapes: chain.every(p => p.deleteShapes),
|
|
reshare: chain.every(p => p.reshare),
|
|
expiry: chain.some(p => p.expiry)
|
|
? Math.min(...chain.filter(p => p.expiry).map(p => p.expiry!))
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
// ── Encryption ──
|
|
|
|
/**
|
|
* Toggle encryption on a space and set the key identifier.
|
|
* When encrypted: true, the Automerge binary is AES-256 encrypted at rest.
|
|
* The server decrypts on load and serves plaintext to authorized clients
|
|
* (this is "Approach B: plaintext projection" from the architecture spec).
|
|
*
|
|
* Full E2E encryption (Approach C) will be added when EncryptID Layer 2
|
|
* client-side key delegation is implemented.
|
|
*/
|
|
export function setEncryption(
|
|
slug: string,
|
|
encrypted: boolean,
|
|
encryptionKeyId?: string,
|
|
): boolean {
|
|
const doc = communities.get(slug);
|
|
if (!doc) return false;
|
|
|
|
const newDoc = Automerge.change(doc, `Set encryption ${encrypted ? 'on' : 'off'}`, (d) => {
|
|
d.meta.encrypted = encrypted;
|
|
d.meta.encryptionKeyId = encrypted ? (encryptionKeyId || `key-${slug}-${Date.now()}`) : undefined;
|
|
});
|
|
communities.set(slug, newDoc);
|
|
saveCommunity(slug);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Derive an AES-256-GCM key from a space's encryption key identifier.
|
|
* In production this will use EncryptID Layer 2 key derivation.
|
|
* For now, uses a deterministic HMAC-based key from a server secret.
|
|
*/
|
|
async function deriveSpaceKey(keyId: string): Promise<CryptoKey> {
|
|
const serverSecret = process.env.ENCRYPTION_SECRET || 'rspace-dev-secret-change-in-production';
|
|
const encoder = new TextEncoder();
|
|
const keyMaterial = await crypto.subtle.importKey(
|
|
'raw',
|
|
encoder.encode(serverSecret),
|
|
{ name: 'HMAC', hash: 'SHA-256' },
|
|
false,
|
|
['sign'],
|
|
);
|
|
const derived = await crypto.subtle.sign('HMAC', keyMaterial, encoder.encode(keyId));
|
|
return crypto.subtle.importKey(
|
|
'raw',
|
|
derived,
|
|
{ name: 'AES-GCM', length: 256 },
|
|
false,
|
|
['encrypt', 'decrypt'],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Encrypt binary data using AES-256-GCM.
|
|
* Returns: 12-byte IV + ciphertext + 16-byte auth tag (all concatenated).
|
|
*/
|
|
async function encryptBinary(data: Uint8Array, key: CryptoKey): Promise<Uint8Array> {
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
// Copy into a fresh ArrayBuffer to satisfy strict BufferSource typing
|
|
const plainBuf = new ArrayBuffer(data.byteLength);
|
|
new Uint8Array(plainBuf).set(data);
|
|
const ciphertext = await crypto.subtle.encrypt(
|
|
{ name: 'AES-GCM', iv },
|
|
key,
|
|
plainBuf,
|
|
);
|
|
const result = new Uint8Array(12 + ciphertext.byteLength);
|
|
result.set(iv, 0);
|
|
result.set(new Uint8Array(ciphertext), 12);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Decrypt binary data encrypted with AES-256-GCM.
|
|
* Expects: 12-byte IV + ciphertext + 16-byte auth tag.
|
|
*/
|
|
async function decryptBinary(data: Uint8Array, key: CryptoKey): Promise<Uint8Array> {
|
|
const iv = data.slice(0, 12);
|
|
const ciphertext = data.slice(12);
|
|
const plaintext = await crypto.subtle.decrypt(
|
|
{ name: 'AES-GCM', iv },
|
|
key,
|
|
ciphertext,
|
|
);
|
|
return new Uint8Array(plaintext);
|
|
}
|
|
|
|
// Magic bytes to identify encrypted Automerge files
|
|
const ENCRYPTED_MAGIC = new Uint8Array([0x72, 0x53, 0x45, 0x4E]); // "rSEN" (rSpace ENcrypted)
|
|
|
|
/**
|
|
* Find all spaces that a given space is nested into (reverse lookup)
|
|
*/
|
|
export async function findNestedIn(sourceSlug: string): Promise<Array<{ slug: string; refId: string; ref: SpaceRef }>> {
|
|
const results: Array<{ slug: string; refId: string; ref: SpaceRef }> = [];
|
|
const allSlugs = await listCommunities();
|
|
|
|
for (const slug of allSlugs) {
|
|
await loadCommunity(slug);
|
|
const doc = communities.get(slug);
|
|
if (!doc?.nestedSpaces) continue;
|
|
|
|
for (const [refId, ref] of Object.entries(doc.nestedSpaces)) {
|
|
if (ref.sourceSlug === sourceSlug) {
|
|
results.push({ slug, refId, ref: JSON.parse(JSON.stringify(ref)) });
|
|
}
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|