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));
|
||||
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(`Modules: ${getAllModules().map((m) => `${m.icon} ${m.name}`).join(", ")}`);
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export class SyncServer {
|
|||
#docs = new Map<string, Automerge.Doc<any>>();
|
||||
#docSubscribers = new Map<string, Set<string>>(); // docId → Set<peerId>
|
||||
#participantMode: boolean;
|
||||
#relayOnlyDocs = new Set<string>(); // docIds forced to relay mode (encrypted spaces)
|
||||
#onDocChange?: (docId: string, doc: Automerge.Doc<any>) => void;
|
||||
|
||||
constructor(opts: SyncServerOptions = {}) {
|
||||
|
|
@ -89,6 +90,30 @@ export class SyncServer {
|
|||
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.
|
||||
*/
|
||||
|
|
@ -263,7 +288,7 @@ export class SyncServer {
|
|||
const { docId, data } = msg;
|
||||
const syncMsg = new Uint8Array(data);
|
||||
|
||||
if (this.#participantMode) {
|
||||
if (this.#participantMode && !this.isRelayOnly(docId)) {
|
||||
// Server participates: apply sync message to server's doc
|
||||
let doc = this.#docs.get(docId);
|
||||
if (!doc) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,16 @@
|
|||
*/
|
||||
|
||||
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 {
|
||||
/** Page <title> */
|
||||
|
|
@ -30,6 +40,10 @@ export interface ShellOptions {
|
|||
head?: string;
|
||||
/** Space visibility level (for client-side access gate) */
|
||||
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 {
|
||||
|
|
@ -47,7 +61,18 @@ export function renderShell(opts: ShellOptions): string {
|
|||
spaceVisibility = "public_read",
|
||||
} = 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)}`;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
|
|
@ -78,7 +103,7 @@ export function renderShell(opts: ShellOptions): string {
|
|||
<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>
|
||||
<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 class="rstack-header__center">
|
||||
<rstack-mi></rstack-mi>
|
||||
|
|
|
|||
|
|
@ -1151,12 +1151,15 @@ spaces.patch("/:slug/encryption", async (c) => {
|
|||
const body = await c.req.json<{ encrypted: boolean; encryptionKeyId?: string }>();
|
||||
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({
|
||||
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.",
|
||||
? "Space encryption enabled. Documents synced in relay mode (server cannot read content)."
|
||||
: "Space encryption disabled. Documents synced in participant mode.",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export interface AppSwitcherModule {
|
|||
icon: string;
|
||||
description: string;
|
||||
standaloneDomain?: string;
|
||||
scoping?: { defaultScope: 'space' | 'global'; userConfigurable: boolean };
|
||||
}
|
||||
|
||||
// Pastel badge abbreviations & colors for each module
|
||||
|
|
@ -196,6 +197,10 @@ export class RStackAppSwitcher extends HTMLElement {
|
|||
? `${window.location.protocol}//rspace.online/${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 `
|
||||
<div class="item-row ${m.id === current ? "active" : ""}">
|
||||
<a class="item"
|
||||
|
|
@ -205,6 +210,7 @@ export class RStackAppSwitcher extends HTMLElement {
|
|||
<div class="item-text">
|
||||
<span class="item-name-row">
|
||||
<span class="item-name">${m.name}</span>
|
||||
${scopeBadge}
|
||||
<span class="item-emoji">${m.icon}</span>
|
||||
</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-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 {
|
||||
padding: 8px 14px 4px; font-size: 0.6rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.08em; opacity: 0.5;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
import { rspaceNavUrl, getCurrentModule } from "../url-helpers";
|
||||
import { resetDocBridge, isEncryptedBackupEnabled, setEncryptedBackupEnabled } from "../local-first/encryptid-bridge";
|
||||
|
||||
const SESSION_KEY = "encryptid_session";
|
||||
const ENCRYPTID_URL = "https://auth.rspace.online";
|
||||
|
|
@ -497,6 +498,7 @@ export class RStackIdentity extends HTMLElement {
|
|||
dropdown.classList.remove("open");
|
||||
if (action === "signout") {
|
||||
clearSession();
|
||||
resetDocBridge();
|
||||
this.#stopNotifPolling();
|
||||
this.#notifications = [];
|
||||
this.#render();
|
||||
|
|
@ -532,11 +534,11 @@ export class RStackIdentity extends HTMLElement {
|
|||
// Backup toggle
|
||||
const backupToggle = this.#shadow.getElementById("backup-toggle") as HTMLInputElement;
|
||||
if (backupToggle) {
|
||||
backupToggle.checked = localStorage.getItem("encryptid_backup_enabled") === "true";
|
||||
backupToggle.checked = isEncryptedBackupEnabled();
|
||||
backupToggle.addEventListener("change", (e) => {
|
||||
e.stopPropagation();
|
||||
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 } }));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -397,6 +397,7 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
|||
<h2>Edit Space</h2>
|
||||
<div class="tabs">
|
||||
<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="invitations">Invitations</button>
|
||||
</div>
|
||||
|
|
@ -423,6 +424,11 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
|||
</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 id="es-members-list" class="member-list"><div class="loading">Loading members...</div></div>
|
||||
</div>
|
||||
|
|
@ -449,6 +455,7 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
|||
panel?.classList.remove("hidden");
|
||||
|
||||
// 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 === "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 {
|
||||
return getModule();
|
||||
}
|
||||
|
|
@ -1000,6 +1141,41 @@ select.input { appearance: auto; }
|
|||
|
||||
.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 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';
|
||||
import { getKeyManager } from '../key-derivation';
|
||||
import { getSessionManager, AuthLevel } from '../session';
|
||||
import { getDocBridge, resetDocBridge } from '../../../shared/local-first/encryptid-bridge';
|
||||
|
||||
// ============================================================================
|
||||
// STYLES
|
||||
|
|
@ -408,6 +409,8 @@ export class EncryptIDLoginButton extends HTMLElement {
|
|||
const keyManager = getKeyManager();
|
||||
if (result.prfOutput) {
|
||||
await keyManager.initFromPRF(result.prfOutput);
|
||||
// Initialize doc encryption bridge for local-first encrypted storage
|
||||
await getDocBridge().initFromAuth(result.prfOutput);
|
||||
}
|
||||
|
||||
// Get derived keys
|
||||
|
|
@ -502,6 +505,9 @@ export class EncryptIDLoginButton extends HTMLElement {
|
|||
const keyManager = getKeyManager();
|
||||
keyManager.clear();
|
||||
|
||||
// Clear doc encryption bridge key material
|
||||
resetDocBridge();
|
||||
|
||||
this.dispatchEvent(new CustomEvent('logout', { bubbles: true }));
|
||||
this.render();
|
||||
}
|
||||
|
|
@ -515,6 +521,7 @@ export class EncryptIDLoginButton extends HTMLElement {
|
|||
const keyManager = getKeyManager();
|
||||
if (result.prfOutput) {
|
||||
await keyManager.initFromPRF(result.prfOutput);
|
||||
await getDocBridge().initFromAuth(result.prfOutput);
|
||||
}
|
||||
|
||||
const keys = await keyManager.getKeys();
|
||||
|
|
|
|||
Loading…
Reference in New Issue