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
2 changes: 1 addition & 1 deletion .cursor/mcp.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
],
"env": {
"PRM_API_URL": "http://127.0.0.1:8787",
"PRM_PASSWORD": "workbench"
"PRM_PASSWORD": "your-workspace-password"
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
56 changes: 56 additions & 0 deletions apps/api/src/authPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
68 changes: 68 additions & 0 deletions apps/api/src/authPolicy.ts
Original file line number Diff line number Diff line change
@@ -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<string, Bucket>();

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();
}
46 changes: 39 additions & 7 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -313,6 +332,7 @@ app.get("/api/workspace/status", (c) => {
/* locked */
}
}
const syncRisk = dataDirSyncRisk();
const body: WorkspaceStatus & { encryptedAtRest: boolean } = {
initialized,
unlocked,
Expand All @@ -322,19 +342,23 @@ app.get("/api/workspace/status", (c) => {
aiProvider,
thinEvidenceCount,
encryptedAtRest: enc.encryptedAtRest,
dataDirSyncRisk: syncRisk.risky,
dataDirSyncWarning: syncRisk.warning,
};
return c.json(body);
});

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)
Expand All @@ -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);
}
});
Expand All @@ -374,15 +402,19 @@ 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);
}
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;
Expand Down
55 changes: 49 additions & 6 deletions apps/api/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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");
}
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
17 changes: 16 additions & 1 deletion apps/desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading