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..19dc076 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -16,3 +16,10 @@ 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 + # 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/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 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); + }); +});