Merge branch 'dev'
This commit is contained in:
commit
dadd2f85c0
|
|
@ -1624,5 +1624,26 @@ const server = Bun.serve<WSData>({
|
||||||
ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e));
|
ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e));
|
||||||
loadAllDocs(syncServer).catch((e) => console.error("[DocStore] Startup load failed:", e));
|
loadAllDocs(syncServer).catch((e) => console.error("[DocStore] Startup load failed:", e));
|
||||||
|
|
||||||
|
// Restore relay mode for encrypted spaces
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const slugs = await listCommunities();
|
||||||
|
let relayCount = 0;
|
||||||
|
for (const slug of slugs) {
|
||||||
|
await loadCommunity(slug);
|
||||||
|
const data = getDocumentData(slug);
|
||||||
|
if (data?.meta?.encrypted) {
|
||||||
|
syncServer.setRelayOnly(slug, true);
|
||||||
|
relayCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (relayCount > 0) {
|
||||||
|
console.log(`[Encryption] ${relayCount} space(s) set to relay mode (encrypted)`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Encryption] Failed to restore relay modes:", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
console.log(`rSpace unified server running on http://localhost:${PORT}`);
|
console.log(`rSpace unified server running on http://localhost:${PORT}`);
|
||||||
console.log(`Modules: ${getAllModules().map((m) => `${m.icon} ${m.name}`).join(", ")}`);
|
console.log(`Modules: ${getAllModules().map((m) => `${m.icon} ${m.name}`).join(", ")}`);
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ export class SyncServer {
|
||||||
#docs = new Map<string, Automerge.Doc<any>>();
|
#docs = new Map<string, Automerge.Doc<any>>();
|
||||||
#docSubscribers = new Map<string, Set<string>>(); // docId → Set<peerId>
|
#docSubscribers = new Map<string, Set<string>>(); // docId → Set<peerId>
|
||||||
#participantMode: boolean;
|
#participantMode: boolean;
|
||||||
|
#relayOnlyDocs = new Set<string>(); // docIds forced to relay mode (encrypted spaces)
|
||||||
#onDocChange?: (docId: string, doc: Automerge.Doc<any>) => void;
|
#onDocChange?: (docId: string, doc: Automerge.Doc<any>) => void;
|
||||||
|
|
||||||
constructor(opts: SyncServerOptions = {}) {
|
constructor(opts: SyncServerOptions = {}) {
|
||||||
|
|
@ -89,6 +90,30 @@ export class SyncServer {
|
||||||
this.#onDocChange = opts.onDocChange;
|
this.#onDocChange = opts.onDocChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a doc (or all docs matching a prefix) as relay-only.
|
||||||
|
* Relay-only docs are forwarded between peers without server reading them.
|
||||||
|
* Used for encrypted spaces where server can't decrypt content.
|
||||||
|
*/
|
||||||
|
setRelayOnly(docIdOrPrefix: string, relay: boolean): void {
|
||||||
|
if (relay) {
|
||||||
|
this.#relayOnlyDocs.add(docIdOrPrefix);
|
||||||
|
} else {
|
||||||
|
this.#relayOnlyDocs.delete(docIdOrPrefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a docId should use relay mode (either explicitly set or prefix-matched).
|
||||||
|
*/
|
||||||
|
isRelayOnly(docId: string): boolean {
|
||||||
|
if (this.#relayOnlyDocs.has(docId)) return true;
|
||||||
|
for (const prefix of this.#relayOnlyDocs) {
|
||||||
|
if (docId.startsWith(prefix + ':')) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a new WebSocket peer.
|
* Register a new WebSocket peer.
|
||||||
*/
|
*/
|
||||||
|
|
@ -263,7 +288,7 @@ export class SyncServer {
|
||||||
const { docId, data } = msg;
|
const { docId, data } = msg;
|
||||||
const syncMsg = new Uint8Array(data);
|
const syncMsg = new Uint8Array(data);
|
||||||
|
|
||||||
if (this.#participantMode) {
|
if (this.#participantMode && !this.isRelayOnly(docId)) {
|
||||||
// Server participates: apply sync message to server's doc
|
// Server participates: apply sync message to server's doc
|
||||||
let doc = this.#docs.get(docId);
|
let doc = this.#docs.get(docId);
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,16 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ModuleInfo } from "../shared/module";
|
import type { ModuleInfo } from "../shared/module";
|
||||||
|
import { getDocumentData } from "./community-store";
|
||||||
|
|
||||||
|
/** Extract enabledModules and encryption status from a loaded space. */
|
||||||
|
export function getSpaceShellMeta(spaceSlug: string): { enabledModules: string[] | null; spaceEncrypted: boolean } {
|
||||||
|
const data = getDocumentData(spaceSlug);
|
||||||
|
return {
|
||||||
|
enabledModules: data?.meta?.enabledModules ?? null,
|
||||||
|
spaceEncrypted: !!data?.meta?.encrypted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface ShellOptions {
|
export interface ShellOptions {
|
||||||
/** Page <title> */
|
/** Page <title> */
|
||||||
|
|
@ -30,6 +40,10 @@ export interface ShellOptions {
|
||||||
head?: string;
|
head?: string;
|
||||||
/** Space visibility level (for client-side access gate) */
|
/** Space visibility level (for client-side access gate) */
|
||||||
spaceVisibility?: string;
|
spaceVisibility?: string;
|
||||||
|
/** Enabled modules for this space (null = all). Filters the app switcher. */
|
||||||
|
enabledModules?: string[] | null;
|
||||||
|
/** Whether this space has client-side encryption enabled */
|
||||||
|
spaceEncrypted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderShell(opts: ShellOptions): string {
|
export function renderShell(opts: ShellOptions): string {
|
||||||
|
|
@ -47,7 +61,18 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
spaceVisibility = "public_read",
|
spaceVisibility = "public_read",
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const moduleListJSON = JSON.stringify(modules);
|
// Auto-populate from space data when not explicitly provided
|
||||||
|
const spaceMeta = (opts.enabledModules === undefined || opts.spaceEncrypted === undefined)
|
||||||
|
? getSpaceShellMeta(spaceSlug)
|
||||||
|
: null;
|
||||||
|
const enabledModules = opts.enabledModules ?? spaceMeta?.enabledModules ?? null;
|
||||||
|
const spaceEncrypted = opts.spaceEncrypted ?? spaceMeta?.spaceEncrypted ?? false;
|
||||||
|
|
||||||
|
// Filter modules by enabledModules (null = show all)
|
||||||
|
const visibleModules = enabledModules
|
||||||
|
? modules.filter(m => m.id === "rspace" || enabledModules.includes(m.id))
|
||||||
|
: modules;
|
||||||
|
const moduleListJSON = JSON.stringify(visibleModules);
|
||||||
const shellDemoUrl = `https://demo.rspace.online/${escapeAttr(moduleId)}`;
|
const shellDemoUrl = `https://demo.rspace.online/${escapeAttr(moduleId)}`;
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
|
|
@ -78,7 +103,7 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
<div class="rstack-header__left">
|
<div class="rstack-header__left">
|
||||||
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
|
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
|
||||||
<rstack-app-switcher current="${escapeAttr(moduleId)}"></rstack-app-switcher>
|
<rstack-app-switcher current="${escapeAttr(moduleId)}"></rstack-app-switcher>
|
||||||
<rstack-space-switcher current="${escapeAttr(spaceSlug)}" name="${escapeAttr(spaceName || spaceSlug)}"></rstack-space-switcher>
|
<rstack-space-switcher current="${escapeAttr(spaceSlug)}" name="${escapeAttr(spaceName || spaceSlug)}"></rstack-space-switcher>${spaceEncrypted ? '<span class="rstack-header__encrypted" title="End-to-end encrypted space">🔒</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="rstack-header__center">
|
<div class="rstack-header__center">
|
||||||
<rstack-mi></rstack-mi>
|
<rstack-mi></rstack-mi>
|
||||||
|
|
|
||||||
|
|
@ -1151,12 +1151,15 @@ spaces.patch("/:slug/encryption", async (c) => {
|
||||||
const body = await c.req.json<{ encrypted: boolean; encryptionKeyId?: string }>();
|
const body = await c.req.json<{ encrypted: boolean; encryptionKeyId?: string }>();
|
||||||
setEncryption(slug, body.encrypted, body.encryptionKeyId);
|
setEncryption(slug, body.encrypted, body.encryptionKeyId);
|
||||||
|
|
||||||
|
// Switch SyncServer to relay mode for encrypted spaces (server can't read ciphertext)
|
||||||
|
syncServer.setRelayOnly(slug, body.encrypted);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
encrypted: body.encrypted,
|
encrypted: body.encrypted,
|
||||||
message: body.encrypted
|
message: body.encrypted
|
||||||
? "Space encryption enabled. Document will be encrypted at rest."
|
? "Space encryption enabled. Documents synced in relay mode (server cannot read content)."
|
||||||
: "Space encryption disabled. Document will be stored in plaintext.",
|
: "Space encryption disabled. Documents synced in participant mode.",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export interface AppSwitcherModule {
|
||||||
icon: string;
|
icon: string;
|
||||||
description: string;
|
description: string;
|
||||||
standaloneDomain?: string;
|
standaloneDomain?: string;
|
||||||
|
scoping?: { defaultScope: 'space' | 'global'; userConfigurable: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pastel badge abbreviations & colors for each module
|
// Pastel badge abbreviations & colors for each module
|
||||||
|
|
@ -196,6 +197,10 @@ export class RStackAppSwitcher extends HTMLElement {
|
||||||
? `${window.location.protocol}//rspace.online/${m.id}`
|
? `${window.location.protocol}//rspace.online/${m.id}`
|
||||||
: rspaceNavUrl(space, m.id);
|
: rspaceNavUrl(space, m.id);
|
||||||
|
|
||||||
|
const scopeBadge = m.scoping?.defaultScope === "global"
|
||||||
|
? `<span class="scope-badge scope-global" title="Global data (shared across spaces)">G</span>`
|
||||||
|
: "";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="item-row ${m.id === current ? "active" : ""}">
|
<div class="item-row ${m.id === current ? "active" : ""}">
|
||||||
<a class="item"
|
<a class="item"
|
||||||
|
|
@ -205,6 +210,7 @@ export class RStackAppSwitcher extends HTMLElement {
|
||||||
<div class="item-text">
|
<div class="item-text">
|
||||||
<span class="item-name-row">
|
<span class="item-name-row">
|
||||||
<span class="item-name">${m.name}</span>
|
<span class="item-name">${m.name}</span>
|
||||||
|
${scopeBadge}
|
||||||
<span class="item-emoji">${m.icon}</span>
|
<span class="item-emoji">${m.icon}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="item-desc">${m.description}</span>
|
<span class="item-desc">${m.description}</span>
|
||||||
|
|
@ -412,6 +418,12 @@ a.rstack-header:hover { background: rgba(255,255,255,0.05); }
|
||||||
.item-emoji { font-size: 0.875rem; flex-shrink: 0; }
|
.item-emoji { font-size: 0.875rem; flex-shrink: 0; }
|
||||||
.item-desc { font-size: 0.7rem; opacity: 0.5; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.item-desc { font-size: 0.7rem; opacity: 0.5; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.scope-badge {
|
||||||
|
font-size: 0.55rem; font-weight: 700; padding: 1px 4px; border-radius: 3px;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em; line-height: 1; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.scope-global { background: rgba(139,92,246,0.2); color: #a78bfa; }
|
||||||
|
|
||||||
.category-header {
|
.category-header {
|
||||||
padding: 8px 14px 4px; font-size: 0.6rem; font-weight: 700;
|
padding: 8px 14px 4px; font-size: 0.6rem; font-weight: 700;
|
||||||
text-transform: uppercase; letter-spacing: 0.08em; opacity: 0.5;
|
text-transform: uppercase; letter-spacing: 0.08em; opacity: 0.5;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { rspaceNavUrl, getCurrentModule } from "../url-helpers";
|
import { rspaceNavUrl, getCurrentModule } from "../url-helpers";
|
||||||
|
import { resetDocBridge, isEncryptedBackupEnabled, setEncryptedBackupEnabled } from "../local-first/encryptid-bridge";
|
||||||
|
|
||||||
const SESSION_KEY = "encryptid_session";
|
const SESSION_KEY = "encryptid_session";
|
||||||
const ENCRYPTID_URL = "https://auth.rspace.online";
|
const ENCRYPTID_URL = "https://auth.rspace.online";
|
||||||
|
|
@ -497,6 +498,7 @@ export class RStackIdentity extends HTMLElement {
|
||||||
dropdown.classList.remove("open");
|
dropdown.classList.remove("open");
|
||||||
if (action === "signout") {
|
if (action === "signout") {
|
||||||
clearSession();
|
clearSession();
|
||||||
|
resetDocBridge();
|
||||||
this.#stopNotifPolling();
|
this.#stopNotifPolling();
|
||||||
this.#notifications = [];
|
this.#notifications = [];
|
||||||
this.#render();
|
this.#render();
|
||||||
|
|
@ -532,11 +534,11 @@ export class RStackIdentity extends HTMLElement {
|
||||||
// Backup toggle
|
// Backup toggle
|
||||||
const backupToggle = this.#shadow.getElementById("backup-toggle") as HTMLInputElement;
|
const backupToggle = this.#shadow.getElementById("backup-toggle") as HTMLInputElement;
|
||||||
if (backupToggle) {
|
if (backupToggle) {
|
||||||
backupToggle.checked = localStorage.getItem("encryptid_backup_enabled") === "true";
|
backupToggle.checked = isEncryptedBackupEnabled();
|
||||||
backupToggle.addEventListener("change", (e) => {
|
backupToggle.addEventListener("change", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const enabled = backupToggle.checked;
|
const enabled = backupToggle.checked;
|
||||||
localStorage.setItem("encryptid_backup_enabled", enabled ? "true" : "false");
|
setEncryptedBackupEnabled(enabled);
|
||||||
this.dispatchEvent(new CustomEvent("backup-toggle", { bubbles: true, composed: true, detail: { enabled } }));
|
this.dispatchEvent(new CustomEvent("backup-toggle", { bubbles: true, composed: true, detail: { enabled } }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -397,6 +397,7 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
||||||
<h2>Edit Space</h2>
|
<h2>Edit Space</h2>
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab active" data-tab="settings">Settings</button>
|
<button class="tab active" data-tab="settings">Settings</button>
|
||||||
|
<button class="tab" data-tab="modules">Modules</button>
|
||||||
<button class="tab" data-tab="members">Members</button>
|
<button class="tab" data-tab="members">Members</button>
|
||||||
<button class="tab" data-tab="invitations">Invitations</button>
|
<button class="tab" data-tab="invitations">Invitations</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -423,6 +424,11 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel hidden" id="panel-modules">
|
||||||
|
<div id="es-modules-list" class="modules-list"><div class="loading">Loading modules...</div></div>
|
||||||
|
<div class="status" id="es-modules-status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tab-panel hidden" id="panel-members">
|
<div class="tab-panel hidden" id="panel-members">
|
||||||
<div id="es-members-list" class="member-list"><div class="loading">Loading members...</div></div>
|
<div id="es-members-list" class="member-list"><div class="loading">Loading members...</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -449,6 +455,7 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
||||||
panel?.classList.remove("hidden");
|
panel?.classList.remove("hidden");
|
||||||
|
|
||||||
// Lazy-load content
|
// Lazy-load content
|
||||||
|
if ((tab as HTMLElement).dataset.tab === "modules") this.#loadModulesConfig(overlay, slug);
|
||||||
if ((tab as HTMLElement).dataset.tab === "members") this.#loadMembers(overlay, slug);
|
if ((tab as HTMLElement).dataset.tab === "members") this.#loadMembers(overlay, slug);
|
||||||
if ((tab as HTMLElement).dataset.tab === "invitations") this.#loadInvitations(overlay, slug);
|
if ((tab as HTMLElement).dataset.tab === "invitations") this.#loadInvitations(overlay, slug);
|
||||||
});
|
});
|
||||||
|
|
@ -680,6 +687,140 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #loadModulesConfig(overlay: HTMLElement, slug: string) {
|
||||||
|
const container = overlay.querySelector("#es-modules-list") as HTMLElement;
|
||||||
|
const statusEl = overlay.querySelector("#es-modules-status") as HTMLElement;
|
||||||
|
try {
|
||||||
|
const token = getAccessToken();
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
// Fetch module config and encryption status in parallel
|
||||||
|
const [modRes, encRes] = await Promise.all([
|
||||||
|
fetch(`/api/spaces/${slug}/modules`, { headers }),
|
||||||
|
fetch(`/api/spaces/${slug}/encryption`, { headers }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!modRes.ok) { container.innerHTML = `<div class="loading">Failed to load modules</div>`; return; }
|
||||||
|
const modData = await modRes.json();
|
||||||
|
const encData = encRes.ok ? await encRes.json() : { encrypted: false };
|
||||||
|
|
||||||
|
interface ModConfig {
|
||||||
|
id: string; name: string; icon: string; enabled: boolean;
|
||||||
|
scoping: { defaultScope: string; userConfigurable: boolean; currentScope: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const modules: ModConfig[] = modData.modules || [];
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="module-encryption">
|
||||||
|
<label class="module-row">
|
||||||
|
<span class="module-label">🔒 End-to-end encryption</span>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="es-encrypt-toggle" ${encData.encrypted ? "checked" : ""} />
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</label>
|
||||||
|
<div class="module-hint">When enabled, documents are encrypted client-side. Server cannot read content.</div>
|
||||||
|
</div>
|
||||||
|
<div class="module-divider"></div>
|
||||||
|
<div class="module-section-label">Enabled Modules</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
for (const m of modules) {
|
||||||
|
if (m.id === "rspace") continue; // core module, always enabled
|
||||||
|
html += `
|
||||||
|
<label class="module-row">
|
||||||
|
<span class="module-label">${m.icon} ${m.name}</span>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" class="mod-toggle" data-mod-id="${m.id}" ${m.enabled ? "checked" : ""} />
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</label>`;
|
||||||
|
|
||||||
|
if (m.scoping.userConfigurable) {
|
||||||
|
html += `
|
||||||
|
<div class="scope-row">
|
||||||
|
<span class="scope-label">Data scope:</span>
|
||||||
|
<select class="scope-select" data-mod-id="${m.id}">
|
||||||
|
<option value="space" ${m.scoping.currentScope === "space" ? "selected" : ""}>Space</option>
|
||||||
|
<option value="global" ${m.scoping.currentScope === "global" ? "selected" : ""}>Global</option>
|
||||||
|
</select>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="actions" style="margin-top:1rem">
|
||||||
|
<button class="btn btn--primary" id="es-modules-save">Save Module Config</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
// Save handler
|
||||||
|
container.querySelector("#es-modules-save")?.addEventListener("click", async () => {
|
||||||
|
statusEl.textContent = "Saving...";
|
||||||
|
statusEl.className = "status";
|
||||||
|
|
||||||
|
// Collect enabled modules
|
||||||
|
const enabledModules: string[] = ["rspace"];
|
||||||
|
container.querySelectorAll(".mod-toggle").forEach((cb) => {
|
||||||
|
const el = cb as HTMLInputElement;
|
||||||
|
if (el.checked) enabledModules.push(el.dataset.modId!);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect scope overrides
|
||||||
|
const scopeOverrides: Record<string, string> = {};
|
||||||
|
container.querySelectorAll(".scope-select").forEach((sel) => {
|
||||||
|
const el = sel as HTMLSelectElement;
|
||||||
|
scopeOverrides[el.dataset.modId!] = el.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check encryption toggle
|
||||||
|
const encryptToggle = container.querySelector("#es-encrypt-toggle") as HTMLInputElement;
|
||||||
|
const newEncrypted = encryptToggle?.checked ?? false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = getAccessToken();
|
||||||
|
const authHeaders = { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) };
|
||||||
|
|
||||||
|
// Save modules config
|
||||||
|
const modSaveRes = await fetch(`/api/spaces/${slug}/modules`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: authHeaders,
|
||||||
|
body: JSON.stringify({ enabledModules, scopeOverrides }),
|
||||||
|
});
|
||||||
|
if (!modSaveRes.ok) {
|
||||||
|
const d = await modSaveRes.json();
|
||||||
|
throw new Error(d.error || "Failed to save modules");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save encryption if changed
|
||||||
|
if (newEncrypted !== encData.encrypted) {
|
||||||
|
const encSaveRes = await fetch(`/api/spaces/${slug}/encryption`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: authHeaders,
|
||||||
|
body: JSON.stringify({ encrypted: newEncrypted }),
|
||||||
|
});
|
||||||
|
if (!encSaveRes.ok) {
|
||||||
|
const d = await encSaveRes.json();
|
||||||
|
throw new Error(d.error || "Failed to update encryption");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.textContent = "Saved! Reload to apply changes.";
|
||||||
|
statusEl.className = "status success";
|
||||||
|
} catch (err: any) {
|
||||||
|
statusEl.textContent = err.message || "Failed to save";
|
||||||
|
statusEl.className = "status error";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
container.innerHTML = `<div class="loading">Failed to load module config</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#getCurrentModule(): string {
|
#getCurrentModule(): string {
|
||||||
return getModule();
|
return getModule();
|
||||||
}
|
}
|
||||||
|
|
@ -1000,6 +1141,41 @@ select.input { appearance: auto; }
|
||||||
|
|
||||||
.loading { padding: 16px; text-align: center; font-size: 0.85rem; color: #94a3b8; }
|
.loading { padding: 16px; text-align: center; font-size: 0.85rem; color: #94a3b8; }
|
||||||
|
|
||||||
|
/* Modules config panel */
|
||||||
|
.modules-list { max-height: 400px; overflow-y: auto; }
|
||||||
|
.module-encryption { margin-bottom: 0.25rem; }
|
||||||
|
.module-divider { height: 1px; background: rgba(255,255,255,0.08); margin: 0.75rem 0; }
|
||||||
|
.module-section-label { font-size: 0.72rem; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.5rem; }
|
||||||
|
.module-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 6px 0; cursor: pointer;
|
||||||
|
}
|
||||||
|
.module-label { font-size: 0.875rem; }
|
||||||
|
.module-hint { font-size: 0.75rem; color: #64748b; margin-top: 2px; padding-left: 2px; }
|
||||||
|
.scope-row {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 2px 0 6px 24px; font-size: 0.8rem; color: #94a3b8;
|
||||||
|
}
|
||||||
|
.scope-label { font-size: 0.75rem; }
|
||||||
|
.scope-select {
|
||||||
|
padding: 3px 8px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
background: rgba(255,255,255,0.05); color: white; font-size: 0.75rem; outline: none;
|
||||||
|
}
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative; display: inline-block; width: 36px; height: 20px;
|
||||||
|
}
|
||||||
|
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute; cursor: pointer; inset: 0;
|
||||||
|
background: rgba(255,255,255,0.15); border-radius: 20px; transition: 0.2s;
|
||||||
|
}
|
||||||
|
.toggle-slider:before {
|
||||||
|
content: ""; position: absolute; height: 14px; width: 14px;
|
||||||
|
left: 3px; bottom: 3px; background: white; border-radius: 50%; transition: 0.2s;
|
||||||
|
}
|
||||||
|
input:checked + .toggle-slider { background: #06b6d4; }
|
||||||
|
input:checked + .toggle-slider:before { transform: translateX(16px); }
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
/**
|
||||||
|
* EncryptID → DocCrypto Bridge
|
||||||
|
*
|
||||||
|
* Connects the EncryptID WebAuthn PRF output to the local-first
|
||||||
|
* DocCrypto layer for client-side document encryption.
|
||||||
|
*
|
||||||
|
* Data flow:
|
||||||
|
* WebAuthn PRF output
|
||||||
|
* → DocCrypto.initFromPRF()
|
||||||
|
* → deriveSpaceKey(spaceId)
|
||||||
|
* → deriveDocKey(spaceKey, docId)
|
||||||
|
* → AES-256-GCM encrypt/decrypt
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { EncryptedDocBridge } from './encryptid-bridge';
|
||||||
|
*
|
||||||
|
* const bridge = new EncryptedDocBridge();
|
||||||
|
*
|
||||||
|
* // After EncryptID authentication (PRF output available):
|
||||||
|
* await bridge.initFromAuth(authResult.prfOutput);
|
||||||
|
*
|
||||||
|
* // Pass to EncryptedDocStore for a space:
|
||||||
|
* const store = new EncryptedDocStore(spaceSlug, bridge.getDocCrypto());
|
||||||
|
*
|
||||||
|
* // On sign-out:
|
||||||
|
* bridge.clear();
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DocCrypto } from './crypto';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface BridgeState {
|
||||||
|
initialized: boolean;
|
||||||
|
/** Cached space keys for quick doc key derivation */
|
||||||
|
spaceKeys: Map<string, CryptoKey>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EncryptedDocBridge
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class EncryptedDocBridge {
|
||||||
|
#crypto: DocCrypto;
|
||||||
|
#spaceKeys = new Map<string, CryptoKey>();
|
||||||
|
#initialized = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.#crypto = new DocCrypto();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize from WebAuthn PRF output.
|
||||||
|
* Call this after EncryptID authentication returns prfOutput.
|
||||||
|
*/
|
||||||
|
async initFromAuth(prfOutput: ArrayBuffer): Promise<void> {
|
||||||
|
await this.#crypto.initFromPRF(prfOutput);
|
||||||
|
this.#spaceKeys.clear();
|
||||||
|
this.#initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize from raw key material (e.g., from EncryptIDKeyManager's
|
||||||
|
* encryption key or any 256-bit key).
|
||||||
|
*/
|
||||||
|
async initFromKey(key: CryptoKey | Uint8Array | ArrayBuffer): Promise<void> {
|
||||||
|
await this.#crypto.init(key);
|
||||||
|
this.#spaceKeys.clear();
|
||||||
|
this.#initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the DocCrypto instance for use with EncryptedDocStore.
|
||||||
|
* Returns null if not initialized.
|
||||||
|
*/
|
||||||
|
getDocCrypto(): DocCrypto | undefined {
|
||||||
|
return this.#initialized ? this.#crypto : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-derive and cache a space key for faster doc key derivation.
|
||||||
|
*/
|
||||||
|
async warmSpaceKey(spaceId: string): Promise<void> {
|
||||||
|
if (!this.#initialized) return;
|
||||||
|
if (!this.#spaceKeys.has(spaceId)) {
|
||||||
|
const key = await this.#crypto.deriveSpaceKey(spaceId);
|
||||||
|
this.#spaceKeys.set(spaceId, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a cached space key (or derive it).
|
||||||
|
*/
|
||||||
|
async getSpaceKey(spaceId: string): Promise<CryptoKey> {
|
||||||
|
if (!this.#initialized) throw new Error('Bridge not initialized');
|
||||||
|
let key = this.#spaceKeys.get(spaceId);
|
||||||
|
if (!key) {
|
||||||
|
key = await this.#crypto.deriveSpaceKey(spaceId);
|
||||||
|
this.#spaceKeys.set(spaceId, key);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a document key directly (convenience for one-off operations).
|
||||||
|
*/
|
||||||
|
async deriveDocKey(spaceId: string, docId: string): Promise<CryptoKey> {
|
||||||
|
const spaceKey = await this.getSpaceKey(spaceId);
|
||||||
|
return this.#crypto.deriveDocKey(spaceKey, docId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the bridge is ready for encryption operations.
|
||||||
|
*/
|
||||||
|
get isInitialized(): boolean {
|
||||||
|
return this.#initialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all key material from memory. Call on sign-out.
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.#crypto.clear();
|
||||||
|
this.#spaceKeys.clear();
|
||||||
|
this.#initialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Singleton for app-wide access
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let _bridge: EncryptedDocBridge | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the global EncryptedDocBridge singleton.
|
||||||
|
*/
|
||||||
|
export function getDocBridge(): EncryptedDocBridge {
|
||||||
|
if (!_bridge) {
|
||||||
|
_bridge = new EncryptedDocBridge();
|
||||||
|
}
|
||||||
|
return _bridge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the global bridge (e.g., on sign-out).
|
||||||
|
*/
|
||||||
|
export function resetDocBridge(): void {
|
||||||
|
if (_bridge) {
|
||||||
|
_bridge.clear();
|
||||||
|
_bridge = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper: check if a space supports client-side encryption
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check localStorage for whether the user has encryption enabled for a space.
|
||||||
|
* This is set by the space encryption toggle in the UI.
|
||||||
|
*/
|
||||||
|
export function isSpaceEncryptionEnabled(spaceSlug: string): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(`rspace:${spaceSlug}:encrypted`) === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the client-side encryption flag for a space.
|
||||||
|
*/
|
||||||
|
export function setSpaceEncryptionEnabled(spaceSlug: string, enabled: boolean): void {
|
||||||
|
try {
|
||||||
|
if (enabled) {
|
||||||
|
localStorage.setItem(`rspace:${spaceSlug}:encrypted`, 'true');
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(`rspace:${spaceSlug}:encrypted`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable (SSR, etc.)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if encrypted backup is enabled globally.
|
||||||
|
*/
|
||||||
|
export function isEncryptedBackupEnabled(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('encryptid_backup_enabled') === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle encrypted backup flag.
|
||||||
|
*/
|
||||||
|
export function setEncryptedBackupEnabled(enabled: boolean): void {
|
||||||
|
try {
|
||||||
|
if (enabled) {
|
||||||
|
localStorage.setItem('encryptid_backup_enabled', 'true');
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('encryptid_backup_enabled');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
} from '../webauthn';
|
} from '../webauthn';
|
||||||
import { getKeyManager } from '../key-derivation';
|
import { getKeyManager } from '../key-derivation';
|
||||||
import { getSessionManager, AuthLevel } from '../session';
|
import { getSessionManager, AuthLevel } from '../session';
|
||||||
|
import { getDocBridge, resetDocBridge } from '../../../shared/local-first/encryptid-bridge';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// STYLES
|
// STYLES
|
||||||
|
|
@ -408,6 +409,8 @@ export class EncryptIDLoginButton extends HTMLElement {
|
||||||
const keyManager = getKeyManager();
|
const keyManager = getKeyManager();
|
||||||
if (result.prfOutput) {
|
if (result.prfOutput) {
|
||||||
await keyManager.initFromPRF(result.prfOutput);
|
await keyManager.initFromPRF(result.prfOutput);
|
||||||
|
// Initialize doc encryption bridge for local-first encrypted storage
|
||||||
|
await getDocBridge().initFromAuth(result.prfOutput);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get derived keys
|
// Get derived keys
|
||||||
|
|
@ -502,6 +505,9 @@ export class EncryptIDLoginButton extends HTMLElement {
|
||||||
const keyManager = getKeyManager();
|
const keyManager = getKeyManager();
|
||||||
keyManager.clear();
|
keyManager.clear();
|
||||||
|
|
||||||
|
// Clear doc encryption bridge key material
|
||||||
|
resetDocBridge();
|
||||||
|
|
||||||
this.dispatchEvent(new CustomEvent('logout', { bubbles: true }));
|
this.dispatchEvent(new CustomEvent('logout', { bubbles: true }));
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
@ -515,6 +521,7 @@ export class EncryptIDLoginButton extends HTMLElement {
|
||||||
const keyManager = getKeyManager();
|
const keyManager = getKeyManager();
|
||||||
if (result.prfOutput) {
|
if (result.prfOutput) {
|
||||||
await keyManager.initFromPRF(result.prfOutput);
|
await keyManager.initFromPRF(result.prfOutput);
|
||||||
|
await getDocBridge().initFromAuth(result.prfOutput);
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = await keyManager.getKeys();
|
const keys = await keyManager.getKeys();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue