From 5f3d645a54e9830098f43e8b7cd129cdb5b5f2d9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 11:49:08 +0000 Subject: [PATCH 1/2] Harden vault auth for public release Remove silent workbench/password and demo-seed defaults on init, enforce an 8-character minimum, and rate-limit unlock/destroy. Tighten CORS away from arbitrary *.localhost, use constant-time token compares, confine document reads under the vault files tree, and write the desktop launch token as mode 0600. Share authHeaders across UI downloads so packaged desktop always sends x-prm-launch-token. Warn when the data dir looks like a cloud sync folder; clarify MCP/demo password docs. Co-authored-by: Damon --- .cursor/mcp.json.example | 2 +- README.md | 4 +- apps/api/src/authPolicy.test.ts | 56 +++++++++++++++++++ apps/api/src/authPolicy.ts | 68 +++++++++++++++++++++++ apps/api/src/index.ts | 46 ++++++++++++--- apps/api/src/store.ts | 55 ++++++++++++++++-- apps/desktop/src-tauri/src/main.rs | 17 +++++- apps/ui/src/lib/api.ts | 16 ++++-- apps/ui/src/lib/exports.ts | 15 +---- apps/ui/src/pages/PersonPage.tsx | 11 ++-- apps/ui/src/pages/PromotionDetailPage.tsx | 5 +- apps/ui/src/pages/RolesPage.tsx | 9 +-- apps/ui/src/pages/TeamPage.tsx | 9 +-- apps/ui/src/pages/UnlockPage.tsx | 14 +++-- apps/ui/src/pages/WritingDeskPage.tsx | 5 +- packages/mcp/README.md | 2 +- packages/mcp/src/tools.ts | 9 ++- packages/shared/src/index.ts | 3 + 18 files changed, 277 insertions(+), 69 deletions(-) create mode 100644 apps/api/src/authPolicy.test.ts create mode 100644 apps/api/src/authPolicy.ts diff --git a/.cursor/mcp.json.example b/.cursor/mcp.json.example index 26e5389..41b719e 100644 --- a/.cursor/mcp.json.example +++ b/.cursor/mcp.json.example @@ -11,7 +11,7 @@ ], "env": { "PRM_API_URL": "http://127.0.0.1:8787", - "PRM_PASSWORD": "workbench" + "PRM_PASSWORD": "your-workspace-password" } } } diff --git a/README.md b/README.md index bef9b08..2223621 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,9 @@ pnpm dev |---|---| | UI | http://localhost:5173 | | API | http://127.0.0.1:8787 | -| Seed password | `workbench` | +| Seed password (demo only) | `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**. To wipe and start over, use **Delete workspace** in Settings (or **Erase workspace** on the unlock screen) — password + type `DELETE`. +First load: choose **Empty workspace** or **Demo team** (8-person org). Demo mode pre-fills password `workbench` for exploration only — **never** put real employee data behind it. Empty workspaces require an explicit password (8+ characters); the API never invents a default. 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). diff --git a/apps/api/src/authPolicy.test.ts b/apps/api/src/authPolicy.test.ts new file mode 100644 index 0000000..45a3e5a --- /dev/null +++ b/apps/api/src/authPolicy.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { describe, it, beforeEach } from "node:test"; +import { + DEMO_PASSWORD, + MIN_PASSWORD_LENGTH, + checkAuthRateLimit, + clearAuthFailures, + recordAuthFailure, + resetAuthRateLimitsForTests, + validateWorkspacePassword, +} from "./authPolicy.js"; + +describe("validateWorkspacePassword", () => { + it("rejects missing / empty passwords (no silent demo default)", () => { + assert.equal(validateWorkspacePassword(undefined).ok, false); + assert.equal(validateWorkspacePassword("").ok, false); + assert.equal(validateWorkspacePassword(null).ok, false); + }); + + it(`rejects passwords shorter than ${MIN_PASSWORD_LENGTH}`, () => { + const short = validateWorkspacePassword("short"); + assert.equal(short.ok, false); + }); + + it("accepts demo password and longer secrets", () => { + const demo = validateWorkspacePassword(DEMO_PASSWORD); + assert.equal(demo.ok, true); + if (demo.ok) assert.equal(demo.password, DEMO_PASSWORD); + const long = validateWorkspacePassword("correct-horse-battery"); + assert.equal(long.ok, true); + }); +}); + +describe("auth rate limit", () => { + beforeEach(() => resetAuthRateLimitsForTests()); + + it("allows attempts until the failure budget is exhausted", () => { + const key = "unlock:test"; + for (let i = 0; i < 7; i++) { + assert.equal(checkAuthRateLimit(key), null); + recordAuthFailure(key); + } + assert.equal(checkAuthRateLimit(key), null); + recordAuthFailure(key); // 8th → lockout + const msg = checkAuthRateLimit(key); + assert.ok(msg && /Too many attempts/.test(msg)); + }); + + it("clears lockout after a successful attempt", () => { + const key = "destroy:test"; + for (let i = 0; i < 8; i++) recordAuthFailure(key); + assert.ok(checkAuthRateLimit(key)); + clearAuthFailures(key); + assert.equal(checkAuthRateLimit(key), null); + }); +}); diff --git a/apps/api/src/authPolicy.ts b/apps/api/src/authPolicy.ts new file mode 100644 index 0000000..641b33a --- /dev/null +++ b/apps/api/src/authPolicy.ts @@ -0,0 +1,68 @@ +/** + * Workspace password policy + local auth rate limiting. + * Loopback-bound API still needs attempt budgets (shared machines / local malware). + */ + +export const MIN_PASSWORD_LENGTH = 8; +/** Demo-only password used by seed CLI / UI demo mode. Never apply as an API default. */ +export const DEMO_PASSWORD = "workbench"; + +const RATE_WINDOW_MS = 15 * 60_000; +const RATE_MAX_FAILURES = 8; +const RATE_LOCKOUT_MS = 15 * 60_000; + +type Bucket = { failures: number[]; lockedUntil: number }; + +const buckets = new Map(); + +export function validateWorkspacePassword( + password: unknown, +): { ok: true; password: string } | { ok: false; error: string } { + if (typeof password !== "string" || password.length === 0) { + return { ok: false, error: "password required" }; + } + if (password.length < MIN_PASSWORD_LENGTH) { + return { + ok: false, + error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters`, + }; + } + return { ok: true, password }; +} + +function prune(bucket: Bucket, now: number) { + bucket.failures = bucket.failures.filter((t) => now - t < RATE_WINDOW_MS); +} + +/** Returns an error message when locked out; otherwise null. */ +export function checkAuthRateLimit(key: string): string | null { + const now = Date.now(); + const bucket = buckets.get(key); + if (!bucket) return null; + if (bucket.lockedUntil > now) { + const secs = Math.max(1, Math.ceil((bucket.lockedUntil - now) / 1000)); + return `Too many attempts. Try again in ${secs}s.`; + } + prune(bucket, now); + return null; +} + +export function recordAuthFailure(key: string) { + const now = Date.now(); + const bucket = buckets.get(key) ?? { failures: [], lockedUntil: 0 }; + prune(bucket, now); + bucket.failures.push(now); + if (bucket.failures.length >= RATE_MAX_FAILURES) { + bucket.lockedUntil = now + RATE_LOCKOUT_MS; + } + buckets.set(key, bucket); +} + +export function clearAuthFailures(key: string) { + buckets.delete(key); +} + +/** Test helper — reset in-memory counters. */ +export function resetAuthRateLimitsForTests() { + buckets.clear(); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 58aad38..3ddb5b3 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -81,7 +81,15 @@ import { createEncryptedBackup, listEncryptedBackups, restoreEncryptedBackup, + dataDirSyncRisk, } from "./store.js"; +import { + checkAuthRateLimit, + clearAuthFailures, + recordAuthFailure, + validateWorkspacePassword, +} from "./authPolicy.js"; +import { timingSafeEqual } from "node:crypto"; import { buildChatSystemPrompt, buildSystemPrompt, @@ -164,12 +172,23 @@ const CORS_ORIGINS = [ function corsOrigin(origin: string): string | null { if (CORS_ORIGINS.includes(origin)) return origin; - // Windows/Android custom-protocol workaround hosts: http(s)://*.localhost - if (/^https?:\/\/(?:[a-z0-9-]+\.)*localhost(?::\d+)?$/i.test(origin)) return origin; + // Packaged Tauri custom-protocol hosts only — not arbitrary *.localhost + if (/^https?:\/\/(?:tauri|asset)\.localhost(?::\d+)?$/i.test(origin)) return origin; if (origin.startsWith("tauri://")) return origin; return null; } +function launchTokenMatches(hdr: string, launch: string) { + try { + const a = Buffer.from(hdr); + const b = Buffer.from(launch); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); + } catch { + return false; + } +} + app.use( "*", cors({ @@ -185,7 +204,7 @@ app.use("/api/*", async (c, next) => { if (launch) { if (c.req.path === "/api/health") return next(); const hdr = c.req.header("x-prm-launch-token") ?? ""; - if (hdr !== launch) { + if (!launchTokenMatches(hdr, launch)) { return c.json({ error: "Invalid or missing launch token", code: "launch_token_required" }, 401); } } @@ -313,6 +332,7 @@ app.get("/api/workspace/status", (c) => { /* locked */ } } + const syncRisk = dataDirSyncRisk(); const body: WorkspaceStatus & { encryptedAtRest: boolean } = { initialized, unlocked, @@ -322,6 +342,8 @@ app.get("/api/workspace/status", (c) => { aiProvider, thinEvidenceCount, encryptedAtRest: enc.encryptedAtRest, + dataDirSyncRisk: syncRisk.risky, + dataDirSyncWarning: syncRisk.warning, }; return c.json(body); }); @@ -329,12 +351,14 @@ app.get("/api/workspace/status", (c) => { app.post("/api/workspace/init", async (c) => { if (isInitialized()) return c.json({ error: "Already initialized" }, 400); const body = await c.req.json<{ name?: string; password?: string; seed?: boolean }>(); - const password = body.password || "workbench"; - if (body.seed !== false) { - const result = await seedWorkspace({ name: body.name, password }); + const pw = validateWorkspacePassword(body.password); + if (!pw.ok) return c.json({ error: pw.error }, 400); + // Seed only when explicitly requested — never default to demo data. + if (body.seed === true) { + const result = await seedWorkspace({ name: body.name, password: pw.password }); return c.json({ ...result, token: getSessionToken(), encryptedAtRest: false }); } - const { hash, salt } = bootstrapVault(password); + const { hash, salt } = bootstrapVault(pw.password); const db = getDb(); const wsId = id("ws"); db.insert(workspace) @@ -354,14 +378,18 @@ app.post("/api/workspace/init", async (c) => { app.post("/api/workspace/unlock", async (c) => { if (!isInitialized()) return c.json({ error: "Not initialized" }, 400); + const limited = checkAuthRateLimit("unlock"); + if (limited) return c.json({ error: limited, code: "rate_limited" }, 429); const { password } = await c.req.json<{ password: string }>(); try { const token = unlockVault(password); + clearAuthFailures("unlock"); const db = getDb(); const ws = db.select().from(workspace).limit(1).all()[0]; logActivity("workspace.unlock", "workspace", ws?.id); return c.json({ token, name: ws?.name, encryptedAtRest: false }); } catch (e) { + recordAuthFailure("unlock"); return c.json({ error: e instanceof Error ? e.message : "Unlock failed" }, 401); } }); @@ -374,6 +402,8 @@ app.post("/api/workspace/lock", (c) => { /** 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 limited = checkAuthRateLimit("destroy"); + if (limited) return c.json({ error: limited, code: "rate_limited" }, 429); 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); @@ -381,8 +411,10 @@ app.post("/api/workspace/destroy", async (c) => { if (!body.password) return c.json({ error: "password required" }, 400); try { destroyWorkspace(body.password); + clearAuthFailures("destroy"); return c.json({ ok: true, initialized: false }); } catch (e) { + recordAuthFailure("destroy"); const err = e as Error & { code?: string }; const status = err.message === "Invalid password" ? 401 : err.code === "legal_hold" ? 403 : 400; diff --git a/apps/api/src/store.ts b/apps/api/src/store.ts index 93e69fc..f939a87 100644 --- a/apps/api/src/store.ts +++ b/apps/api/src/store.ts @@ -18,7 +18,7 @@ import { rmSync, copyFileSync, } from "node:fs"; -import { join, basename } from "node:path"; +import { join, basename, resolve, relative } from "node:path"; import { openDatabase, type Db } from "@prm/db"; import { activityEvents, workspace } from "@prm/db"; import { eq } from "drizzle-orm"; @@ -257,9 +257,46 @@ export function setWorkspaceSecret(secret: Buffer) { workspaceSecret = secret; } +function timingSafeStringEqual(a: string, b: string) { + try { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); + } catch { + return false; + } +} + export function requireSession(token: string | undefined) { const current = getSessionToken(); - return Boolean(current && token && current === token && workspaceSecret && existsSync(DB_PATH)); + return Boolean( + current && + token && + timingSafeStringEqual(current, token) && + workspaceSecret && + existsSync(DB_PATH), + ); +} + +/** Warn when the vault lives inside a consumer cloud-sync folder. */ +export function dataDirSyncRisk(): { risky: boolean; warning?: string } { + const normalized = ROOT.replace(/\\/g, "/").toLowerCase(); + const markers: Array<{ re: RegExp; name: string }> = [ + { re: /\/dropbox(\s*\(|\/|$)/, name: "Dropbox" }, + { re: /\/(library\/mobile documents|icloud)(\/|$)/, name: "iCloud" }, + { re: /\/(onedrive|one drive)(\/|$)/, name: "OneDrive" }, + { re: /\/google drive(\/|$)/, name: "Google Drive" }, + ]; + for (const m of markers) { + if (m.re.test(normalized)) { + return { + risky: true, + warning: `Data directory appears to be inside ${m.name}. Cloud sync can corrupt or expose an unlocked vault — move PRM_DATA_DIR (or the desktop app-data path) outside sync folders.`, + }; + } + } + return { risky: false }; } /** Initialize vault + open plaintext DB for first-time create/seed. */ @@ -533,10 +570,16 @@ export function writeFrameworkFile(filename: string, data: Buffer) { export function readDocumentFile(storagePath: string) { const secret = getWorkspaceSecret(); - if (existsSync(storagePath)) return readFileSync(storagePath); - if (existsSync(`${storagePath}.enc`) && secret) { - openSealedFile(storagePath, secret); - return readFileSync(storagePath); + const resolved = resolve(storagePath); + const root = resolve(paths.files); + const rel = relative(root, resolved); + if (!rel || rel.startsWith("..") || resolved === root) { + throw new Error("Document path outside vault files"); + } + if (existsSync(resolved)) return readFileSync(resolved); + if (existsSync(`${resolved}.enc`) && secret) { + openSealedFile(resolved, secret); + return readFileSync(resolved); } throw new Error("Document not found"); } diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 4246cb2..4e67f52 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -87,7 +87,22 @@ fn write_launch_token(path: &Path, token: &str) -> std::io::Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - fs::write(path, token) + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + let mut f = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + f.write_all(token.as_bytes())?; + return Ok(()); + } + #[cfg(not(unix))] + { + fs::write(path, token) + } } fn random_token() -> String { diff --git a/apps/ui/src/lib/api.ts b/apps/ui/src/lib/api.ts index 61bff2d..7bacc87 100644 --- a/apps/ui/src/lib/api.ts +++ b/apps/ui/src/lib/api.ts @@ -27,6 +27,16 @@ function launchToken(): string | null { return window.__PRM_LAUNCH_TOKEN__?.trim() || null; } +/** Headers for authenticated API fetches outside `api()` (downloads, FormData). */ +export function authHeaders(init?: HeadersInit): Headers { + const headers = new Headers(init); + const token = getToken(); + if (token) headers.set("x-prm-token", token); + const launch = launchToken(); + if (launch) headers.set("x-prm-launch-token", launch); + return headers; +} + /** Packaged desktop sets this; browser/Vite leaves it empty so `/api` stays relative (proxy). */ export function apiBaseUrl(): string { if (typeof window === "undefined") return ""; @@ -62,14 +72,10 @@ export async function waitForApi(opts?: { attempts?: number; delayMs?: number }) } export async function api(path: string, init: RequestInit = {}): Promise { - const headers = new Headers(init.headers); + const headers = authHeaders(init.headers); if (!headers.has("Content-Type") && !(init.body instanceof FormData)) { headers.set("Content-Type", "application/json"); } - const token = getToken(); - if (token) headers.set("x-prm-token", token); - const launch = launchToken(); - if (launch) headers.set("x-prm-launch-token", launch); const res = await fetch(apiUrl(path), { ...init, headers }); if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); diff --git a/apps/ui/src/lib/exports.ts b/apps/ui/src/lib/exports.ts index c3f9c76..6710e14 100644 --- a/apps/ui/src/lib/exports.ts +++ b/apps/ui/src/lib/exports.ts @@ -1,13 +1,8 @@ -import { apiUrl, getToken } from "./api"; +import { apiUrl, authHeaders } from "./api"; /** Open an HTML export in a new tab (Print → PDF). */ export async function openHtmlExport(path: string) { - const headers = new Headers(); - const token = getToken(); - if (token) headers.set("x-prm-token", token); - const launch = typeof window !== "undefined" ? window.__PRM_LAUNCH_TOKEN__?.trim() : ""; - if (launch) headers.set("x-prm-launch-token", launch); - const res = await fetch(apiUrl(path), { headers }); + const res = await fetch(apiUrl(path), { headers: authHeaders() }); if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); throw new Error((err as { error?: string }).error || "Export failed"); @@ -21,14 +16,10 @@ export async function openHtmlExport(path: string) { /** Download a binary export (e.g. encrypted readonly bundle). */ export async function downloadBinaryExport(path: string, init: RequestInit, fallbackName: string) { - const headers = new Headers(init.headers); + const headers = authHeaders(init.headers); if (!headers.has("Content-Type") && !(init.body instanceof FormData)) { headers.set("Content-Type", "application/json"); } - const token = getToken(); - if (token) headers.set("x-prm-token", token); - const launch = typeof window !== "undefined" ? window.__PRM_LAUNCH_TOKEN__?.trim() : ""; - if (launch) headers.set("x-prm-launch-token", launch); const res = await fetch(apiUrl(path), { ...init, headers }); if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); diff --git a/apps/ui/src/pages/PersonPage.tsx b/apps/ui/src/pages/PersonPage.tsx index bba036a..faa4208 100644 --- a/apps/ui/src/pages/PersonPage.tsx +++ b/apps/ui/src/pages/PersonPage.tsx @@ -11,7 +11,7 @@ import type { PersonExternalLinkDTO, PersonTimelineDTO, } from "@prm/shared"; -import { api, getToken, apiUrl } from "../lib/api"; +import { api, authHeaders, apiUrl } from "../lib/api"; import { NextStep, PageHeader, Section } from "../components/PageChrome"; import { PageSectionsLayout, type SectionNavItem } from "../components/PageSectionsLayout"; import { DeliveryRollupPanel } from "../components/DeliveryRollupPanel"; @@ -564,7 +564,7 @@ export function PersonPage() { {d.title} ({d.filename} · {d.visibility} · {d.occurredAt}){" "} { e.preventDefault(); - fetch(apiUrl(`/api/documents/${d.id}/download`), { headers: { "x-prm-token": getToken() ?? "" } }) + fetch(apiUrl(`/api/documents/${d.id}/download`), { headers: authHeaders() }) .then((r) => r.blob()) .then((blob) => { const url = URL.createObjectURL(blob); @@ -587,10 +587,7 @@ export function PersonPage() { fd.append("title", docTitle || docFile.name); fd.append("visibility", "em_and_subject"); fd.append("occurredAt", new Date().toISOString().slice(0, 10)); - const headers: Record = { "x-prm-token": getToken() ?? "" }; - if (typeof window !== "undefined" && window.__PRM_LAUNCH_TOKEN__) { - headers["x-prm-launch-token"] = window.__PRM_LAUNCH_TOKEN__; - } + const headers = authHeaders(); const res = await fetch(apiUrl(`/api/people/${id}/documents`), { method: "POST", headers, @@ -700,7 +697,7 @@ export function PersonPage() { if (!docId) return; try { const fileRes = await fetch(apiUrl(`/api/documents/${docId}/download`), { - headers: { "x-prm-token": getToken() || "" }, + headers: authHeaders(), }); if (!fileRes.ok) throw new Error("Could not load document"); const text = await fileRes.text(); diff --git a/apps/ui/src/pages/PromotionDetailPage.tsx b/apps/ui/src/pages/PromotionDetailPage.tsx index f8d5ea6..94f4e58 100644 --- a/apps/ui/src/pages/PromotionDetailPage.tsx +++ b/apps/ui/src/pages/PromotionDetailPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import type { GapMark, RoleDefinitionDTO } from "@prm/shared"; -import { api, getToken, apiUrl } from "../lib/api"; +import { api, authHeaders, apiUrl } from "../lib/api"; import { NextStep, PageHeader, Section } from "../components/PageChrome"; import { PageSectionsLayout } from "../components/PageSectionsLayout"; @@ -89,9 +89,8 @@ export function PromotionDetailPage() { async function openHtmlPacket() { if (!id) return; - const token = getToken(); const res = await fetch(apiUrl(`/api/exports/promotion/${id}?format=html`), { - headers: token ? { "x-prm-token": token } : {}, + headers: authHeaders(), }); const html = await res.text(); const url = URL.createObjectURL(new Blob([html], { type: "text/html" })); diff --git a/apps/ui/src/pages/RolesPage.tsx b/apps/ui/src/pages/RolesPage.tsx index ded56c9..181f550 100644 --- a/apps/ui/src/pages/RolesPage.tsx +++ b/apps/ui/src/pages/RolesPage.tsx @@ -6,7 +6,7 @@ import type { RoleDraftDTO, RoleFrameworkInfo, } from "@prm/shared"; -import { api, getToken, apiUrl } from "../lib/api"; +import { api, authHeaders, apiUrl } from "../lib/api"; import { NextStep, PageHeader, Section } from "../components/PageChrome"; import { PageSectionsLayout } from "../components/PageSectionsLayout"; @@ -168,12 +168,7 @@ export function RolesPage() { try { const fd = new FormData(); fd.append("file", file); - const headers: HeadersInit = {}; - const token = getToken(); - if (token) headers["x-prm-token"] = token; - if (typeof window !== "undefined" && window.__PRM_LAUNCH_TOKEN__) { - headers["x-prm-launch-token"] = window.__PRM_LAUNCH_TOKEN__; - } + const headers = authHeaders(); const res = await fetch(apiUrl("/api/roles/framework/upload"), { method: "POST", headers, body: fd }); const json = (await res.json()) as { error?: string; diff --git a/apps/ui/src/pages/TeamPage.tsx b/apps/ui/src/pages/TeamPage.tsx index ae1d9d6..98a954e 100644 --- a/apps/ui/src/pages/TeamPage.tsx +++ b/apps/ui/src/pages/TeamPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import type { PeopleImportResult, PersonDTO } from "@prm/shared"; -import { api, getToken, apiUrl } from "../lib/api"; +import { api, authHeaders, apiUrl } from "../lib/api"; import { NextStep, PageHeader } from "../components/PageChrome"; export function TeamPage() { @@ -212,12 +212,7 @@ export function TeamPage() { // Authenticated download via fetch (anchor alone omits x-prm-token). e.preventDefault(); void (async () => { - const headers: HeadersInit = {}; - const token = getToken(); - if (token) headers["x-prm-token"] = token; - const launch = typeof window !== "undefined" ? window.__PRM_LAUNCH_TOKEN__ : undefined; - if (launch) headers["x-prm-launch-token"] = launch; - const res = await fetch(apiUrl("/api/people/import/template"), { headers }); + const res = await fetch(apiUrl("/api/people/import/template"), { headers: authHeaders() }); if (!res.ok) { setImportMsg("Could not download template"); return; diff --git a/apps/ui/src/pages/UnlockPage.tsx b/apps/ui/src/pages/UnlockPage.tsx index 9361e49..fc03ba5 100644 --- a/apps/ui/src/pages/UnlockPage.tsx +++ b/apps/ui/src/pages/UnlockPage.tsx @@ -35,8 +35,8 @@ export function UnlockPage({ setError(null); try { if (!status.initialized) { - if (password.trim().length < 4) { - setError("Choose a password with at least 4 characters."); + if (password.trim().length < 8) { + setError("Choose a password with at least 8 characters."); setBusy(false); return; } @@ -135,8 +135,14 @@ export function UnlockPage({ ) : !status.initialized ? (

{setupMode === "demo" - ? "Demo data is for exploring the product. You can erase the workspace later from Unlock or Settings and create a new one." - : "No sample people or reviews. You will add the roster from Team."} + ? "Demo data is for exploring the product. Never store real employee data behind the demo password. You can erase the workspace later from Unlock or Settings and create a new one." + : "No sample people or reviews. You will add the roster from Team. Choose a strong password — there is no cloud recovery."} +

+ ) : null} + + {status.dataDirSyncRisk && status.dataDirSyncWarning ? ( +

+ {status.dataDirSyncWarning}

) : null} diff --git a/apps/ui/src/pages/WritingDeskPage.tsx b/apps/ui/src/pages/WritingDeskPage.tsx index 12b0832..28c890c 100644 --- a/apps/ui/src/pages/WritingDeskPage.tsx +++ b/apps/ui/src/pages/WritingDeskPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; import type { BiasFinding, DecisionLogEntry, DeliveryCiteSuggestion, GapMark, PacketDTO, PromoReadiness } from "@prm/shared"; import { COMPETENCY_CATALOG, MIDDLE_BAND, competencyLabel } from "@prm/shared"; -import { api, getToken, apiUrl } from "../lib/api"; +import { api, authHeaders, apiUrl } from "../lib/api"; import { estimateThenConfirm } from "../lib/aiEstimate"; import { openHtmlExport } from "../lib/exports"; import { NextStep, PageHeader, Section } from "../components/PageChrome"; @@ -330,9 +330,8 @@ export function WritingDeskPage() { async function openSubjectHtml() { if (!cycleId || !personId) return; - const token = getToken(); const res = await fetch(apiUrl(`/api/exports/subject/${cycleId}/${personId}?format=html`), { - headers: token ? { "x-prm-token": token } : {}, + headers: authHeaders(), }); if (!res.ok) { setError("Could not load HTML packet"); diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 6003eac..866528d 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -21,7 +21,7 @@ Add to **Cursor Settings → MCP** (or project `.cursor/mcp.json`): "args": ["--dir", "/absolute/path/to/PerformanceReviewManager", "exec", "tsx", "packages/mcp/src/index.ts"], "env": { "PRM_API_URL": "http://127.0.0.1:8787", - "PRM_PASSWORD": "workbench" + "PRM_PASSWORD": "your-workspace-password" } } } diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index d553908..68c73a3 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -72,9 +72,12 @@ export function registerPrmTools(server: McpServer, client: PrmClient) { { title: "Unlock workspace", description: - "Unlock the encrypted PRM vault with the workspace password and cache the session token for later tools. Default demo password is workbench.", + "Unlock the encrypted PRM vault with the workspace password and cache the session token for later tools. Set PRM_PASSWORD or pass password explicitly — there is no silent demo default.", inputSchema: z.object({ - password: z.string().optional().describe("Workspace password. Defaults to PRM_PASSWORD or workbench."), + password: z + .string() + .optional() + .describe("Workspace password. Defaults to PRM_PASSWORD env only (never a built-in demo password)."), }), }, async ({ password }) => { @@ -483,7 +486,7 @@ export function registerPrmTools(server: McpServer, client: PrmClient) { External source context: ${source ?? "unspecified (Jira / Google / docs / chat exports)"}. Workflow: -1. Call prm_status. If locked, prm_unlock (password workbench unless told otherwise). +1. Call prm_status. If locked, prm_unlock with the workspace password (or set PRM_PASSWORD). Demo vaults often use workbench — never assume that for real data. 2. prm_list_people / prm_list_roles to map names → person ids and role ids. 3. Prefer a first-run backfill: for each direct with thin dossiers (<2 achievements/feedback/docs), gather recent outcomes before drafting reviews. The UI Backfill wizard (/backfill) mirrors this path. 4. Gather items from the external system (tickets closed, docs, 1:1 notes, transcripts). diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0548359..8ecdcbe 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -499,6 +499,9 @@ export interface WorkspaceStatus { encryptedAtRest?: boolean; /** Directs with thin dossiers — useful for backfill CTA after unlock. */ thinEvidenceCount?: number; + /** True when data dir looks like Dropbox/iCloud/OneDrive/Google Drive. */ + dataDirSyncRisk?: boolean; + dataDirSyncWarning?: string; } export interface AiEstimateDTO { From 4a1014e9937eac83ada149343a387d478d0d5933 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 11:49:21 +0000 Subject: [PATCH 2/2] Fix desktop Cargo authors and MCP env comment Co-authored-by: Damon --- apps/desktop/src-tauri/Cargo.toml | 2 +- packages/mcp/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 4ce5ded..7d8d4da 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ name = "prm-desktop" version = "0.1.0" description = "Performance Review Manager desktop shell" -authors = ["you"] +authors = ["Damon van der Linde"] edition = "2021" [build-dependencies] diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 1023f78..108b228 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -5,7 +5,7 @@ * * Env: * PRM_API_URL default http://127.0.0.1:8787 - * PRM_PASSWORD default workbench + * PRM_PASSWORD unlock password (required for auto-unlock; no demo default) * PRM_TOKEN optional pre-unlocked session token */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";