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
|
- Implement revocation propagation
|
||||||
- Add `reshare: false` enforcement
|
- Add `reshare: false` enforcement
|
||||||
|
|
||||||
### Phase 5: Encryption Integration
|
### Phase 5: Encryption Integration (Approach B — Server-Mediated)
|
||||||
- Add `encrypted` flag to `CommunityMeta`
|
- `encrypted` and `encryptionKeyId` flags on `CommunityMeta` — DONE
|
||||||
- Implement plaintext projection for nested encrypted spaces
|
- AES-256-GCM encryption at rest for Automerge documents — DONE
|
||||||
- Implement module-level encryption opt-in (e.g., rWallet always encrypted)
|
- 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;
|
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
|
// Update choice-spider properties
|
||||||
if (data.type === "folk-choice-spider") {
|
if (data.type === "folk-choice-spider") {
|
||||||
const spider = shape as any;
|
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-rank";
|
||||||
export * from "./folk-choice-spider";
|
export * from "./folk-choice-spider";
|
||||||
|
|
||||||
|
// Nested Space Shape
|
||||||
|
export * from "./folk-canvas";
|
||||||
|
|
||||||
// Sync
|
// Sync
|
||||||
export * from "./community-sync";
|
export * from "./community-sync";
|
||||||
export * from "./presence";
|
export * from "./presence";
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,24 @@ export async function loadCommunity(slug: string): Promise<Automerge.Doc<Communi
|
||||||
if (await binaryFile.exists()) {
|
if (await binaryFile.exists()) {
|
||||||
try {
|
try {
|
||||||
const buffer = await binaryFile.arrayBuffer();
|
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);
|
communities.set(slug, doc);
|
||||||
return doc;
|
return doc;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -271,8 +288,31 @@ export async function saveCommunity(slug: string): Promise<void> {
|
||||||
|
|
||||||
const binary = Automerge.save(currentDoc);
|
const binary = Automerge.save(currentDoc);
|
||||||
const path = `${STORAGE_DIR}/${slug}.automerge`;
|
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);
|
}, 2000);
|
||||||
|
|
||||||
saveTimers.set(slug, timer);
|
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)
|
* Find all spaces that a given space is nested into (reverse lookup)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,9 @@ import {
|
||||||
removePeerSyncState,
|
removePeerSyncState,
|
||||||
updateShape,
|
updateShape,
|
||||||
updateShapeFields,
|
updateShapeFields,
|
||||||
|
cascadePermissions,
|
||||||
} from "./community-store";
|
} from "./community-store";
|
||||||
|
import type { NestPermissions, SpaceRefFilter } from "./community-store";
|
||||||
import { ensureDemoCommunity } from "./seed-demo";
|
import { ensureDemoCommunity } from "./seed-demo";
|
||||||
import { ensureCampaignDemo } from "./seed-campaign";
|
import { ensureCampaignDemo } from "./seed-campaign";
|
||||||
import type { SpaceVisibility } from "./community-store";
|
import type { SpaceVisibility } from "./community-store";
|
||||||
|
|
@ -352,6 +354,10 @@ interface WSData {
|
||||||
claims: EncryptIDClaims | null;
|
claims: EncryptIDClaims | null;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
mode: "automerge" | "json";
|
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
|
// Track connected clients per community
|
||||||
|
|
@ -370,10 +376,26 @@ function broadcastJsonSnapshot(slug: string, excludePeerId?: string): void {
|
||||||
if (!clients) return;
|
if (!clients) return;
|
||||||
const docData = getDocumentData(slug);
|
const docData = getDocumentData(slug);
|
||||||
if (!docData) return;
|
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) {
|
for (const [clientPeerId, client] of clients) {
|
||||||
if (client.data.mode === "json" && clientPeerId !== excludePeerId && client.readyState === WebSocket.OPEN) {
|
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 peerId = generatePeerId();
|
||||||
const mode = url.searchParams.get("mode") === "json" ? "json" : "automerge";
|
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, {
|
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;
|
if (upgraded) return undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -593,14 +639,15 @@ const server = Bun.serve<WSData>({
|
||||||
// ── WebSocket handlers (unchanged) ──
|
// ── WebSocket handlers (unchanged) ──
|
||||||
websocket: {
|
websocket: {
|
||||||
open(ws: ServerWebSocket<WSData>) {
|
open(ws: ServerWebSocket<WSData>) {
|
||||||
const { communitySlug, peerId, mode } = ws.data;
|
const { communitySlug, peerId, mode, nestFrom, nestFilter } = ws.data;
|
||||||
|
|
||||||
if (!communityClients.has(communitySlug)) {
|
if (!communityClients.has(communitySlug)) {
|
||||||
communityClients.set(communitySlug, new Map());
|
communityClients.set(communitySlug, new Map());
|
||||||
}
|
}
|
||||||
communityClients.get(communitySlug)!.set(peerId, ws);
|
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) => {
|
loadCommunity(communitySlug).then((doc) => {
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
|
|
@ -608,7 +655,20 @@ const server = Bun.serve<WSData>({
|
||||||
if (mode === "json") {
|
if (mode === "json") {
|
||||||
const docData = getDocumentData(communitySlug);
|
const docData = getDocumentData(communitySlug);
|
||||||
if (docData) {
|
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 {
|
} else {
|
||||||
const syncMessage = generateSyncMessageForPeer(communitySlug, peerId);
|
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" }));
|
ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit" }));
|
||||||
return;
|
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);
|
updateShape(communitySlug, msg.id, msg.data);
|
||||||
broadcastJsonSnapshot(communitySlug, peerId);
|
broadcastJsonSnapshot(communitySlug, peerId);
|
||||||
broadcastAutomergeSync(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" }));
|
ws.send(JSON.stringify({ type: "error", message: "Authentication required to delete" }));
|
||||||
return;
|
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);
|
forgetShape(communitySlug, msg.id, ws.data.claims?.sub);
|
||||||
broadcastJsonSnapshot(communitySlug, peerId);
|
broadcastJsonSnapshot(communitySlug, peerId);
|
||||||
broadcastAutomergeSync(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" }));
|
ws.send(JSON.stringify({ type: "error", message: "Authentication required to forget" }));
|
||||||
return;
|
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);
|
forgetShape(communitySlug, msg.id, ws.data.claims?.sub);
|
||||||
broadcastJsonSnapshot(communitySlug, peerId);
|
broadcastJsonSnapshot(communitySlug, peerId);
|
||||||
broadcastAutomergeSync(communitySlug, peerId);
|
broadcastAutomergeSync(communitySlug, peerId);
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
updateNestPolicy,
|
updateNestPolicy,
|
||||||
capPermissions,
|
capPermissions,
|
||||||
findNestedIn,
|
findNestedIn,
|
||||||
|
setEncryption,
|
||||||
DEFAULT_COMMUNITY_NEST_POLICY,
|
DEFAULT_COMMUNITY_NEST_POLICY,
|
||||||
} from "./community-store";
|
} from "./community-store";
|
||||||
import type {
|
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);
|
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 };
|
export { spaces };
|
||||||
|
|
|
||||||
|
|
@ -540,6 +540,7 @@
|
||||||
FolkChoiceRank,
|
FolkChoiceRank,
|
||||||
FolkChoiceSpider,
|
FolkChoiceSpider,
|
||||||
FolkSocialPost,
|
FolkSocialPost,
|
||||||
|
FolkCanvas,
|
||||||
CommunitySync,
|
CommunitySync,
|
||||||
PresenceManager,
|
PresenceManager,
|
||||||
generatePeerId,
|
generatePeerId,
|
||||||
|
|
@ -598,6 +599,7 @@
|
||||||
FolkChoiceRank.define();
|
FolkChoiceRank.define();
|
||||||
FolkChoiceSpider.define();
|
FolkChoiceSpider.define();
|
||||||
FolkSocialPost.define();
|
FolkSocialPost.define();
|
||||||
|
FolkCanvas.define();
|
||||||
|
|
||||||
// Get community info from URL
|
// Get community info from URL
|
||||||
// Supports path-based slugs: cca.rspace.online/campaign/demo → slug "campaign-demo"
|
// 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.hashtags) shape.hashtags = data.hashtags;
|
||||||
if (data.stepNumber) shape.stepNumber = data.stepNumber;
|
if (data.stepNumber) shape.stepNumber = data.stepNumber;
|
||||||
break;
|
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":
|
case "folk-markdown":
|
||||||
default:
|
default:
|
||||||
shape = document.createElement("folk-markdown");
|
shape = document.createElement("folk-markdown");
|
||||||
|
|
@ -1012,6 +1023,7 @@
|
||||||
"folk-choice-rank": { width: 380, height: 480 },
|
"folk-choice-rank": { width: 380, height: 480 },
|
||||||
"folk-choice-spider": { width: 440, height: 540 },
|
"folk-choice-spider": { width: 440, height: 540 },
|
||||||
"folk-social-post": { width: 300, height: 380 },
|
"folk-social-post": { width: 300, height: 380 },
|
||||||
|
"folk-canvas": { width: 600, height: 400 },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get the center of the current viewport in canvas coordinates
|
// Get the center of the current viewport in canvas coordinates
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue