From ca5dff072c9e0b396c45338f0c08a06dfef9a962 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Feb 2026 18:43:30 -0800 Subject: [PATCH] feat: add folk-canvas shape, WS cascade enforcement, and at-rest encryption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3: folk-canvas nested space renderer with live WS connection, auto-scaling viewport, collapsed/expanded views, permission badges. Phase 4: WS cascade permission enforcement — nest filter on broadcasts, addShapes/deleteShapes checks, readOnly enforcement for nested connections. Phase 5: AES-256-GCM at-rest encryption for Automerge documents with transparent encrypt-on-save/decrypt-on-load and API toggle endpoints. Co-Authored-By: Claude Opus 4.6 --- docs/SPACE-ARCHITECTURE.md | 13 +- lib/community-sync.ts | 10 + lib/folk-canvas.ts | 546 +++++++++++++++++++++++++++++++++++++ lib/index.ts | 3 + server/community-store.ts | 137 +++++++++- server/index.ts | 89 +++++- server/spaces.ts | 52 ++++ website/canvas.html | 12 + 8 files changed, 849 insertions(+), 13 deletions(-) create mode 100644 lib/folk-canvas.ts diff --git a/docs/SPACE-ARCHITECTURE.md b/docs/SPACE-ARCHITECTURE.md index de1d9c4..d09bc91 100644 --- a/docs/SPACE-ARCHITECTURE.md +++ b/docs/SPACE-ARCHITECTURE.md @@ -791,10 +791,15 @@ GET /api/spaces Returns user's own space first, pinned - Implement revocation propagation - Add `reshare: false` enforcement -### Phase 5: Encryption Integration -- Add `encrypted` flag to `CommunityMeta` -- Implement plaintext projection for nested encrypted spaces -- Implement module-level encryption opt-in (e.g., rWallet always encrypted) +### Phase 5: Encryption Integration (Approach B — Server-Mediated) +- `encrypted` and `encryptionKeyId` flags on `CommunityMeta` — DONE +- AES-256-GCM encryption at rest for Automerge documents — DONE + - Custom file format: magic bytes `rSEN` + keyId length + keyId + IV + ciphertext + - Transparent encrypt-on-save, decrypt-on-load in community-store + - Key derivation via HMAC-SHA256 from server secret + keyId (placeholder for EncryptID L2) +- API endpoints: `GET/PATCH /api/spaces/:slug/encryption` — DONE +- Plaintext projection for nested views: server decrypts and serves via WS — inherent in Approach B +- Future: EncryptID Layer 2 client-side key delegation (Approach C) for true E2E encryption --- diff --git a/lib/community-sync.ts b/lib/community-sync.ts index c4e384b..58b5621 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -737,6 +737,16 @@ export class CommunitySync extends EventTarget { if (data.rankings !== undefined) rank.rankings = data.rankings; } + // Update nested canvas properties + if (data.type === "folk-canvas") { + const canvas = shape as any; + if (data.sourceSlug !== undefined && canvas.sourceSlug !== data.sourceSlug) canvas.sourceSlug = data.sourceSlug; + if (data.sourceDID !== undefined && canvas.sourceDID !== data.sourceDID) canvas.sourceDID = data.sourceDID; + if (data.permissions !== undefined) canvas.permissions = data.permissions; + if (data.collapsed !== undefined && canvas.collapsed !== data.collapsed) canvas.collapsed = data.collapsed; + if (data.label !== undefined && canvas.label !== data.label) canvas.label = data.label; + } + // Update choice-spider properties if (data.type === "folk-choice-spider") { const spider = shape as any; diff --git a/lib/folk-canvas.ts b/lib/folk-canvas.ts new file mode 100644 index 0000000..313c85a --- /dev/null +++ b/lib/folk-canvas.ts @@ -0,0 +1,546 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; +import type { ShapeData, SpaceRef, NestPermissions } from "./community-sync"; + +const styles = css` + :host { + background: #f8fafc; + border-radius: 8px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + min-width: 300px; + min-height: 200px; + overflow: hidden; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + background: #334155; + color: white; + border-radius: 8px 8px 0 0; + font-size: 12px; + font-weight: 600; + cursor: move; + gap: 8px; + } + + .header-left { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + } + + .header-left .icon { + flex-shrink: 0; + } + + .source-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .header-right { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + } + + .badge { + font-size: 10px; + padding: 2px 6px; + border-radius: 9999px; + font-weight: 500; + white-space: nowrap; + } + + .badge-read { + background: rgba(59, 130, 246, 0.2); + color: #93c5fd; + } + + .badge-write { + background: rgba(34, 197, 94, 0.2); + color: #86efac; + } + + .header-actions button { + background: transparent; + border: none; + color: white; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 14px; + line-height: 1; + } + + .header-actions button:hover { + background: rgba(255, 255, 255, 0.2); + } + + .content { + width: 100%; + height: calc(100% - 32px); + position: relative; + overflow: auto; + } + + .nested-canvas { + position: relative; + width: 100%; + height: 100%; + min-height: 150px; + } + + .nested-shape { + position: absolute; + background: white; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 8px; + font-size: 12px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + } + + .nested-shape .shape-type { + font-size: 10px; + color: #94a3b8; + margin-bottom: 4px; + } + + .nested-shape .shape-content { + color: #334155; + word-break: break-word; + } + + .status-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 12px; + background: #f1f5f9; + border-top: 1px solid #e2e8f0; + font-size: 10px; + color: #64748b; + } + + .status-indicator { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + margin-right: 4px; + } + + .status-connected { background: #22c55e; } + .status-connecting { background: #eab308; } + .status-disconnected { background: #ef4444; } + + .collapsed-view { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: calc(100% - 32px); + padding: 16px; + text-align: center; + gap: 8px; + } + + .collapsed-icon { + font-size: 32px; + opacity: 0.5; + } + + .collapsed-label { + font-size: 13px; + color: #475569; + font-weight: 500; + } + + .collapsed-meta { + font-size: 11px; + color: #94a3b8; + } + + .enter-btn { + margin-top: 8px; + background: #334155; + color: white; + border: none; + border-radius: 6px; + padding: 6px 16px; + cursor: pointer; + font-size: 12px; + } + + .enter-btn:hover { + background: #1e293b; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-canvas": FolkCanvas; + } +} + +export class FolkCanvas extends FolkShape { + static override tagName = "folk-canvas"; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + const childRules = Array.from(styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #sourceSlug: string = ""; + #parentSlug: string = ""; // slug of the space this shape lives in (for nest-from context) + #permissions: NestPermissions = { + read: true, write: false, addShapes: false, deleteShapes: false, reshare: false + }; + #collapsed = false; + #label: string | null = null; + #sourceDID: string | null = null; + + // WebSocket connection to nested space + #ws: WebSocket | null = null; + #connectionStatus: "disconnected" | "connecting" | "connected" = "disconnected"; + #nestedShapes: Map = new Map(); + #reconnectAttempts = 0; + #maxReconnectAttempts = 5; + + // DOM refs + #nestedCanvasEl: HTMLElement | null = null; + #statusIndicator: HTMLElement | null = null; + #statusText: HTMLElement | null = null; + #shapeCountEl: HTMLElement | null = null; + + get sourceSlug() { return this.#sourceSlug; } + set sourceSlug(value: string) { + if (this.#sourceSlug === value) return; + this.#sourceSlug = value; + this.requestUpdate("sourceSlug"); + this.dispatchEvent(new CustomEvent("content-change", { detail: { sourceSlug: value } })); + // Reconnect to new source + this.#disconnect(); + if (value) this.#connectToSource(); + } + + get permissions(): NestPermissions { return this.#permissions; } + set permissions(value: NestPermissions) { + this.#permissions = value; + this.requestUpdate("permissions"); + } + + get collapsed() { return this.#collapsed; } + set collapsed(value: boolean) { + this.#collapsed = value; + this.requestUpdate("collapsed"); + this.#renderView(); + } + + get label() { return this.#label; } + set label(value: string | null) { + this.#label = value; + this.requestUpdate("label"); + } + + get sourceDID() { return this.#sourceDID; } + set sourceDID(value: string | null) { this.#sourceDID = value; } + + get parentSlug() { return this.#parentSlug; } + set parentSlug(value: string) { this.#parentSlug = value; } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + // Read initial attributes + this.#sourceSlug = this.getAttribute("source-slug") || ""; + this.#label = this.getAttribute("label") || null; + this.#collapsed = this.getAttribute("collapsed") === "true"; + + const wrapper = document.createElement("div"); + wrapper.style.cssText = "width: 100%; height: 100%; display: flex; flex-direction: column;"; + wrapper.innerHTML = html` +
+
+ \u{1F5BC} + +
+
+ +
+ + + +
+
+
+
+
+
+
+ Disconnected + 0 shapes +
+ `; + + // Replace the slot container + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) containerDiv.replaceWith(wrapper); + + // Cache DOM refs + this.#nestedCanvasEl = wrapper.querySelector(".nested-canvas"); + this.#statusIndicator = wrapper.querySelector(".status-indicator"); + this.#statusText = wrapper.querySelector(".status-text"); + this.#shapeCountEl = wrapper.querySelector(".shape-count"); + + const sourceNameEl = wrapper.querySelector(".source-name") as HTMLElement; + const permBadge = wrapper.querySelector(".permission-badge") as HTMLElement; + const collapseBtn = wrapper.querySelector(".collapse-btn") as HTMLButtonElement; + const enterBtn = wrapper.querySelector(".enter-space-btn") as HTMLButtonElement; + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + + // Set header text + sourceNameEl.textContent = this.#label || this.#sourceSlug || "Nested Space"; + + // Permission badge + if (this.#permissions.write) { + permBadge.textContent = "read + write"; + permBadge.className = "badge badge-write"; + } else { + permBadge.textContent = "read-only"; + permBadge.className = "badge badge-read"; + } + + // Collapse toggle + collapseBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.collapsed = !this.#collapsed; + collapseBtn.textContent = this.#collapsed ? "\u25B6" : "\u25BC"; + this.dispatchEvent(new CustomEvent("content-change", { detail: { collapsed: this.#collapsed } })); + }); + + // Enter space (navigate) + enterBtn.addEventListener("click", (e) => { + e.stopPropagation(); + if (this.#sourceSlug) { + window.open(`/${this.#sourceSlug}/canvas`, "_blank"); + } + }); + + // Close (remove nesting) + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Prevent drag on interactive content + const content = wrapper.querySelector(".content") as HTMLElement; + content.addEventListener("pointerdown", (e) => e.stopPropagation()); + + // Connect to the nested space + if (this.#sourceSlug && !this.#collapsed) { + this.#connectToSource(); + } + + return root; + } + + #connectToSource(): void { + if (!this.#sourceSlug || this.#connectionStatus === "connected" || this.#connectionStatus === "connecting") return; + + this.#setStatus("connecting"); + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const nestParam = this.#parentSlug ? `&nest-from=${encodeURIComponent(this.#parentSlug)}` : ""; + const wsUrl = `${protocol}//${window.location.host}/ws/${this.#sourceSlug}?mode=json${nestParam}`; + + this.#ws = new WebSocket(wsUrl); + + this.#ws.onopen = () => { + this.#connectionStatus = "connected"; + this.#reconnectAttempts = 0; + this.#setStatus("connected"); + }; + + this.#ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === "snapshot" && msg.shapes) { + this.#nestedShapes.clear(); + for (const [id, shape] of Object.entries(msg.shapes)) { + const s = shape as ShapeData; + if (!s.forgotten) { + this.#nestedShapes.set(id, s); + } + } + this.#renderNestedShapes(); + } + } catch (e) { + console.error("[FolkCanvas] Failed to handle message:", e); + } + }; + + this.#ws.onclose = () => { + this.#connectionStatus = "disconnected"; + this.#setStatus("disconnected"); + this.#attemptReconnect(); + }; + + this.#ws.onerror = () => { + this.#setStatus("disconnected"); + }; + } + + #attemptReconnect(): void { + if (this.#reconnectAttempts >= this.#maxReconnectAttempts) return; + this.#reconnectAttempts++; + const delay = Math.min(1000 * Math.pow(2, this.#reconnectAttempts - 1), 16000); + setTimeout(() => { + if (this.#connectionStatus === "disconnected") { + this.#connectToSource(); + } + }, delay); + } + + #disconnect(): void { + if (this.#ws) { + this.#ws.onclose = null; // prevent reconnect + this.#ws.close(); + this.#ws = null; + } + this.#connectionStatus = "disconnected"; + this.#nestedShapes.clear(); + this.#setStatus("disconnected"); + } + + #setStatus(status: "disconnected" | "connecting" | "connected"): void { + this.#connectionStatus = status; + if (this.#statusIndicator) { + this.#statusIndicator.className = `status-indicator status-${status}`; + } + if (this.#statusText) { + this.#statusText.textContent = status.charAt(0).toUpperCase() + status.slice(1); + } + } + + #renderView(): void { + if (!this.shadowRoot) return; + const content = this.shadowRoot.querySelector(".content") as HTMLElement; + const statusBar = this.shadowRoot.querySelector(".status-bar") as HTMLElement; + if (!content || !statusBar) return; + + if (this.#collapsed) { + content.innerHTML = ` +
+
\u{1F5BC}
+
${this.#label || this.#sourceSlug}
+
${this.#nestedShapes.size} shapes
+ +
+ `; + statusBar.style.display = "none"; + const enterBtn = content.querySelector(".enter-btn"); + enterBtn?.addEventListener("click", () => { + if (this.#sourceSlug) window.open(`/${this.#sourceSlug}/canvas`, "_blank"); + }); + // Disconnect when collapsed + this.#disconnect(); + } else { + content.innerHTML = `
`; + this.#nestedCanvasEl = content.querySelector(".nested-canvas"); + statusBar.style.display = "flex"; + // Reconnect when expanded + if (this.#sourceSlug) this.#connectToSource(); + this.#renderNestedShapes(); + } + } + + #renderNestedShapes(): void { + if (!this.#nestedCanvasEl) return; + + this.#nestedCanvasEl.innerHTML = ""; + + if (this.#nestedShapes.size === 0) { + this.#nestedCanvasEl.innerHTML = ` +
+ ${this.#connectionStatus === "connected" ? "Empty space" : "Connecting..."} +
+ `; + } + + // Calculate bounding box of all shapes to fit within our viewport + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const shape of this.#nestedShapes.values()) { + minX = Math.min(minX, shape.x); + minY = Math.min(minY, shape.y); + maxX = Math.max(maxX, shape.x + (shape.width || 300)); + maxY = Math.max(maxY, shape.y + (shape.height || 200)); + } + + const contentWidth = maxX - minX || 1; + const contentHeight = maxY - minY || 1; + const canvasWidth = this.#nestedCanvasEl.clientWidth || 600; + const canvasHeight = this.#nestedCanvasEl.clientHeight || 400; + const scale = Math.min(canvasWidth / contentWidth, canvasHeight / contentHeight, 1) * 0.9; + const offsetX = (canvasWidth - contentWidth * scale) / 2; + const offsetY = (canvasHeight - contentHeight * scale) / 2; + + for (const shape of this.#nestedShapes.values()) { + const el = document.createElement("div"); + el.className = "nested-shape"; + el.style.left = `${offsetX + (shape.x - minX) * scale}px`; + el.style.top = `${offsetY + (shape.y - minY) * scale}px`; + el.style.width = `${(shape.width || 300) * scale}px`; + el.style.height = `${(shape.height || 200) * scale}px`; + + const typeLabel = shape.type.replace("folk-", "").replace(/-/g, " "); + const content = shape.content + ? shape.content.slice(0, 100) + (shape.content.length > 100 ? "..." : "") + : ""; + + el.innerHTML = ` +
${typeLabel}
+
${content}
+ `; + + this.#nestedCanvasEl.appendChild(el); + } + + // Update shape count + if (this.#shapeCountEl) { + this.#shapeCountEl.textContent = `${this.#nestedShapes.size} shape${this.#nestedShapes.size !== 1 ? "s" : ""}`; + } + } + + disconnectedCallback(): void { + this.#disconnect(); + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-canvas", + sourceSlug: this.#sourceSlug, + sourceDID: this.#sourceDID, + permissions: this.#permissions, + collapsed: this.#collapsed, + label: this.#label, + }; + } +} diff --git a/lib/index.ts b/lib/index.ts index 90c6aba..412e66e 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -63,6 +63,9 @@ export * from "./folk-choice-vote"; export * from "./folk-choice-rank"; export * from "./folk-choice-spider"; +// Nested Space Shape +export * from "./folk-canvas"; + // Sync export * from "./community-sync"; export * from "./presence"; diff --git a/server/community-store.ts b/server/community-store.ts index c3e4158..2163b0d 100644 --- a/server/community-store.ts +++ b/server/community-store.ts @@ -198,7 +198,24 @@ export async function loadCommunity(slug: string): Promise(new Uint8Array(buffer)); + 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(bytes); communities.set(slug, doc); return doc; } catch (e) { @@ -271,8 +288,31 @@ export async function saveCommunity(slug: string): Promise { const binary = Automerge.save(currentDoc); const path = `${STORAGE_DIR}/${slug}.automerge`; - await Bun.write(path, binary); - console.log(`[Store] Saved ${slug} (${binary.length} bytes)`); + + // 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); @@ -830,6 +870,97 @@ export function cascadePermissions(chain: NestPermissions[]): NestPermissions { }; } +// ── 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 { + const serverSecret = process.env.ENCRYPTION_SECRET || 'REDACTED_ENCRYPTION_FALLBACK'; + 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 { + 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 { + 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) */ diff --git a/server/index.ts b/server/index.ts index fce8d89..3c7dd6b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -24,7 +24,9 @@ import { removePeerSyncState, updateShape, updateShapeFields, + cascadePermissions, } from "./community-store"; +import type { NestPermissions, SpaceRefFilter } from "./community-store"; import { ensureDemoCommunity } from "./seed-demo"; import { ensureCampaignDemo } from "./seed-campaign"; import type { SpaceVisibility } from "./community-store"; @@ -352,6 +354,10 @@ interface WSData { claims: EncryptIDClaims | null; readOnly: boolean; mode: "automerge" | "json"; + // Nest context: set when a folk-canvas shape connects to a nested space + nestFrom?: string; // slug of the parent space that contains the nest + nestPermissions?: NestPermissions; // effective permissions for this nested view + nestFilter?: SpaceRefFilter; // shape filter applied to this nested view } // Track connected clients per community @@ -370,10 +376,26 @@ function broadcastJsonSnapshot(slug: string, excludePeerId?: string): void { if (!clients) return; const docData = getDocumentData(slug); if (!docData) return; - const snapshotMsg = JSON.stringify({ type: "snapshot", shapes: docData.shapes || {} }); + const allShapes = docData.shapes || {}; + + // Pre-build the unfiltered message (most clients use this) + let unfilteredMsg: string | null = null; + for (const [clientPeerId, client] of clients) { if (client.data.mode === "json" && clientPeerId !== excludePeerId && client.readyState === WebSocket.OPEN) { - client.send(snapshotMsg); + // Apply nest filter for nested connections + if (client.data.nestFilter) { + const filtered: typeof allShapes = {}; + for (const [id, shape] of Object.entries(allShapes)) { + if (client.data.nestFilter.shapeTypes && !client.data.nestFilter.shapeTypes.includes(shape.type)) continue; + if (client.data.nestFilter.shapeIds && !client.data.nestFilter.shapeIds.includes(id)) continue; + filtered[id] = shape; + } + client.send(JSON.stringify({ type: "snapshot", shapes: filtered })); + } else { + if (!unfilteredMsg) unfilteredMsg = JSON.stringify({ type: "snapshot", shapes: allShapes }); + client.send(unfilteredMsg); + } } } } @@ -516,8 +538,32 @@ const server = Bun.serve({ const peerId = generatePeerId(); const mode = url.searchParams.get("mode") === "json" ? "json" : "automerge"; + + // Nest context: if connecting from a parent space via folk-canvas + const nestFrom = url.searchParams.get("nest-from") || undefined; + let nestPermissions: NestPermissions | undefined; + let nestFilter: SpaceRefFilter | undefined; + + if (nestFrom) { + await loadCommunity(nestFrom); + const parentData = getDocumentData(nestFrom); + if (parentData?.nestedSpaces) { + // Find the SpaceRef in the parent that points to this space + const matchingRef = Object.values(parentData.nestedSpaces) + .find(ref => ref.sourceSlug === communitySlug); + if (matchingRef) { + nestPermissions = matchingRef.permissions; + nestFilter = matchingRef.filter; + // If nest doesn't allow writes, force readOnly + if (!matchingRef.permissions.write) { + readOnly = true; + } + } + } + } + const upgraded = server.upgrade(req, { - data: { communitySlug, peerId, claims, readOnly, mode } as WSData, + data: { communitySlug, peerId, claims, readOnly, mode, nestFrom, nestPermissions, nestFilter } as WSData, }); if (upgraded) return undefined; } @@ -593,14 +639,15 @@ const server = Bun.serve({ // ── WebSocket handlers (unchanged) ── websocket: { open(ws: ServerWebSocket) { - const { communitySlug, peerId, mode } = ws.data; + const { communitySlug, peerId, mode, nestFrom, nestFilter } = ws.data; if (!communityClients.has(communitySlug)) { communityClients.set(communitySlug, new Map()); } communityClients.get(communitySlug)!.set(peerId, ws); - console.log(`[WS] Client ${peerId} connected to ${communitySlug} (mode: ${mode})`); + const nestLabel = nestFrom ? ` (nested from ${nestFrom})` : ""; + console.log(`[WS] Client ${peerId} connected to ${communitySlug} (mode: ${mode})${nestLabel}`); loadCommunity(communitySlug).then((doc) => { if (!doc) return; @@ -608,7 +655,20 @@ const server = Bun.serve({ if (mode === "json") { const docData = getDocumentData(communitySlug); if (docData) { - ws.send(JSON.stringify({ type: "snapshot", shapes: docData.shapes || {} })); + let shapes = docData.shapes || {}; + + // Apply nest filter if this is a nested connection + if (nestFilter) { + const filtered: typeof shapes = {}; + for (const [id, shape] of Object.entries(shapes)) { + if (nestFilter.shapeTypes && !nestFilter.shapeTypes.includes(shape.type)) continue; + if (nestFilter.shapeIds && !nestFilter.shapeIds.includes(id)) continue; + filtered[id] = shape; + } + shapes = filtered; + } + + ws.send(JSON.stringify({ type: "snapshot", shapes })); } } else { const syncMessage = generateSyncMessageForPeer(communitySlug, peerId); @@ -664,6 +724,15 @@ const server = Bun.serve({ ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit" })); return; } + // Nest-level: check addShapes for new shapes + if (ws.data.nestPermissions) { + const existingDoc = getDocumentData(communitySlug); + const isNewShape = existingDoc && !existingDoc.shapes?.[msg.id]; + if (isNewShape && !ws.data.nestPermissions.addShapes) { + ws.send(JSON.stringify({ type: "error", message: "Nest permissions do not allow adding shapes" })); + return; + } + } updateShape(communitySlug, msg.id, msg.data); broadcastJsonSnapshot(communitySlug, peerId); broadcastAutomergeSync(communitySlug, peerId); @@ -672,6 +741,10 @@ const server = Bun.serve({ ws.send(JSON.stringify({ type: "error", message: "Authentication required to delete" })); return; } + if (ws.data.nestPermissions && !ws.data.nestPermissions.deleteShapes) { + ws.send(JSON.stringify({ type: "error", message: "Nest permissions do not allow deleting shapes" })); + return; + } forgetShape(communitySlug, msg.id, ws.data.claims?.sub); broadcastJsonSnapshot(communitySlug, peerId); broadcastAutomergeSync(communitySlug, peerId); @@ -680,6 +753,10 @@ const server = Bun.serve({ ws.send(JSON.stringify({ type: "error", message: "Authentication required to forget" })); return; } + if (ws.data.nestPermissions && !ws.data.nestPermissions.deleteShapes) { + ws.send(JSON.stringify({ type: "error", message: "Nest permissions do not allow forgetting shapes" })); + return; + } forgetShape(communitySlug, msg.id, ws.data.claims?.sub); broadcastJsonSnapshot(communitySlug, peerId); broadcastAutomergeSync(communitySlug, peerId); diff --git a/server/spaces.ts b/server/spaces.ts index 6904b7c..fba2f1d 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -21,6 +21,7 @@ import { updateNestPolicy, capPermissions, findNestedIn, + setEncryption, DEFAULT_COMMUNITY_NEST_POLICY, } from "./community-store"; import type { @@ -622,4 +623,55 @@ spaces.patch("/:slug/nest-requests/:reqId", async (c) => { return c.json({ error: "action must be 'approve' or 'deny'" }, 400); }); +// ══════════════════════════════════════════════════════════════════════════════ +// ENCRYPTION API +// ══════════════════════════════════════════════════════════════════════════════ + +// ── Get encryption status ── + +spaces.get("/:slug/encryption", async (c) => { + const slug = c.req.param("slug"); + await loadCommunity(slug); + const data = getDocumentData(slug); + if (!data) return c.json({ error: "Space not found" }, 404); + + return c.json({ + encrypted: !!data.meta.encrypted, + encryptionKeyId: data.meta.encryptionKeyId || null, + }); +}); + +// ── Toggle encryption (admin only) ── + +spaces.patch("/:slug/encryption", async (c) => { + const slug = c.req.param("slug"); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + await loadCommunity(slug); + const data = getDocumentData(slug); + if (!data) return c.json({ error: "Space not found" }, 404); + + // Must be admin or owner + const member = data.members?.[claims.sub]; + const isOwner = data.meta.ownerDID === claims.sub; + if (!isOwner && member?.role !== 'admin') { + return c.json({ error: "Admin access required" }, 403); + } + + const body = await c.req.json<{ encrypted: boolean; encryptionKeyId?: string }>(); + setEncryption(slug, body.encrypted, body.encryptionKeyId); + + return c.json({ + ok: true, + encrypted: body.encrypted, + message: body.encrypted + ? "Space encryption enabled. Document will be encrypted at rest." + : "Space encryption disabled. Document will be stored in plaintext.", + }); +}); + export { spaces }; diff --git a/website/canvas.html b/website/canvas.html index e1d4888..b2cb4b7 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -540,6 +540,7 @@ FolkChoiceRank, FolkChoiceSpider, FolkSocialPost, + FolkCanvas, CommunitySync, PresenceManager, generatePeerId, @@ -598,6 +599,7 @@ FolkChoiceRank.define(); FolkChoiceSpider.define(); FolkSocialPost.define(); + FolkCanvas.define(); // Get community info from URL // Supports path-based slugs: cca.rspace.online/campaign/demo → slug "campaign-demo" @@ -943,6 +945,15 @@ if (data.hashtags) shape.hashtags = data.hashtags; if (data.stepNumber) shape.stepNumber = data.stepNumber; break; + case "folk-canvas": + shape = document.createElement("folk-canvas"); + shape.parentSlug = communitySlug; // pass parent context for nest-from + if (data.sourceSlug) shape.sourceSlug = data.sourceSlug; + if (data.sourceDID) shape.sourceDID = data.sourceDID; + if (data.permissions) shape.permissions = data.permissions; + if (data.collapsed != null) shape.collapsed = data.collapsed; + if (data.label) shape.label = data.label; + break; case "folk-markdown": default: shape = document.createElement("folk-markdown"); @@ -1012,6 +1023,7 @@ "folk-choice-rank": { width: 380, height: 480 }, "folk-choice-spider": { width: 440, height: 540 }, "folk-social-post": { width: 300, height: 380 }, + "folk-canvas": { width: 600, height: 400 }, }; // Get the center of the current viewport in canvas coordinates