import { mkdir, readdir, unlink } from "node:fs/promises"; import * as Automerge from "@automerge/automerge"; import { deriveSpaceKey, encryptBinary, decryptBinary, isEncryptedFile, packEncrypted, unpackEncrypted, } from "./local-first/encryption-utils"; import type { ConnectionPolicy, SpaceConnection } from "../lib/connection-types"; import { DEFAULT_PERSONAL_CONNECTION_POLICY, DEFAULT_COMMUNITY_CONNECTION_POLICY, } from "../lib/connection-types"; const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities"; export type SpaceVisibility = 'public' | 'permissioned' | 'private'; /** Normalize legacy visibility values to the current 3-type model. */ export function normalizeVisibility(v: string): SpaceVisibility { if (v === 'public_read' || v === 'public') return 'public'; if (v === 'authenticated' || v === 'permissioned') return 'permissioned'; if (v === 'members_only' || v === 'private') return 'private'; return 'public'; } // ── 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[]; // null = all enabled moduleScopeOverrides?: Record; description?: string; avatar?: string; nestPolicy?: NestPolicy; connectionPolicy?: ConnectionPolicy; 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' | 'member' | 'moderator' | 'admin'; joinedAt: number; displayName?: string; } export interface CommunityDoc { meta: CommunityMeta; shapes: { [id: string]: ShapeData; }; members: { [did: string]: SpaceMember; }; nestedSpaces: { [refId: string]: SpaceRef; }; connections: { [connId: string]: SpaceConnection; }; } // Per-peer sync state for Automerge interface PeerState { syncState: Automerge.SyncState; lastActivity: number; } // In-memory cache of Automerge documents const communities = new Map>(); // Track sync state per peer (WebSocket connection) const peerSyncStates = new Map>(); // Debounce save timers const saveTimers = new Map(); // Ensure storage directory exists await mkdir(STORAGE_DIR, { recursive: true }); /** * Runtime migration: rename 'participant' → 'member' in Automerge doc members. * Returns the (possibly updated) doc. Only mutates if a participant role is found. */ function migrateParticipantToMember( doc: Automerge.Doc, slug: string, ): Automerge.Doc { if (!doc.members) return doc; const needsMigration = Object.values(doc.members).some( (m) => (m as any).role === 'participant', ); if (!needsMigration) return doc; console.log(`[Store] Migrating participant→member roles in ${slug}`); return Automerge.change(doc, 'Migrate participant→member', (d) => { for (const did of Object.keys(d.members)) { if ((d.members[did] as any).role === 'participant') { d.members[did].role = 'member'; } } }); } function migrateVisibility( doc: Automerge.Doc, slug: string, ): Automerge.Doc { const v = doc.meta?.visibility as string; if (!v) return doc; const normalized = normalizeVisibility(v); if (v === normalized) return doc; console.log(`[Store] Migrating visibility ${v}→${normalized} in ${slug}`); return Automerge.change(doc, `Migrate visibility ${v}→${normalized}`, (d) => { d.meta.visibility = normalized; }); } /** * Runtime migration: ensure `connections` map exists on docs created before SpaceConnections. */ function migrateAddConnections( doc: Automerge.Doc, slug: string, ): Automerge.Doc { if (doc.connections) return doc; console.log(`[Store] Migrating: adding connections map to ${slug}`); return Automerge.change(doc, 'Add connections map', (d) => { d.connections = {}; }); } /** * Load community document from disk */ export async function loadCommunity(slug: string): Promise | 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 (isEncryptedFile(bytes)) { const { keyId, ciphertext } = unpackEncrypted(bytes); const key = await deriveSpaceKey(keyId); bytes = new Uint8Array(await decryptBinary(ciphertext, key)); console.log(`[Store] Decrypted ${slug} (keyId: ${keyId})`); } let doc = Automerge.load(bytes); // Runtime migration: participant → member doc = migrateParticipantToMember(doc, slug); // Runtime migration: normalize legacy visibility values doc = migrateVisibility(doc, slug); // Runtime migration: add connections map doc = migrateAddConnections(doc, slug); 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 let doc = jsonToAutomerge(data); // Runtime migration: participant → member doc = migrateParticipantToMember(doc, slug); // Runtime migration: normalize legacy visibility values doc = migrateVisibility(doc, slug); // Runtime migration: add connections map doc = migrateAddConnections(doc, slug); 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 { let doc = Automerge.init(); 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 }; } d.connections = {}; for (const [connId, conn] of Object.entries((data as any).connections || {})) { d.connections[connId] = { ...(conn as SpaceConnection) }; } }); return doc; } /** * Save community document to disk (debounced) */ export async function saveCommunity(slug: string): Promise { 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 packed = packEncrypted(keyId, ciphertext); await Bun.write(path, packed); console.log(`[Store] Saved ${slug} encrypted (${packed.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 = 'private', options?: { enabledModules?: string[]; nestPolicy?: NestPolicy; connectionPolicy?: ConnectionPolicy; description?: string; }, ): Promise> { let doc = Automerge.init(); 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?.connectionPolicy) { d.meta.connectionPolicy = options.connectionPolicy; } if (options?.description) { d.meta.description = options.description; } d.shapes = {}; d.members = {}; d.nestedSpaces = {}; d.connections = {}; // 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; } /** * Delete a community/space — removes from memory and disk */ export async function deleteCommunity(slug: string): Promise { // Cancel any pending debounce save timer const timer = saveTimers.get(slug); if (timer) { clearTimeout(timer); saveTimers.delete(slug); } // Remove from in-memory cache communities.delete(slug); // Remove all peer sync states for this slug peerSyncStates.delete(slug); // Delete files from disk const automerge = `${STORAGE_DIR}/${slug}.automerge`; const json = `${STORAGE_DIR}/${slug}.json`; try { await unlink(automerge); } catch { /* file may not exist */ } try { await unlink(json); } catch { /* file may not exist */ } console.log(`[Store] Deleted community ${slug}`); } /** * Update space metadata (name, visibility, description) */ export function updateSpaceMeta( slug: string, fields: { name?: string; visibility?: SpaceVisibility; description?: string; enabledModules?: string[]; moduleScopeOverrides?: Record; }, ): boolean { const doc = communities.get(slug); if (!doc) return false; const newDoc = Automerge.change(doc, `Update space meta`, (d) => { if (fields.name !== undefined) d.meta.name = fields.name; if (fields.visibility !== undefined) d.meta.visibility = fields.visibility; if (fields.description !== undefined) d.meta.description = fields.description; if (fields.enabledModules !== undefined) d.meta.enabledModules = fields.enabledModules; if (fields.moduleScopeOverrides !== undefined) d.meta.moduleScopeOverrides = fields.moduleScopeOverrides; }); communities.set(slug, newDoc); saveCommunity(slug); return true; } /** * Check if community exists */ export async function communityExists(slug: string): Promise { 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 { try { const files = await readdir(STORAGE_DIR); const slugs = new Set(); 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; } { 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(); 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[] { 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 — add DID to forgottenBy map. Shape stays in doc but fades for all. * Three-state: present → forgotten (faded) → deleted */ 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]) { const shape = d.shapes[shapeId] as Record; // Add DID to forgottenBy map (Automerge merges concurrent map writes cleanly) if (!shape.forgottenBy || typeof shape.forgottenBy !== 'object') { shape.forgottenBy = {}; } if (forgottenBy) { (shape.forgottenBy as Record)[forgottenBy] = Date.now(); } // Legacy compat: keep scalar forgotten flag synced shape.forgotten = true; shape.forgottenAt = Date.now(); } }); communities.set(slug, newDoc); saveCommunity(slug); } /** * Remember a forgotten shape — clear forgottenBy map + deleted flag. * Restores shape to present state for everyone. */ 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]) { const shape = d.shapes[shapeId] as Record; shape.forgottenBy = {}; shape.deleted = false; // Legacy compat shape.forgotten = false; shape.forgottenAt = 0; } }); 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, ): 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) */ 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, ): 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)[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): 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; } /** * Find all spaces that a given space is nested into (reverse lookup) */ export async function findNestedIn(sourceSlug: string): Promise> { 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; } // ── Space Connections CRUD ── /** * Add a SpaceConnection to a space's doc */ export function addConnection(slug: string, conn: SpaceConnection): void { const doc = communities.get(slug); if (!doc) return; const newDoc = Automerge.change(doc, `Add connection ${conn.id} → ${conn.remoteSlug}`, (d) => { if (!d.connections) d.connections = {}; d.connections[conn.id] = { ...conn }; }); communities.set(slug, newDoc); saveCommunity(slug); } /** * Update fields on an existing SpaceConnection */ export function updateConnection( slug: string, connId: string, fields: Partial, ): boolean { const doc = communities.get(slug); if (!doc || !doc.connections?.[connId]) return false; const newDoc = Automerge.change(doc, `Update connection ${connId}`, (d) => { if (d.connections[connId]) { for (const [key, value] of Object.entries(fields)) { if (key === 'id') continue; // never change ID (d.connections[connId] as unknown as Record)[key] = value; } } }); communities.set(slug, newDoc); saveCommunity(slug); return true; } /** * Remove a SpaceConnection from a space's doc */ export function removeConnection(slug: string, connId: string): boolean { const doc = communities.get(slug); if (!doc || !doc.connections?.[connId]) return false; const newDoc = Automerge.change(doc, `Remove connection ${connId}`, (d) => { if (d.connections && d.connections[connId]) { delete d.connections[connId]; } }); communities.set(slug, newDoc); saveCommunity(slug); return true; } /** * Get the ConnectionPolicy for a space, with fallback to default */ export function getConnectionPolicy(slug: string): ConnectionPolicy | null { const doc = communities.get(slug); if (!doc) return null; return doc.meta.connectionPolicy || DEFAULT_COMMUNITY_CONNECTION_POLICY; } /** * Update the ConnectionPolicy for a space */ export function updateConnectionPolicy(slug: string, policy: Partial): boolean { const doc = communities.get(slug); if (!doc) return false; const newDoc = Automerge.change(doc, `Update connection policy`, (d) => { const current = d.meta.connectionPolicy || { ...DEFAULT_COMMUNITY_CONNECTION_POLICY }; d.meta.connectionPolicy = { ...current, ...policy, defaultPermissions: { ...current.defaultPermissions, ...(policy.defaultPermissions || {}) }, acceptedFlowKinds: policy.acceptedFlowKinds || current.acceptedFlowKinds, }; }); communities.set(slug, newDoc); saveCommunity(slug); return true; }