From 1bf300a237a18e6387d07624ff6c6512fd7d3ef6 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 1 Sep 2026 16:23:23 +0300 Subject: [PATCH 1/2] chore(security): untrack docs.local and guard against re-adding it docs.local/convex-auth-patterns.md was tracked and published in this public repo. The content is generic Convex auth documentation with no secrets, and it is the only path that has ever existed under docs.local in this repo's history, so no history rewrite is needed. .gitignore alone does not hold: `git add -f` overrides it, and once a path is tracked .gitignore no longer applies to it. On voicelayer that gap published dictation transcripts and personal-data screenshots and forced a full history rewrite plus repo rebuild on 2026-09-01. Adds scripts/guard-no-docslocal.sh, wired into the husky pre-commit hook and a CI job, so a tracked docs.local path cannot come back. Co-Authored-By: Claude Fable 5 --- .github/workflows/docs-local-guard.yml | 20 +++++ .husky/pre-commit | 5 ++ docs.local/convex-auth-patterns.md | 103 ------------------------- scripts/guard-no-docslocal.sh | 65 ++++++++++++++++ 4 files changed, 90 insertions(+), 103 deletions(-) create mode 100644 .github/workflows/docs-local-guard.yml delete mode 100644 docs.local/convex-auth-patterns.md create mode 100755 scripts/guard-no-docslocal.sh diff --git a/.github/workflows/docs-local-guard.yml b/.github/workflows/docs-local-guard.yml new file mode 100644 index 0000000..3360619 --- /dev/null +++ b/.github/workflows/docs-local-guard.yml @@ -0,0 +1,20 @@ +name: docs.local guard + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +jobs: + docs-local-guard: + name: No tracked docs.local + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Guard against tracked docs.local + run: bash scripts/guard-no-docslocal.sh diff --git a/.husky/pre-commit b/.husky/pre-commit index 2845d0a..0a4cf7a 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -16,3 +16,8 @@ echo "Running typecheck..." bun run typecheck || { echo "Typecheck failed"; exit 1; } echo "All checks passed" + +# Guard — no tracked docs.local/ (personal data; see scripts/guard-no-docslocal.sh) +if [ -f scripts/guard-no-docslocal.sh ]; then + sh scripts/guard-no-docslocal.sh || exit 1 +fi diff --git a/docs.local/convex-auth-patterns.md b/docs.local/convex-auth-patterns.md deleted file mode 100644 index f526eb8..0000000 --- a/docs.local/convex-auth-patterns.md +++ /dev/null @@ -1,103 +0,0 @@ -# Convex Authentication Patterns - -This document outlines best practices and secure patterns for handling authentication within Convex functions, emphasizing the use of `ctx.auth.getUserIdentity()` and project-specific helpers. - -## Core Security Principle - -**NEVER accept user ID as a function argument.** Always retrieve the user's identity directly from the `ctx.auth` object within Convex functions. The `ctx.auth` object is cryptographically verified and cannot be spoofed by an attacker. - -## Key Patterns and Helpers - -### 1. `getAuthUserId(ctx)`: Optional Authentication - -This helper function retrieves the authenticated user's ID if available. It returns `null` if the user is not authenticated, making it suitable for operations that can be performed by both authenticated and unauthenticated users, but behave differently for each. - -```typescript -// convex/authHelpers.ts -import { authComponent } from './betterAuth'; // Assuming betterAuth is the custom auth component - -// Returns userId or null - use for optional auth -export async function getAuthUserId(ctx: any): Promise { - const authUser = await authComponent.safeGetAuthUser(ctx); - return authUser?._id ?? null; -} -``` - -### 2. `requireAuth(ctx)`: Required Authentication - -This helper function ensures that a user is authenticated before proceeding. If the user is not authenticated, it throws an error, preventing unauthorized access to sensitive operations. - -```typescript -// convex/authHelpers.ts -import { authComponent } from './betterAuth'; // Assuming betterAuth is the custom auth component - -// Throws if not authenticated - use for required auth -export async function requireAuth(ctx: any): Promise { - const userId = await getAuthUserId(ctx); // Re-uses getAuthUserId for consistency - if (!userId) { - throw new Error('Authentication required'); - } - return userId; -} -``` - -## Usage in Convex Functions - -### Secure Query Example (`getForCurrentUser` adapted) - -```typescript -import { query } from "./_generated/server"; -import { requireAuth } from "./authHelpers"; // Assuming authHelpers.ts is in the same directory - -export const getForCurrentUser = query({ - args: {}, - handler: async (ctx) => { - // This will throw if not authenticated - const userId = await requireAuth(ctx); - - // Use userId for secure data fetching - return await ctx.db - .query("messages") - .filter((q) => q.eq(q.field("author"), userId)) - .collect(); - }, -}); -``` - -### Secure Mutation Example (`updateTeam` adapted) - -```typescript -import { mutation } from "./_generated/server"; -import { requireAuth } from "./authHelpers"; // Assuming authHelpers.ts is in the same directory -import { v } from "convex/values"; - -export const updateTeam = mutation({ - args: { - id: v.id("teams"), - update: v.object({ - name: v.optional(v.string()), - owner: v.optional(v.id("users")), - }), - }, - handler: async (ctx, { id, update }) => { - // This will throw if not authenticated - const userId = await requireAuth(ctx); - - // Perform authorization checks using the verified userId - const isTeamMember = /* check if user (userId) is a member of the team (id) */ - if (!isTeamMember) { - throw new Error("Unauthorized"); - } - await ctx.db.patch("teams", id, update); - }, -}); -``` - -## Client-Side Integration Notes (Convex React Hooks) - -For client-side applications, `ConvexProviderWithAuth` and `useConvexAuth` are essential for integrating custom authentication providers and managing authentication state. - -- `ConvexProviderWithAuth`: Replaces `ConvexProvider` to combine Convex functionality with custom authentication, providing authentication state to descendant components. -- `useConvexAuth`: A React hook to retrieve the current authentication state (`isLoading`, `isAuthenticated`). - -This ensures that authentication status is consistently managed and accessible throughout your application, both on the server (via `ctx.auth`) and client. \ No newline at end of file diff --git a/scripts/guard-no-docslocal.sh b/scripts/guard-no-docslocal.sh new file mode 100755 index 0000000..572e40f --- /dev/null +++ b/scripts/guard-no-docslocal.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Guard: no docs.local/ path may ever be TRACKED by git. +# +# Why this exists (2026-09-01): +# docs.local/ held verbatim speech-to-text dictation transcripts and desktop +# screenshots containing personal data. Seven such blobs reached the public +# repo and forced a full history rewrite plus a repo rebuild. +# +# .gitignore did NOT prevent it. `git add -f` overrides .gitignore, and once a +# path is tracked, .gitignore is ignored for that path forever after. +# This guard is the part that actually holds. +# +# Exit 0 = clean. Exit 1 = tracked docs.local paths found. +# +# Used by: .githooks/pre-push and .github/workflows/ci.yml + +set -uo pipefail + +tracked="$(git ls-files -- 'docs.local' 'docs.local/**' 2>/dev/null || true)" + +if [ -z "$tracked" ]; then + echo "docs.local guard: OK — 0 tracked paths" + exit 0 +fi + +count="$(printf '%s\n' "$tracked" | grep -c . || true)" + +cat <^1 -- docs.local/ && git reset HEAD docs.local/ + + After merging, check EVERY checkout (`git worktree list`), not just this one. + +Do not bypass with --no-verify. + +BANNER + +exit 1 From 7d27208d4d01cd04c91e0c8379b958367181aa55 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 1 Sep 2026 18:01:48 +0300 Subject: [PATCH 2/2] fix(guard): invoke docs.local guard with bash, add unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the review on #40. 1. BUG — the hook ran `sh scripts/guard-no-docslocal.sh`. The guard uses `set -o pipefail`, which POSIX shells reject. On Ubuntu/Debian /bin/sh is dash, so the guard aborted with "Illegal option -o pipefail" and, via `|| exit 1`, rejected EVERY commit — even with nothing tracked. Reproduced locally: `dash scripts/guard-no-docslocal.sh` exits 2. macOS hid it because /bin/sh there is bash 3.2 in POSIX mode. 2. RULE VIOLATION — new helpers need unit tests (CLAUDE.md). Adds src/__tests__/guard-no-docslocal.test.ts covering both exit paths against throwaway git repos: - nothing tracked -> exit 0 - `git add -f` a docs.local path -> exit 1, path listed - untracked again -> exit 0, file still on disk - hook invokes bash, not sh -> regression test for finding 1 Verified the regression test actually catches it: reverting the hook to `sh` turns that test red, restoring `bash` turns it green. Co-Authored-By: Claude Fable 5 --- .husky/pre-commit | 4 +- src/__tests__/guard-no-docslocal.test.ts | 102 +++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/guard-no-docslocal.test.ts diff --git a/.husky/pre-commit b/.husky/pre-commit index 0a4cf7a..19dc076 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -19,5 +19,7 @@ echo "All checks passed" # Guard — no tracked docs.local/ (personal data; see scripts/guard-no-docslocal.sh) if [ -f scripts/guard-no-docslocal.sh ]; then - sh scripts/guard-no-docslocal.sh || exit 1 + # Must be `bash`: the guard uses `set -o pipefail`, which POSIX shells + # (dash on Ubuntu/Debian, where /bin/sh is dash) reject outright. + bash scripts/guard-no-docslocal.sh || exit 1 fi diff --git a/src/__tests__/guard-no-docslocal.test.ts b/src/__tests__/guard-no-docslocal.test.ts new file mode 100644 index 0000000..be5084c --- /dev/null +++ b/src/__tests__/guard-no-docslocal.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + mkdtempSync, + rmSync, + mkdirSync, + writeFileSync, + readFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const GUARD = resolve(process.cwd(), "scripts/guard-no-docslocal.sh"); +const HOOK = resolve(process.cwd(), ".husky/pre-commit"); + +/** Build a throwaway git repo with the guard script copied in. */ +function makeRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "guard-docslocal-")); + const git = (...args: string[]) => + execFileSync("git", args, { cwd: dir, stdio: "pipe" }); + + git("init", "-q"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "test"); + + mkdirSync(join(dir, "scripts"), { recursive: true }); + mkdirSync(join(dir, "docs.local"), { recursive: true }); + writeFileSync( + join(dir, "scripts/guard-no-docslocal.sh"), + readFileSync(GUARD), + ); + writeFileSync(join(dir, ".gitignore"), "docs.local/\n"); + writeFileSync(join(dir, "README.md"), "test\n"); + git("add", "README.md", ".gitignore", "scripts/guard-no-docslocal.sh"); + git("commit", "-qm", "init"); + + return dir; +} + +function runGuard(dir: string, shell = "bash") { + return spawnSync(shell, ["scripts/guard-no-docslocal.sh"], { + cwd: dir, + encoding: "utf8", + }); +} + +describe("guard-no-docslocal.sh", () => { + let dir: string; + + beforeAll(() => { + dir = makeRepo(); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("exits 0 when no docs.local path is tracked", () => { + // docs.local exists on disk and is gitignored, but nothing is tracked. + writeFileSync(join(dir, "docs.local/scratch.md"), "local only\n"); + + const res = runGuard(dir); + + expect(res.status).toBe(0); + expect(res.stdout).toContain("0 tracked paths"); + }); + + it("exits 1 when a docs.local path is force-added past .gitignore", () => { + // `git add -f` is exactly how .gitignore gets defeated in practice. + writeFileSync(join(dir, "docs.local/leaked.md"), "sensitive\n"); + execFileSync("git", ["add", "-f", "docs.local/leaked.md"], { cwd: dir }); + + const res = runGuard(dir); + + expect(res.status).toBe(1); + expect(res.stdout).toContain("docs.local/leaked.md"); + }); + + it("returns to exit 0 once the path is untracked, leaving the file on disk", () => { + execFileSync("git", ["rm", "-q", "--cached", "docs.local/leaked.md"], { + cwd: dir, + }); + + const res = runGuard(dir); + + expect(res.status).toBe(0); + // The whole point: untracking must not delete the user's local file. + expect(readFileSync(join(dir, "docs.local/leaked.md"), "utf8")).toBe( + "sensitive\n", + ); + }); + + it("is invoked with bash, not sh, by the pre-commit hook", () => { + // Regression test: the guard uses `set -o pipefail`, which dash rejects. + // On Ubuntu/Debian /bin/sh is dash, so `sh scripts/guard-no-docslocal.sh` + // aborted with "Illegal option -o pipefail" and rejected EVERY commit. + const hook = readFileSync(HOOK, "utf8"); + + expect(hook).toMatch(/bash scripts\/guard-no-docslocal\.sh/); + expect(hook).not.toMatch(/(^|[^a-z])sh scripts\/guard-no-docslocal\.sh/m); + }); +});