Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-02 14:50:26 -08:00
commit dadd2f85c0
9 changed files with 490 additions and 7 deletions

View File

@ -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(", ")}`);

View File

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

View File

@ -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">&#x1F512;</span>' : ''}
</div> </div>
<div class="rstack-header__center"> <div class="rstack-header__center">
<rstack-mi></rstack-mi> <rstack-mi></rstack-mi>

View File

@ -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.",
}); });
}); });

View File

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

View File

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

View File

@ -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">&#x1F512; 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); } }
`; `;

View File

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

View File

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