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:
parent
91cb68a09f
commit
ca5dff072c
|
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue