feat: add folk-canvas shape, WS cascade enforcement, and at-rest encryption

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-24 18:43:30 -08:00
parent 91cb68a09f
commit ca5dff072c
8 changed files with 849 additions and 13 deletions

View File

@ -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
---

View File

@ -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;

546
lib/folk-canvas.ts Normal file
View File

@ -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<string, ShapeData> = 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`
<div class="header" data-drag>
<div class="header-left">
<span class="icon">\u{1F5BC}</span>
<span class="source-name"></span>
</div>
<div class="header-right">
<span class="permission-badge badge"></span>
<div class="header-actions">
<button class="collapse-btn" title="Toggle collapse">\u25BC</button>
<button class="enter-space-btn" title="Open space">\u2197</button>
<button class="close-btn" title="Remove">\u00D7</button>
</div>
</div>
</div>
<div class="content">
<div class="nested-canvas"></div>
</div>
<div class="status-bar">
<span><span class="status-indicator status-disconnected"></span><span class="status-text">Disconnected</span></span>
<span class="shape-count">0 shapes</span>
</div>
`;
// 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 = `
<div class="collapsed-view">
<div class="collapsed-icon">\u{1F5BC}</div>
<div class="collapsed-label">${this.#label || this.#sourceSlug}</div>
<div class="collapsed-meta">${this.#nestedShapes.size} shapes</div>
<button class="enter-btn">Open space \u2192</button>
</div>
`;
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 = `<div class="nested-canvas"></div>`;
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 = `
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#94a3b8;font-size:12px;">
${this.#connectionStatus === "connected" ? "Empty space" : "Connecting..."}
</div>
`;
}
// 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 = `
<div class="shape-type">${typeLabel}</div>
<div class="shape-content">${content}</div>
`;
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,
};
}
}

View File

@ -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";

View File

@ -198,7 +198,24 @@ export async function loadCommunity(slug: string): Promise<Automerge.Doc<Communi
if (await binaryFile.exists()) {
try {
const buffer = await binaryFile.arrayBuffer();
const doc = Automerge.load<CommunityDoc>(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<CommunityDoc>(bytes);
communities.set(slug, doc);
return doc;
} catch (e) {
@ -271,8 +288,31 @@ export async function saveCommunity(slug: string): Promise<void> {
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<CryptoKey> {
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<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)
*/

View File

@ -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<WSData>({
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<WSData>({
// ── WebSocket handlers (unchanged) ──
websocket: {
open(ws: ServerWebSocket<WSData>) {
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<WSData>({
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<WSData>({
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<WSData>({
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<WSData>({
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);

View File

@ -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 };

View File

@ -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