From dfa09a39f638988277d1281182f63bce7d82af4e Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 2 Mar 2026 19:39:43 -0800 Subject: [PATCH] feat: email invite endpoint, canvas share panel, backlog task-77 done - Add POST /:slug/invite email endpoint (nodemailer via Mailcow SMTP) - Add share badge + panel UI to canvas whiteboard - Mark task-77 (encrypted VPS backup) as Done with updated references Co-Authored-By: Claude Opus 4.6 --- ...crypted-VPS-backup-for-client-side-data.md | 67 +++- server/spaces.ts | 49 +++ website/canvas.html | 338 +++++++++++++++++- 3 files changed, 444 insertions(+), 10 deletions(-) diff --git a/backlog/tasks/task-77 - EncryptID-Optional-encrypted-VPS-backup-for-client-side-data.md b/backlog/tasks/task-77 - EncryptID-Optional-encrypted-VPS-backup-for-client-side-data.md index 045c32e..5628dad 100644 --- a/backlog/tasks/task-77 - EncryptID-Optional-encrypted-VPS-backup-for-client-side-data.md +++ b/backlog/tasks/task-77 - EncryptID-Optional-encrypted-VPS-backup-for-client-side-data.md @@ -1,18 +1,28 @@ --- id: TASK-77 title: 'EncryptID: Optional encrypted VPS backup for client-side data' -status: To Do +status: Done assignee: [] created_date: '2026-03-02 20:19' +updated_date: '2026-03-03 03:31' labels: - encryptid - privacy - feature dependencies: [] references: - - src/encryptid/wallet-store.ts - - src/encryptid/key-derivation.ts - - src/encryptid/server.ts + - server/local-first/encryption-utils.ts + - server/local-first/backup-store.ts + - server/local-first/backup-routes.ts + - shared/local-first/backup.ts + - server/local-first/doc-persistence.ts + - server/local-first/sync-server.ts + - server/sync-instance.ts + - shared/local-first/sync.ts + - shared/local-first/encryptid-bridge.ts + - docs/DATA-ARCHITECTURE.md +documentation: + - docs/DATA-ARCHITECTURE.md priority: medium --- @@ -33,9 +43,50 @@ Consider extending this to all client-side data (wallet associations, preference ## Acceptance Criteria -- [ ] #1 Settings UI toggle for encrypted backup (default: off) -- [ ] #2 Encrypted blobs sync to EncryptID server when enabled -- [ ] #3 Restore flow on new device after passkey auth -- [ ] #4 Server never sees plaintext — only stores opaque ciphertext + IV +- [x] #1 Settings UI toggle for encrypted backup (default: off) +- [x] #2 Encrypted blobs sync to EncryptID server when enabled +- [x] #3 Restore flow on new device after passkey auth +- [x] #4 Server never sees plaintext — only stores opaque ciphertext + IV - [ ] #5 User can optionally specify a custom VPS endpoint for backup + +## Implementation Notes + + +2026-03-03: Implemented full 4-layer architecture. AC #5 (custom VPS) deferred to future Layer 3 federated replication phase. Commit 46c2a0b on dev, merged to main, deployed to Netcup. + + +## Final Summary + + +## Layered Local-First Data Architecture — Complete Implementation + +### What was done +Implemented the full 4-layer data architecture (device → encrypted backup → shared sync → federated): + +**New files (5):** +- `server/local-first/encryption-utils.ts` — Shared AES-256-GCM primitives (deriveSpaceKey, encrypt/decrypt, rSEN pack/unpack) +- `server/local-first/backup-store.ts` — Filesystem opaque blob storage with manifest tracking +- `server/local-first/backup-routes.ts` — Hono REST API (PUT/GET/DELETE /api/backup/:space/:docId) with JWT auth +- `shared/local-first/backup.ts` — BackupSyncManager with delta-only push, full restore, auto-backup +- `docs/DATA-ARCHITECTURE.md` — 4-layer architecture design doc with threat model and data flow diagrams + +**Modified files (10):** +- `server/community-store.ts` — Replaced inline encryption with shared encryption-utils +- `server/local-first/doc-persistence.ts` — Added encryptionKeyId param, rSEN detection in loadAllDocs, saveEncryptedBlob/loadEncryptedBlob for relay blobs +- `server/local-first/sync-server.ts` — Added relay-backup/relay-restore wire messages, onRelayBackup/onRelayLoad callbacks +- `server/sync-instance.ts` — Added encryption key lookup + relay backup/load wiring +- `shared/local-first/sync.ts` — Added RelayBackupMessage/RelayRestoreMessage types, sendRelayBackup(), handleRelayRestore() +- `shared/local-first/storage.ts` — Added loadRaw() for backup manager +- `shared/local-first/encryptid-bridge.ts` — Wired backup stubs to BackupSyncManager, added getBackupManager()/initBackupManager() +- `shared/local-first/index.ts` — Exported new backup + relay message types +- `docker-compose.yml` — Added rspace-backups:/data/backups volume +- `server/index.ts` — Mounted backup routes at /api/backup + +### Verification +- `npx tsc --noEmit` — zero errors +- `bun run scripts/test-automerge-roundtrip.ts` — 35/35 pass +- Deployed to Netcup, container starts cleanly with 33 docs loaded + +### AC #5 (custom VPS endpoint) deferred to Layer 3 (Federated Replication) — designed in DATA-ARCHITECTURE.md but not yet implemented. + diff --git a/server/spaces.ts b/server/spaces.ts index aa02e20..050a90a 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -8,6 +8,7 @@ import { Hono } from "hono"; import { stat } from "node:fs/promises"; +import { createTransport, type Transporter } from "nodemailer"; import { communityExists, createCommunity, @@ -1368,4 +1369,52 @@ spaces.post("/:slug/copy-shapes", async (c) => { return c.json({ ok: true, count: remapped.length }, 201); }); +// ── Invite by email ── + +let inviteTransport: Transporter | null = null; + +if (process.env.SMTP_PASS) { + inviteTransport = createTransport({ + host: process.env.SMTP_HOST || "mail.rmail.online", + port: Number(process.env.SMTP_PORT) || 587, + secure: Number(process.env.SMTP_PORT) === 465, + auth: { + user: process.env.SMTP_USER || "noreply@rspace.online", + pass: process.env.SMTP_PASS, + }, + tls: { rejectUnauthorized: false }, + }); +} + +spaces.post("/:slug/invite", async (c) => { + const { slug } = c.req.param(); + const { email, shareUrl } = await c.req.json<{ email: string; shareUrl: string }>(); + + if (!email || !shareUrl) { + return c.json({ error: "email and shareUrl required" }, 400); + } + + if (!inviteTransport) { + console.warn("Invite email skipped (SMTP not configured) —", email, shareUrl); + return c.json({ error: "Email not configured" }, 503); + } + + try { + await inviteTransport.sendMail({ + from: process.env.SMTP_FROM || "rSpace ", + to: email, + subject: `You're invited to join "${slug}" on rSpace`, + html: [ + `

You've been invited to collaborate on ${slug}.

`, + `

Join the space

`, + `

rSpace — collaborative knowledge work

`, + ].join("\n"), + }); + return c.json({ ok: true }); + } catch (err: any) { + console.error("Invite email failed:", err.message); + return c.json({ error: "Failed to send email" }, 500); + } +}); + export { spaces }; diff --git a/website/canvas.html b/website/canvas.html index d353061..612d394 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -757,6 +757,183 @@ color: #64748b; } + /* ── Share badge & panel ── */ + #share-badge { + position: fixed; + bottom: 16px; + right: 310px; + padding: 6px 12px; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + font-size: 12px; + color: #64748b; + z-index: 1000; + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + user-select: none; + transition: box-shadow 0.15s; + } + + #share-badge:hover { + box-shadow: 0 2px 14px rgba(0, 0, 0, 0.18); + } + + #share-panel { + position: fixed; + bottom: 56px; + right: 16px; + width: 320px; + background: white; + border-radius: 12px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18); + z-index: 1001; + display: none; + overflow: hidden; + } + + #share-panel.open { + display: block; + } + + #share-panel-header { + padding: 12px 16px; + border-bottom: 1px solid #e2e8f0; + display: flex; + align-items: center; + justify-content: space-between; + } + + #share-panel-header h3 { + font-size: 14px; + color: #0f172a; + margin: 0; + } + + #share-panel-close { + background: none; + border: none; + font-size: 18px; + color: #94a3b8; + cursor: pointer; + padding: 0 4px; + line-height: 1; + } + + #share-panel-close:hover { + color: #0f172a; + } + + #share-panel-body { + padding: 0; + } + + .share-section { + padding: 12px 16px; + border-bottom: 1px solid #f1f5f9; + } + + .share-section:last-child { + border-bottom: none; + } + + #share-qr { + display: block; + margin: 0 auto; + border-radius: 8px; + } + + .share-link-row { + display: flex; + gap: 8px; + align-items: center; + } + + .share-link-row input { + flex: 1; + padding: 6px 10px; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 12px; + color: #334155; + background: #f8fafc; + outline: none; + } + + .share-link-row input:focus { + border-color: #14b8a6; + } + + #share-copy-btn { + padding: 6px 14px; + border: none; + border-radius: 6px; + background: #14b8a6; + color: white; + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s; + } + + #share-copy-btn:hover { + background: #0d9488; + } + + .share-section label { + display: block; + font-size: 12px; + color: #64748b; + margin-bottom: 8px; + } + + .share-email-row { + display: flex; + gap: 8px; + align-items: center; + } + + .share-email-row input { + flex: 1; + padding: 6px 10px; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 12px; + color: #334155; + background: white; + outline: none; + } + + .share-email-row input:focus { + border-color: #14b8a6; + } + + #share-send-btn { + padding: 6px 14px; + border: none; + border-radius: 6px; + background: #3b82f6; + color: white; + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s; + } + + #share-send-btn:hover { + background: #2563eb; + } + + #share-email-status { + font-size: 11px; + margin-top: 6px; + min-height: 16px; + } + /* ── People Online badge ── */ #people-online-badge { position: fixed; @@ -963,6 +1140,54 @@ background: rgba(255, 255, 255, 0.4); } + /* ── Share panel dark mode ── */ + body[data-theme="dark"] #share-badge { + background: #1e293b; + color: #94a3b8; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); + } + + body[data-theme="dark"] #share-panel { + background: #1e293b; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); + } + + body[data-theme="dark"] #share-panel-header { + border-bottom-color: #334155; + } + + body[data-theme="dark"] #share-panel-header h3 { + color: #e2e8f0; + } + + body[data-theme="dark"] #share-panel-close { + color: #64748b; + } + + body[data-theme="dark"] #share-panel-close:hover { + color: #e2e8f0; + } + + body[data-theme="dark"] .share-section { + border-bottom-color: #334155; + } + + body[data-theme="dark"] .share-link-row input { + background: #0f172a; + border-color: #334155; + color: #e2e8f0; + } + + body[data-theme="dark"] .share-email-row input { + background: #0f172a; + border-color: #334155; + color: #e2e8f0; + } + + body[data-theme="dark"] .share-section label { + color: #94a3b8; + } + /* ── People panel dark mode ── */ body[data-theme="dark"] #people-online-badge { background: #1e293b; @@ -1018,8 +1243,16 @@ color: #475569; } - /* ── People panel mobile ── */ + /* ── Share & People panel mobile ── */ @media (max-width: 640px) { + #share-badge { + right: 220px; + bottom: 12px; + } + #share-panel { + width: calc(100vw - 32px); + right: 16px; + } #people-online-badge { right: 100px; bottom: 12px; @@ -1553,6 +1786,34 @@ 1 online +
+ 🔗 Share +
+ +
+
+

Share Space

+ +
+
+ + + +
+
+
@@ -2181,7 +2442,7 @@ } }); - // Click-outside closes panel + // Click-outside closes people panel document.addEventListener("click", (e) => { if (peoplePanel.classList.contains("open") && !peoplePanel.contains(e.target) && @@ -2190,6 +2451,79 @@ } }); + // ── Share panel ── + const sharePanel = document.getElementById("share-panel"); + const shareBadge = document.getElementById("share-badge"); + + function getShareUrl() { + const proto = window.location.protocol; + const host = window.location.host.split(":")[0]; + if (host.endsWith("rspace.online") && host.split(".").length >= 3) { + return `${proto}//${host}/rspace`; + } + return `${proto}//${window.location.host}/${communitySlug}/rspace`; + } + + shareBadge.addEventListener("click", () => { + const isOpen = sharePanel.classList.toggle("open"); + if (isOpen) { + // Close people panel if open + peoplePanel.classList.remove("open"); + const url = getShareUrl(); + document.getElementById("share-url").value = url; + document.getElementById("share-qr").src = + `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(url)}`; + } + }); + + document.getElementById("share-panel-close").addEventListener("click", () => { + sharePanel.classList.remove("open"); + }); + + document.getElementById("share-copy-btn").addEventListener("click", async () => { + const url = document.getElementById("share-url").value; + await navigator.clipboard.writeText(url); + const btn = document.getElementById("share-copy-btn"); + btn.textContent = "Copied!"; + setTimeout(() => btn.textContent = "Copy", 2000); + }); + + document.getElementById("share-send-btn").addEventListener("click", async () => { + const email = document.getElementById("share-email").value.trim(); + const status = document.getElementById("share-email-status"); + if (!email) return; + status.textContent = "Sending..."; + status.style.color = ""; + try { + const res = await fetch(`/api/spaces/${communitySlug}/invite`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, shareUrl: getShareUrl() }), + }); + if (res.ok) { + status.textContent = "Invite sent!"; + status.style.color = "#10b981"; + document.getElementById("share-email").value = ""; + } else { + status.textContent = "Failed to send"; + status.style.color = "#ef4444"; + } + } catch { + status.textContent = "Failed to send"; + status.style.color = "#ef4444"; + } + setTimeout(() => status.textContent = "", 4000); + }); + + // Click-outside closes share panel + document.addEventListener("click", (e) => { + if (sharePanel.classList.contains("open") && + !sharePanel.contains(e.target) && + !shareBadge.contains(e.target)) { + sharePanel.classList.remove("open"); + } + }); + function navigateToPeer(cursor) { const rect = canvas.getBoundingClientRect(); panX = rect.width / 2 - cursor.x * scale;