Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ Performance Review Manager — a local, single-user ("solo EM") pnpm monorepo. S
- This repo requires **pnpm 10.33.3**. The VM's default corepack resolves pnpm 11, which (a) rejects this committed lockfile via a `minimumReleaseAge` supply-chain policy and (b) ignores the `pnpm.onlyBuiltDependencies` field in `package.json` that is needed to build the `better-sqlite3` native module. The update script pins 10.33.3 via `corepack prepare pnpm@10.33.3 --activate`; keep using pnpm 10.x. If native `better-sqlite3` is missing, run `pnpm rebuild better-sqlite3`.

### Data / running
- Local data lives in `/workspace/data` (gitignored): SQLite DB, encrypted vault, uploaded files. Delete it to reset to a clean state (stop the API first, since it holds the DB handle).
- Local data lives in `/workspace/data` (gitignored): SQLite DB, encrypted vault, uploaded files. Delete it to reset to a clean state (stop the API first, since it holds the DB handle), or use **Delete workspace** in Settings / **Erase workspace** on Unlock (password + type `DELETE`).
- First app load offers **Empty workspace** or **Demo team** (seeds an 8-person org). After init it shows "Unlock". Default demo password: `workbench`.
- Locking the workspace ("Lock & encrypt") seals the SQLite DB + files to `.enc` at rest; unlocking decrypts them.
- After erase, the create flow returns so you can start a new empty or demo workspace.
- `.session` stores the auth token only (not the AES key). After an API process restart you must unlock again even if the UI still has a token.

### Known pre-existing issues (not environment problems)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ pnpm dev
| API | http://127.0.0.1:8787 |
| Seed password | `workbench` |

First load: choose **Empty workspace** or **Demo team** (8-person org, password default `workbench`), then unlock later sessions with your password. Between sessions use **Lock & encrypt**.
First load: choose **Empty workspace** or **Demo team** (8-person org, password default `workbench`), then unlock later sessions with your password. Between sessions use **Lock & encrypt**. To wipe and start over, use **Delete workspace** in Settings (or **Erase workspace** on the unlock screen) — password + type `DELETE`.

Requires **pnpm 10.33.3** (see [`AGENTS.md`](./AGENTS.md) if Corepack hands you pnpm 11).

Expand Down
23 changes: 22 additions & 1 deletion apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
nowIso,
requireSession,
bootstrapVault,
destroyWorkspace,
unlockVault,
writeEncryptedDocument,
writeFrameworkFile,
Expand Down Expand Up @@ -196,7 +197,8 @@ app.use("/api/*", async (c, next) => {
c.req.path === "/api/health" ||
c.req.path === "/api/workspace/status" ||
c.req.path === "/api/workspace/init" ||
c.req.path === "/api/workspace/unlock"
c.req.path === "/api/workspace/unlock" ||
c.req.path === "/api/workspace/destroy"
) {
return next();
}
Expand Down Expand Up @@ -369,6 +371,25 @@ app.post("/api/workspace/lock", (c) => {
return c.json({ ok: true, encryptedAtRest: true });
});

/** Wipe local vault + data so a new empty/demo workspace can be created. Public (password-gated). */
app.post("/api/workspace/destroy", async (c) => {
if (!isInitialized()) return c.json({ error: "No workspace to delete" }, 400);
const body = await c.req.json<{ password?: string; confirm?: string }>();
if (body.confirm !== "DELETE") {
return c.json({ error: 'Type confirm: "DELETE" to erase the workspace' }, 400);
}
if (!body.password) return c.json({ error: "password required" }, 400);
try {
destroyWorkspace(body.password);
return c.json({ ok: true, initialized: false });
} catch (e) {
const err = e as Error & { code?: string };
const status =
err.message === "Invalid password" ? 401 : err.code === "legal_hold" ? 403 : 400;
return c.json({ error: err.message || "Destroy failed", code: err.code }, status);
}
});

app.get("/api/workspace/backups", (c) => {
return c.json({ backups: listEncryptedBackups() });
});
Expand Down
85 changes: 85 additions & 0 deletions apps/api/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,91 @@ export function lockVault() {
clearSession();
}

/**
* Permanently delete the local workspace (DB, vault, files, backups).
* Requires the workspace password. Blocks when legal hold is enabled.
* After success, `/api/workspace/init` can create a fresh empty or demo workspace.
*/
export function destroyWorkspace(password: string): void {
if (!isInitialized()) {
throw new Error("No workspace to delete");
}

const wasUnlocked = Boolean(sessionToken && workspaceSecret && existsSync(DB_PATH) && dbHandle);
let unlockedForDestroy = false;

try {
if (!wasUnlocked) {
unlockVault(password);
unlockedForDestroy = true;
} else {
const vault = readVault();
if (vault) {
if (!verifyPassword(password, vault.passwordHash, vault.salt)) {
throw new Error("Invalid password");
}
} else {
const row = getDb().select().from(workspace).limit(1).all()[0];
if (!row || !verifyPassword(password, row.passwordHash, row.passwordSalt)) {
throw new Error("Invalid password");
}
}
}

const row = getDb().select().from(workspace).limit(1).all()[0];
if (row) {
let parsed: { legalHold?: boolean } = {};
try {
parsed = JSON.parse(row.settingsJson || "{}") as { legalHold?: boolean };
} catch {
parsed = {};
}
if (parsed.legalHold) {
const err = new Error(
"Legal hold is on — disable it in Settings → Retention before deleting the workspace.",
);
(err as Error & { code?: string }).code = "legal_hold";
throw err;
}
}
} catch (e) {
if (unlockedForDestroy) {
try {
lockVault();
} catch {
/* ignore re-seal failure */
}
}
throw e;
}

try {
if (sqliteHandle) {
sqliteHandle.pragma("wal_checkpoint(TRUNCATE)");
}
} catch {
/* ignore */
}
closeDbHandles();
removeSidecars();
clearSession();

for (const f of [DB_PATH, `${DB_PATH}-wal`, `${DB_PATH}-shm`, DB_ENC_PATH, VAULT_PATH, SESSION_PATH]) {
try {
if (existsSync(f)) unlinkSync(f);
} catch {
/* ignore */
}
}

rmSync(paths.files, { recursive: true, force: true });
rmSync(paths.backups, { recursive: true, force: true });
mkdirSync(paths.documents, { recursive: true });
mkdirSync(paths.frameworks, { recursive: true });
mkdirSync(paths.exports, { recursive: true });
mkdirSync(paths.backups, { recursive: true });
}

export function encryptionStatus() {
const vault = readVault();
return {
Expand Down
16 changes: 14 additions & 2 deletions apps/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,22 @@ export function App() {

const unlocked = Boolean(getToken()) && status.unlocked;

async function handleWorkspaceDestroyed() {
setToken(null);
await refresh();
}

if (!status.initialized || !unlocked) {
return (
<>
<UnlockPage status={status} onUnlocked={async (token) => { setToken(token); await refresh(); }} />
<UnlockPage
status={status}
onUnlocked={async (token) => {
setToken(token);
await refresh();
}}
onDestroyed={handleWorkspaceDestroyed}
/>
<div className="theme-float">{themeToggle}</div>
</>
);
Expand Down Expand Up @@ -255,7 +267,7 @@ export function App() {
<Route path="/backfill" element={<BackfillPage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/templates" element={<TemplatesPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings" element={<SettingsPage onWorkspaceDestroyed={handleWorkspaceDestroyed} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
Expand Down
81 changes: 81 additions & 0 deletions apps/ui/src/components/PageSectionNav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useEffect, useState } from "react";

export type SectionNavItem = {
id: string;
label: string;
};

export function PageSectionNav({
items,
ariaLabel = "On this page",
}: {
items: SectionNavItem[];
ariaLabel?: string;
}) {
const [activeId, setActiveId] = useState(items[0]?.id ?? "");
const sectionKey = items.map((item) => item.id).join("|");

useEffect(() => {
const nodes = items
.map((item) => document.getElementById(item.id))
.filter((el): el is HTMLElement => Boolean(el));
if (nodes.length === 0) return;

const ratios = new Map<string, number>();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
ratios.set(entry.target.id, entry.isIntersecting ? entry.intersectionRatio : 0);
}
const visible = items
.map((item) => document.getElementById(item.id))
.filter((el): el is HTMLElement => Boolean(el))
.filter((el) => (ratios.get(el.id) ?? 0) > 0.02)
.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
if (visible[0]) {
setActiveId(visible[0].id);
return;
}
const above = nodes
.filter((n) => n.getBoundingClientRect().top <= 140)
.sort((a, b) => b.getBoundingClientRect().top - a.getBoundingClientRect().top)[0];
if (above) setActiveId(above.id);
},
{
root: null,
rootMargin: "-12% 0px -60% 0px",
threshold: [0, 0.1, 0.25, 0.5, 1],
},
);

for (const node of nodes) observer.observe(node);
return () => observer.disconnect();
}, [sectionKey, items]);

function goTo(id: string) {
const el = document.getElementById(id);
if (!el) return;
setActiveId(id);
el.scrollIntoView({ behavior: "smooth", block: "start" });
}

return (
<nav className="page-section-nav" aria-label={ariaLabel}>
<div className="page-section-nav-label">{ariaLabel}</div>
<ul className="page-section-nav-list">
{items.map((item) => (
<li key={item.id}>
<button
type="button"
className={`page-section-nav-link${activeId === item.id ? " on" : ""}`}
aria-current={activeId === item.id ? "true" : undefined}
onClick={() => goTo(item.id)}
>
{item.label}
</button>
</li>
))}
</ul>
</nav>
);
}
Loading
Loading