From f282aea4fdea4d0edfc885e17d0377ca6fbe96ae Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Sun, 25 Oct 2026 09:17:08 +0100 Subject: [PATCH] feat: harden input validation, doc access, and CI - CI: add GitHub Actions (lint, typecheck, unit + integration); add typecheck/test:unit/test:integration scripts; fix the 3 tsc errors and 41 ESLint errors that were blocking a green baseline. - Payload caps: bound doc content (1 MB), title, version_note, meta, and comment body/author/anchor_text/anchor_type with 400s instead of unbounded writes. - DB: index the hot read paths (docs userId+updatedAt and visibility+ createdAt, doc_versions docId+versionNumber, comments docId+createdAt and docId+status). - Auth: extract one evaluateDocAccess used by GET /docs/:slug, the feedback endpoints, and the share page, removing three divergent implementations. Fixes magic tokens sent via X-Magic-Token not unlocking owner-only fields on GET. - Security: add HSTS/CSP and related headers (CSP production-only, with the Redoc CDN and Google Fonts allowed for /api-docs). - Tests: cover every doc read access path and the new payload limits. --- .github/workflows/ci.yml | 80 ++++++++ next.config.ts | 40 ++++ package.json | 3 + .../migration.sql | 8 + prisma/schema.prisma | 5 + src/__tests__/api/agent-dx.test.ts | 3 +- src/__tests__/api/collections.test.ts | 3 +- src/__tests__/api/doc-access.test.ts | 171 ++++++++++++++++++ src/__tests__/api/docs.test.ts | 2 +- src/__tests__/api/input-limits.test.ts | 85 +++++++++ src/__tests__/api/review-lifecycle.test.ts | 3 +- src/__tests__/lib/validation.test.ts | 80 ++++++++ src/app/agents/page.tsx | 6 +- .../v1/docs/[slug]/comments/batch/route.ts | 17 +- src/app/api/v1/docs/[slug]/comments/route.ts | 24 ++- src/app/api/v1/docs/[slug]/route.ts | 62 +++---- src/app/api/v1/docs/route.ts | 18 +- src/app/cli/page.tsx | 6 +- src/app/created/[slug]/page.tsx | 44 +++-- src/app/okf/page.tsx | 6 +- src/app/page.tsx | 12 +- src/app/prompts/page.tsx | 6 +- src/app/rfcs/page.tsx | 6 +- src/app/share-markdown-online/page.tsx | 6 +- src/app/share/[slug]/page.tsx | 52 +++--- src/app/skill/page.tsx | 6 +- src/app/vs/[competitor]/page.tsx | 4 +- src/app/vscode/page.tsx | 6 +- src/app/writing/page.tsx | 6 +- src/components/CommentSection.tsx | 3 + src/components/ReactionsBar.tsx | 3 + src/components/ReviewsSection.tsx | 3 + src/components/ThemeToggle.tsx | 38 ++-- src/lib/auth.ts | 162 ++++++++++++----- src/lib/useReviewerName.ts | 37 ++-- src/lib/validation.ts | 99 ++++++++++ 36 files changed, 921 insertions(+), 194 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 prisma/migrations/20261025120000_add_query_indexes/migration.sql create mode 100644 src/__tests__/api/doc-access.test.ts create mode 100644 src/__tests__/api/input-limits.test.ts create mode 100644 src/__tests__/lib/validation.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0c101bf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + name: Lint, typecheck, unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + - run: npx prisma generate + - run: npm run lint + - run: npm run typecheck + - run: npm run test:unit + + integration: + name: Integration tests + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: draftmark + POSTGRES_PASSWORD: draftmark + POSTGRES_DB: draftmark_test + ports: + - 5435:5432 + options: >- + --health-cmd "pg_isready -U draftmark" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgres://draftmark:draftmark@localhost:5435/draftmark_test + JWT_SECRET: test-jwt-secret-for-vitest + NEXT_PUBLIC_BASE_URL: http://localhost:3333 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + - run: npx prisma generate + - run: npx prisma migrate deploy + + - name: Start dev server + run: | + RATE_LIMIT_DISABLED=1 npm run dev > /tmp/next.log 2>&1 & + for i in $(seq 1 60); do + if curl -sf http://localhost:3333/api/v1/explore > /dev/null; then + exit 0 + fi + sleep 2 + done + echo "Server failed to start" + cat /tmp/next.log + exit 1 + + - run: npm run test:integration + + - name: Show server log on failure + if: failure() + run: cat /tmp/next.log diff --git a/next.config.ts b/next.config.ts index 68a6c64..366ba5c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,47 @@ import type { NextConfig } from "next"; +// Applied to every response. CSP is production-only: the Next dev overlay and +// HMR need inline/eval scripts that a strict policy would block. +const SECURITY_HEADERS = [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, +]; + +// Notes on the allowances: +// - 'unsafe-inline' (scripts/styles) is required by the pre-hydration theme +// script in layout.tsx, Next.js' bootstrap, and Mermaid/Redoc inline styles. +// - https://cdn.redoc.ly serves the Redoc bundle used by /api-docs. +// - Google Fonts hosts are referenced by Redoc's default stylesheet; the app's +// own fonts are self-hosted via next/font. +const CONTENT_SECURITY_POLICY = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' https://cdn.redoc.ly", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "img-src 'self' data: blob:", + "font-src 'self' data: https://fonts.gstatic.com", + "connect-src 'self'", + "worker-src 'self' blob:", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "upgrade-insecure-requests", +].join("; "); + const nextConfig: NextConfig = { output: "standalone", + async headers() { + const headers = [...SECURITY_HEADERS]; + if (process.env.NODE_ENV === "production") { + headers.push( + { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }, + { key: "Content-Security-Policy", value: CONTENT_SECURITY_POLICY } + ); + } + return [{ source: "/:path*", headers }]; + }, }; export default nextConfig; diff --git a/package.json b/package.json index e9fae9a..48fb094 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "build": "next build", "start": "next start", "lint": "eslint", + "typecheck": "tsc --noEmit", "test": "DATABASE_URL=postgres://draftmark:draftmark@localhost:5435/draftmark_test vitest run", + "test:unit": "vitest run --project unit", + "test:integration": "DATABASE_URL=postgres://draftmark:draftmark@localhost:5435/draftmark_test vitest run --project integration", "test:watch": "DATABASE_URL=postgres://draftmark:draftmark@localhost:5435/draftmark_test vitest", "test:server": "RATE_LIMIT_DISABLED=1 DATABASE_URL=postgres://draftmark:draftmark@localhost:5435/draftmark_test next dev --port 3333" }, diff --git a/prisma/migrations/20261025120000_add_query_indexes/migration.sql b/prisma/migrations/20261025120000_add_query_indexes/migration.sql new file mode 100644 index 0000000..c2c5a13 --- /dev/null +++ b/prisma/migrations/20261025120000_add_query_indexes/migration.sql @@ -0,0 +1,8 @@ +-- Indexes backing the hot read paths: dashboard doc lists (userId + updatedAt), +-- the public explore feed (visibility + createdAt), latest-version lookups, and +-- the comment feed (per-doc, optionally filtered by status). +CREATE INDEX "docs_user_id_updated_at_idx" ON "docs"("user_id", "updated_at"); +CREATE INDEX "docs_visibility_created_at_idx" ON "docs"("visibility", "created_at"); +CREATE INDEX "doc_versions_doc_id_version_number_idx" ON "doc_versions"("doc_id", "version_number"); +CREATE INDEX "comments_doc_id_created_at_idx" ON "comments"("doc_id", "created_at"); +CREATE INDEX "comments_doc_id_status_idx" ON "comments"("doc_id", "status"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e45a5fc..a535aae 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -72,6 +72,8 @@ model Doc { reviews Review[] collectionDocs CollectionDoc[] + @@index([userId, updatedAt]) + @@index([visibility, createdAt]) @@map("docs") } @@ -84,6 +86,7 @@ model DocVersion { createdAt DateTime @default(now()) @map("created_at") doc Doc @relation(fields: [docId], references: [id], onDelete: Cascade) + @@index([docId, versionNumber]) @@map("doc_versions") } @@ -108,6 +111,8 @@ model Comment { replies Comment[] @relation("CommentReplies") subscriptions CommentSubscription[] + @@index([docId, createdAt]) + @@index([docId, status]) @@map("comments") } diff --git a/src/__tests__/api/agent-dx.test.ts b/src/__tests__/api/agent-dx.test.ts index 3e336bd..5e90f2b 100644 --- a/src/__tests__/api/agent-dx.test.ts +++ b/src/__tests__/api/agent-dx.test.ts @@ -2,11 +2,12 @@ import { describe, it, expect } from "vitest"; import { prisma } from "@/lib/prisma"; import { generateSlug } from "@/lib/slug"; import { generateMagicToken, generateApiKey, hashToken } from "@/lib/tokens"; +import type { Prisma } from "@/generated/prisma/client"; async function createTestDoc( overrides: { visibility?: string; - meta?: Record; + meta?: Prisma.InputJsonValue; } = {} ) { const slug = generateSlug(); diff --git a/src/__tests__/api/collections.test.ts b/src/__tests__/api/collections.test.ts index 6167cae..8ed98b4 100644 --- a/src/__tests__/api/collections.test.ts +++ b/src/__tests__/api/collections.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { prisma } from "@/lib/prisma"; import { generateSlug } from "@/lib/slug"; import { generateMagicToken, generateApiKey, hashToken } from "@/lib/tokens"; +import type { Prisma } from "@/generated/prisma/client"; const BASE_URL = process.env.TEST_BASE_URL || "http://localhost:3333"; @@ -344,7 +345,7 @@ describe("Collections API", () => { describe("GET /collections/:slug?format=okf", () => { async function createDocWithMeta(overrides: { visibility?: string; - meta?: Record; + meta?: Prisma.InputJsonValue; content?: string; } = {}) { const slug = generateSlug(); diff --git a/src/__tests__/api/doc-access.test.ts b/src/__tests__/api/doc-access.test.ts new file mode 100644 index 0000000..c907cca --- /dev/null +++ b/src/__tests__/api/doc-access.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from "vitest"; +import { prisma } from "@/lib/prisma"; +import { generateSlug } from "@/lib/slug"; +import { + generateMagicToken, + generateApiKey, + generateAccountApiKey, + generateShareToken, + hashToken, +} from "@/lib/tokens"; + +const BASE_URL = process.env.TEST_BASE_URL || "http://localhost:3333"; + +async function createDoc( + overrides: { + visibility?: string; + shareToken?: string | null; + userId?: string | null; + meta?: Record; + } = {} +) { + const rawMagicToken = generateMagicToken(); + const rawApiKey = generateApiKey(); + + const doc = await prisma.doc.create({ + data: { + slug: generateSlug(), + title: "Access Doc", + content: "# Access Doc\n\nPrivate body", + visibility: overrides.visibility ?? "private", + magicToken: hashToken(rawMagicToken), + apiKey: hashToken(rawApiKey), + shareToken: overrides.shareToken ?? null, + userId: overrides.userId ?? null, + meta: overrides.meta ?? undefined, + }, + }); + + return { doc, rawMagicToken, rawApiKey }; +} + +function getDoc(slug: string, headers: Record = {}) { + return fetch(`${BASE_URL}/api/v1/docs/${slug}`, { headers }); +} + +describe("GET /docs/:slug access paths", () => { + it("allows anonymous reads of public docs", async () => { + const { doc } = await createDoc({ visibility: "public" }); + + const res = await getDoc(doc.slug); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.content).toBe("# Access Doc\n\nPrivate body"); + }); + + it("rejects private docs without a credential", async () => { + const { doc } = await createDoc(); + + const res = await getDoc(doc.slug); + expect(res.status).toBe(401); + const data = await res.json(); + expect(data.error).toContain("private"); + expect(data.content).toBeUndefined(); + }); + + it("rejects private docs with a wrong magic token", async () => { + const { doc } = await createDoc(); + + const res = await getDoc(doc.slug, { "x-magic-token": "tok_wrong" }); + expect(res.status).toBe(401); + }); + + it("allows the magic token via ?token=", async () => { + const { doc, rawMagicToken } = await createDoc(); + + const res = await fetch( + `${BASE_URL}/api/v1/docs/${doc.slug}?token=${rawMagicToken}` + ); + expect(res.status).toBe(200); + }); + + it("allows the magic token via the X-Magic-Token header", async () => { + const { doc, rawMagicToken } = await createDoc(); + + const res = await getDoc(doc.slug, { "x-magic-token": rawMagicToken }); + expect(res.status).toBe(200); + }); + + it("allows the magic token via the per-doc cookie", async () => { + const { doc, rawMagicToken } = await createDoc(); + + const res = await getDoc(doc.slug, { + cookie: `dm_tok_${doc.slug}=${rawMagicToken}`, + }); + expect(res.status).toBe(200); + }); + + it("allows the doc API key", async () => { + const { doc, rawApiKey } = await createDoc(); + + const res = await getDoc(doc.slug, { + authorization: `Bearer ${rawApiKey}`, + }); + expect(res.status).toBe(200); + }); + + it("allows a share token via ?share_token=", async () => { + const shareToken = generateShareToken(); + const { doc } = await createDoc({ shareToken }); + + const res = await fetch( + `${BASE_URL}/api/v1/docs/${doc.slug}?share_token=${shareToken}` + ); + expect(res.status).toBe(200); + }); + + it("allows a share token via ?token=share_...", async () => { + const shareToken = generateShareToken(); + const { doc } = await createDoc({ shareToken }); + + const res = await fetch( + `${BASE_URL}/api/v1/docs/${doc.slug}?token=${shareToken}` + ); + expect(res.status).toBe(200); + }); + + it("allows an account API key owned doc", async () => { + const rawAcctKey = generateAccountApiKey(); + const user = await prisma.user.create({ + data: { email: `owner-${generateSlug()}@example.com` }, + }); + await prisma.accountApiKey.create({ + data: { userId: user.id, key: hashToken(rawAcctKey) }, + }); + const { doc } = await createDoc({ userId: user.id }); + + const res = await getDoc(doc.slug, { + authorization: `Bearer ${rawAcctKey}`, + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.content).toBe("# Access Doc\n\nPrivate body"); + }); + + it("treats a magic token as owner regardless of which source carries it", async () => { + const { doc, rawMagicToken } = await createDoc({ + meta: { agent: "claude" }, + }); + + // Regression: the header source used to be ignored for owner detection, so + // owner-only fields were hidden even with a valid token. + const res = await getDoc(doc.slug, { "x-magic-token": rawMagicToken }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.meta).toEqual({ agent: "claude" }); + expect(data.views_count).toBeDefined(); + }); + + it("does not expose owner-only fields to share-token readers", async () => { + const shareToken = generateShareToken(); + const { doc } = await createDoc({ shareToken, meta: { agent: "claude" } }); + + const res = await fetch( + `${BASE_URL}/api/v1/docs/${doc.slug}?share_token=${shareToken}` + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.meta).toBeUndefined(); + expect(data.views_count).toBeUndefined(); + }); +}); diff --git a/src/__tests__/api/docs.test.ts b/src/__tests__/api/docs.test.ts index b873fb2..6db8c35 100644 --- a/src/__tests__/api/docs.test.ts +++ b/src/__tests__/api/docs.test.ts @@ -196,7 +196,7 @@ describe("Doc CRUD", () => { describe("GET /docs/:slug social data", () => { it("returns reactions_count, comments_count, and reviews", async () => { - const { doc, rawApiKey } = await createTestDoc(); + const { doc } = await createTestDoc(); // Add a reaction await prisma.reaction.create({ diff --git a/src/__tests__/api/input-limits.test.ts b/src/__tests__/api/input-limits.test.ts new file mode 100644 index 0000000..05e777f --- /dev/null +++ b/src/__tests__/api/input-limits.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { prisma } from "@/lib/prisma"; +import { generateSlug } from "@/lib/slug"; +import { generateMagicToken, generateApiKey, hashToken } from "@/lib/tokens"; +import { MAX_CONTENT_BYTES, MAX_COMMENT_CHARS } from "@/lib/validation"; + +const BASE_URL = process.env.TEST_BASE_URL || "http://localhost:3333"; + +describe("request payload limits", () => { + it("rejects oversized doc content on create", async () => { + const res = await fetch(`${BASE_URL}/api/v1/docs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "a".repeat(MAX_CONTENT_BYTES + 1) }), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("exceeds"); + }); + + it("rejects oversized meta on create", async () => { + const res = await fetch(`${BASE_URL}/api/v1/docs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content: "# Small doc", + meta: { blob: "x".repeat(20_000) }, + }), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("meta"); + }); + + it("rejects non-object meta on create", async () => { + const res = await fetch(`${BASE_URL}/api/v1/docs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "# Small doc", meta: ["nope"] }), + }); + expect(res.status).toBe(400); + }); + + it("rejects oversized content on update", async () => { + const rawMagicToken = generateMagicToken(); + const doc = await prisma.doc.create({ + data: { + slug: generateSlug(), + content: "# Small", + visibility: "public", + magicToken: hashToken(rawMagicToken), + apiKey: hashToken(generateApiKey()), + }, + }); + + const res = await fetch(`${BASE_URL}/api/v1/docs/${doc.slug}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "x-magic-token": rawMagicToken, + }, + body: JSON.stringify({ content: "a".repeat(MAX_CONTENT_BYTES + 1) }), + }); + expect(res.status).toBe(400); + }); + + it("rejects oversized comment bodies", async () => { + const doc = await prisma.doc.create({ + data: { + slug: generateSlug(), + content: "# Comment target", + visibility: "public", + magicToken: hashToken(generateMagicToken()), + apiKey: hashToken(generateApiKey()), + }, + }); + + const res = await fetch(`${BASE_URL}/api/v1/docs/${doc.slug}/comments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body: "a".repeat(MAX_COMMENT_CHARS + 1) }), + }); + expect(res.status).toBe(400); + }); +}); diff --git a/src/__tests__/api/review-lifecycle.test.ts b/src/__tests__/api/review-lifecycle.test.ts index 9cf8125..4d5807d 100644 --- a/src/__tests__/api/review-lifecycle.test.ts +++ b/src/__tests__/api/review-lifecycle.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { prisma } from "@/lib/prisma"; import { generateSlug } from "@/lib/slug"; import { generateMagicToken, generateApiKey, hashToken } from "@/lib/tokens"; +import type { Prisma } from "@/generated/prisma/client"; async function createTestDoc( overrides: { @@ -9,7 +10,7 @@ async function createTestDoc( status?: string; expectedReviews?: number | null; reviewDeadline?: Date | null; - meta?: Record; + meta?: Prisma.InputJsonValue; } = {} ) { const slug = generateSlug(); diff --git a/src/__tests__/lib/validation.test.ts b/src/__tests__/lib/validation.test.ts new file mode 100644 index 0000000..bb30e32 --- /dev/null +++ b/src/__tests__/lib/validation.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { + MAX_CONTENT_BYTES, + MAX_TITLE_CHARS, + MAX_META_BYTES, + MAX_COMMENT_CHARS, + validateContent, + validateTitle, + validateVersionNote, + validateMeta, + validateCommentBody, + validateAuthor, + validateAnchorText, + validateAnchorType, +} from "@/lib/validation"; + +describe("validateContent", () => { + it("requires a non-empty string", () => { + expect(validateContent(undefined)).not.toBeNull(); + expect(validateContent("")).not.toBeNull(); + expect(validateContent(42)).not.toBeNull(); + }); + + it("accepts content at the limit and rejects one byte over", () => { + expect(validateContent("a".repeat(MAX_CONTENT_BYTES))).toBeNull(); + expect(validateContent("a".repeat(MAX_CONTENT_BYTES + 1))).not.toBeNull(); + }); + + it("counts UTF-8 bytes, not characters", () => { + // 4-byte emoji: 250_001 of them exceed 1 MB even though the string is short. + expect(validateContent("😀".repeat(250_001))).not.toBeNull(); + }); +}); + +describe("validateTitle / validateVersionNote", () => { + it("allows null and undefined", () => { + expect(validateTitle(null)).toBeNull(); + expect(validateVersionNote(undefined)).toBeNull(); + }); + + it("rejects non-strings and over-long values", () => { + expect(validateTitle(1)).not.toBeNull(); + expect(validateTitle("t".repeat(MAX_TITLE_CHARS + 1))).not.toBeNull(); + expect(validateVersionNote(1)).not.toBeNull(); + }); +}); + +describe("validateMeta", () => { + it("allows undefined and objects", () => { + expect(validateMeta(undefined)).toBeNull(); + expect(validateMeta({ agent: "claude" })).toBeNull(); + }); + + it("rejects arrays, primitives, and oversized payloads", () => { + expect(validateMeta([])).not.toBeNull(); + expect(validateMeta("meta")).not.toBeNull(); + expect(validateMeta({ blob: "x".repeat(MAX_META_BYTES) })).not.toBeNull(); + }); +}); + +describe("comment field validation", () => { + it("requires a non-empty body and enforces the cap", () => { + expect(validateCommentBody(" ")).not.toBeNull(); + expect(validateCommentBody("a".repeat(MAX_COMMENT_CHARS + 1))).not.toBeNull(); + expect(validateCommentBody("hello")).toBeNull(); + }); + + it("validates author and anchor text types/lengths", () => { + expect(validateAuthor(1)).not.toBeNull(); + expect(validateAnchorText(1)).not.toBeNull(); + expect(validateAuthor("alice")).toBeNull(); + expect(validateAnchorText("quote")).toBeNull(); + }); + + it("only accepts known anchor types", () => { + expect(validateAnchorType("line")).toBeNull(); + expect(validateAnchorType("selection")).toBeNull(); + expect(validateAnchorType("bogus")).not.toBeNull(); + }); +}); diff --git a/src/app/agents/page.tsx b/src/app/agents/page.tsx index e6abfa8..6ba0119 100644 --- a/src/app/agents/page.tsx +++ b/src/app/agents/page.tsx @@ -81,7 +81,7 @@ export default function AgentsPage() { {/* THE LOOP */}
-
// the loop
+
{"// the loop"}
00
@@ -288,7 +288,7 @@ export default function AgentsPage() { {/* FEATURES FOR AGENTS */}
-
// built for this
+
{"// built for this"}
[⚡]
@@ -392,7 +392,7 @@ export default function AgentsPage() { {/* EXAMPLE WORKFLOW */}
-
// example: claude code
+
{"// example: claude code"}
setup (once)
diff --git a/src/app/api/v1/docs/[slug]/comments/batch/route.ts b/src/app/api/v1/docs/[slug]/comments/batch/route.ts index 373737b..bdd0471 100644 --- a/src/app/api/v1/docs/[slug]/comments/batch/route.ts +++ b/src/app/api/v1/docs/[slug]/comments/batch/route.ts @@ -10,7 +10,13 @@ import { commentIdentity } from "@/lib/identity"; import { notifyOwnerOfComment } from "@/lib/notify"; import { notifyReplySubscribers } from "@/lib/subscriptions"; import { enforceRateLimit, LIMITS } from "@/lib/ratelimit"; -import { parseNullableInt } from "@/lib/validation"; +import { + parseNullableInt, + validateCommentBody, + validateAuthor, + validateAnchorText, + validateAnchorType, +} from "@/lib/validation"; type RouteContext = { params: Promise<{ slug: string }> }; @@ -53,9 +59,14 @@ export async function POST(request: NextRequest, { params }: RouteContext) { const parsed: { anchorRef: number | null; crossRefLine: number | null }[] = []; for (let i = 0; i < body.comments.length; i++) { const c = body.comments[i]; - if (!c.body || typeof c.body !== "string") { + const commentError = + validateCommentBody(c.body) ?? + validateAuthor(c.author) ?? + validateAnchorText(c.anchor_text) ?? + validateAnchorType(c.anchor_type); + if (commentError) { return NextResponse.json( - { error: `Comment at index ${i}: body is required` }, + { error: `Comment at index ${i}: ${commentError}` }, { status: 400 } ); } diff --git a/src/app/api/v1/docs/[slug]/comments/route.ts b/src/app/api/v1/docs/[slug]/comments/route.ts index b51a9af..e5a8a18 100644 --- a/src/app/api/v1/docs/[slug]/comments/route.ts +++ b/src/app/api/v1/docs/[slug]/comments/route.ts @@ -10,7 +10,13 @@ import { commentIdentity } from "@/lib/identity"; import { notifyOwnerOfComment } from "@/lib/notify"; import { createReplySubscription, notifyReplySubscribers } from "@/lib/subscriptions"; import { enforceRateLimit, LIMITS } from "@/lib/ratelimit"; -import { parseNullableInt } from "@/lib/validation"; +import { + parseNullableInt, + validateCommentBody, + validateAuthor, + validateAnchorText, + validateAnchorType, +} from "@/lib/validation"; type RouteContext = { params: Promise<{ slug: string }> }; @@ -89,11 +95,17 @@ export async function POST(request: NextRequest, { params }: RouteContext) { if (feedbackCheck) return feedbackCheck; const body = await request.json().catch(() => null); - if (!body || !body.body || typeof body.body !== "string") { - return NextResponse.json( - { error: "Comment body is required" }, - { status: 400 } - ); + if (!body) { + return NextResponse.json({ error: "Request body required" }, { status: 400 }); + } + + const validationError = + validateCommentBody(body.body) ?? + validateAuthor(body.author) ?? + validateAnchorText(body.anchor_text) ?? + validateAnchorType(body.anchor_type); + if (validationError) { + return NextResponse.json({ error: validationError }, { status: 400 }); } // anchor_ref/cross_ref_line map to Int columns — validate before the diff --git a/src/app/api/v1/docs/[slug]/route.ts b/src/app/api/v1/docs/[slug]/route.ts index 8642d8b..4605fcf 100644 --- a/src/app/api/v1/docs/[slug]/route.ts +++ b/src/app/api/v1/docs/[slug]/route.ts @@ -1,10 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -import { authorizeWithMagicToken, getAuthenticatedUser, canAccessPrivateResources } from "@/lib/auth"; -import { hashToken, generateShareToken, safeCompare } from "@/lib/tokens"; +import { + authorizeWithMagicToken, + getAuthenticatedUser, + canAccessPrivateResources, + getDocAccessForRequest, +} from "@/lib/auth"; +import { generateShareToken } from "@/lib/tokens"; import { extractTitleFromContent } from "@/lib/markdown"; import { generateSeoSlug } from "@/lib/slug"; import { buildOkfConceptDoc } from "@/lib/okf"; +import { validateContent, validateTitle, validateVersionNote } from "@/lib/validation"; export async function GET( request: NextRequest, @@ -24,41 +30,27 @@ export async function GET( return NextResponse.json({ error: "Document not found" }, { status: 404 }); } - // Determine access level: owner (magic_token) vs reviewer (api_key/public) - const tokenParam = new URL(request.url).searchParams.get("token"); - const authHeader = request.headers.get("authorization"); - const apiKey = authHeader?.startsWith("Bearer ") - ? authHeader.slice(7) - : null; - - const cookieToken = request.cookies.get(`dm_tok_${slug}`)?.value; - const isOwnerByToken = - !!(tokenParam && doc.magicToken === hashToken(tokenParam)) || - !!(cookieToken && doc.magicToken === hashToken(cookieToken)); - const hasApiKey = !!(apiKey && !apiKey.startsWith("acct_") && doc.apiKey === hashToken(apiKey)); - - // Check share token (unhashed, read-only access). - // Accept via ?share_token=, x-share-token header, or ?token=share_... (matches share page). - const shareTokenParam = new URL(request.url).searchParams.get("share_token") || - request.headers.get("x-share-token"); - const hasShareToken = - !!(shareTokenParam && doc.shareToken && safeCompare(doc.shareToken, shareTokenParam)) || - !!(tokenParam && tokenParam.startsWith("share_") && doc.shareToken && safeCompare(doc.shareToken, tokenParam)); - - // Check account ownership (session cookie or acct_ API key) - const user = await getAuthenticatedUser(request); - const isAccountOwner = !!(user && doc.userId && user.id === doc.userId); - const isOwner = isOwnerByToken || isAccountOwner; - - // Suspended account (unverified >24h) loses private doc access - if (doc.visibility === "private" && isAccountOwner && !isOwnerByToken && user && !canAccessPrivateResources(user)) { + // Owner vs reviewer access is resolved by the shared evaluator so this route + // and the feedback endpoints (and the share page) can't drift. + const { access, user } = await getDocAccessForRequest(request, doc); + const isOwner = access.owner; + + // Suspended account (unverified >24h) loses private doc access, unless the + // request also presented a valid magic token. + if ( + doc.visibility === "private" && + access.accountOwner && + !access.ownerByMagic && + user && + !canAccessPrivateResources(user) + ) { return NextResponse.json( { error: "Email verification required. Verify your email to continue accessing private documents." }, { status: 403 } ); } - if (doc.visibility === "private" && !isOwner && !hasApiKey && !hasShareToken) { + if (!access.readable) { return NextResponse.json( { error: "This document is private. Provide a valid token or API key." }, { status: 401 } @@ -165,6 +157,14 @@ export async function PATCH( const { content, title, visibility, version_note, status, expected_reviews, review_deadline } = body; + const validationError = + (content !== undefined ? validateContent(content) : null) ?? + (title !== undefined ? validateTitle(title) : null) ?? + (version_note !== undefined ? validateVersionNote(version_note) : null); + if (validationError) { + return NextResponse.json({ error: validationError }, { status: 400 }); + } + // Get current version number const latestVersion = await prisma.docVersion.findFirst({ where: { docId: ownerDoc!.id }, diff --git a/src/app/api/v1/docs/route.ts b/src/app/api/v1/docs/route.ts index 00976c5..db94d6d 100644 --- a/src/app/api/v1/docs/route.ts +++ b/src/app/api/v1/docs/route.ts @@ -5,18 +5,15 @@ import { generateMagicToken, generateApiKey, generateShareToken, hashToken } fro import { extractTitleFromContent } from "@/lib/markdown"; import { getAuthenticatedUser, canAccessPrivateResources } from "@/lib/auth"; import { enforceRateLimit, LIMITS } from "@/lib/ratelimit"; +import { validateContent, validateTitle, validateVersionNote, validateMeta } from "@/lib/validation"; export async function POST(request: NextRequest) { const limited = enforceRateLimit(request, LIMITS.createDoc.bucket, LIMITS.createDoc.limit, LIMITS.createDoc.windowMs); if (limited) return limited; const body = await request.json().catch(() => null); - - if (!body || !body.content) { - return NextResponse.json( - { error: "Content is required" }, - { status: 400 } - ); + if (!body) { + return NextResponse.json({ error: "Request body required" }, { status: 400 }); } const { @@ -29,6 +26,15 @@ export async function POST(request: NextRequest) { meta, } = body; + const validationError = + validateContent(content) ?? + validateTitle(title) ?? + validateVersionNote(version_note) ?? + validateMeta(meta); + if (validationError) { + return NextResponse.json({ error: validationError }, { status: 400 }); + } + if (visibility !== "public" && visibility !== "private") { return NextResponse.json( { error: "Visibility must be 'public' or 'private'" }, diff --git a/src/app/cli/page.tsx b/src/app/cli/page.tsx index ac4b4c7..fccf726 100644 --- a/src/app/cli/page.tsx +++ b/src/app/cli/page.tsx @@ -84,7 +84,7 @@ export default function CliPage() { {/* THE FLOW */}
-
// the flow
+
{"// the flow"}
00
@@ -202,7 +202,7 @@ export default function CliPage() { {/* BUILT FOR THE TERMINAL */}
-
// built for the terminal
+
{"// built for the terminal"}
[|]
@@ -284,7 +284,7 @@ export default function CliPage() { {/* EXAMPLE SESSION */}
-
// example: full loop
+
{"// example: full loop"}
setup (once)
diff --git a/src/app/created/[slug]/page.tsx b/src/app/created/[slug]/page.tsx index beb956b..4a6724c 100644 --- a/src/app/created/[slug]/page.tsx +++ b/src/app/created/[slug]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { useParams } from "next/navigation"; import Link from "next/link"; import Nav from "@/components/Nav"; @@ -13,25 +13,43 @@ type DocCredentials = { share_url?: string; }; +function subscribeToStorage(onChange: () => void) { + window.addEventListener("storage", onChange); + return () => window.removeEventListener("storage", onChange); +} + +function readStoredCreds(key: string): string | null { + // Try localStorage first (persistent), fall back to sessionStorage (legacy) + return localStorage.getItem(key) ?? sessionStorage.getItem(key); +} + export default function CreatedPage() { const params = useParams(); const slug = params.slug as string; - const [creds, setCreds] = useState(null); + const storageKey = `doc_${slug}`; + const stored = useSyncExternalStore( + subscribeToStorage, + () => readStoredCreds(storageKey), + () => null + ); + const creds = useMemo(() => { + if (!stored) return null; + try { + return JSON.parse(stored) as DocCredentials; + } catch { + return null; + } + }, [stored]); const [copied, setCopied] = useState(null); + // Migrate from sessionStorage to localStorage if needed. Write-only effect: + // no setState, so it can't trigger cascading renders. useEffect(() => { - // Try localStorage first (persistent), fall back to sessionStorage (legacy) - const stored = localStorage.getItem(`doc_${slug}`) || sessionStorage.getItem(`doc_${slug}`); - if (stored) { - const parsed = JSON.parse(stored); - setCreds(parsed); - // Migrate from sessionStorage to localStorage if needed - if (!localStorage.getItem(`doc_${slug}`)) { - localStorage.setItem(`doc_${slug}`, stored); - sessionStorage.removeItem(`doc_${slug}`); - } + if (stored && !localStorage.getItem(storageKey)) { + localStorage.setItem(storageKey, stored); + sessionStorage.removeItem(storageKey); } - }, [slug]); + }, [stored, storageKey]); function copyToClipboard(text: string, label: string) { navigator.clipboard.writeText(text); diff --git a/src/app/okf/page.tsx b/src/app/okf/page.tsx index 8395f89..1a1920f 100644 --- a/src/app/okf/page.tsx +++ b/src/app/okf/page.tsx @@ -95,7 +95,7 @@ export default function OkfPage() { {/* WHAT IS OKF */}
-
// what is OKF
+
{"// what is OKF"}
[md]
@@ -140,7 +140,7 @@ export default function OkfPage() { {/* HOW DRAFTMARK PRODUCES IT */}
-
// how Draftmark produces it
+
{"// how Draftmark produces it"}
00
@@ -245,7 +245,7 @@ export default function OkfPage() { {/* THE MAPPING */}
-
// the mapping
+
{"// the mapping"}
[→]
diff --git a/src/app/page.tsx b/src/app/page.tsx index 608386e..d8bccdd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -164,7 +164,7 @@ export default function Home() { {/* HOW IT WORKS */}
-
// how it works
+
{"// how it works"}
01
@@ -207,7 +207,7 @@ export default function Home() { {/* FEATURES */}
-
// what you get
+
{"// what you get"}
[md]
@@ -298,7 +298,7 @@ export default function Home() { {/* API */}
-
// api first
+
{"// api first"}
Built for
@@ -378,7 +378,7 @@ export default function Home() { {/* CLI */}
-
// cli
+
{"// cli"}
One command
@@ -459,7 +459,7 @@ export default function Home() { {/* USE CASES */}
-
// use cases
+
{"// use cases"}
share markdown online @@ -482,7 +482,7 @@ export default function Home() { {/* PRICING */}
-
// pricing
+
{"// pricing"}
free
diff --git a/src/app/prompts/page.tsx b/src/app/prompts/page.tsx index b583465..5cdefe4 100644 --- a/src/app/prompts/page.tsx +++ b/src/app/prompts/page.tsx @@ -76,7 +76,7 @@ export default function PromptsPage() { {/* THE LOOP */}
-
// the loop
+
{"// the loop"}
01
@@ -115,7 +115,7 @@ export default function PromptsPage() { {/* FEATURES */}
-
// built for prompt work
+
{"// built for prompt work"}
[#42]
@@ -171,7 +171,7 @@ export default function PromptsPage() { {/* EXAMPLE WORKFLOW */}
-
// example: agent prompt iteration
+
{"// example: agent prompt iteration"}
agent posts prompt
diff --git a/src/app/rfcs/page.tsx b/src/app/rfcs/page.tsx index b19fcb4..fa215dd 100644 --- a/src/app/rfcs/page.tsx +++ b/src/app/rfcs/page.tsx @@ -76,7 +76,7 @@ export default function RfcsPage() { {/* THE LOOP */}
-
// the loop
+
{"// the loop"}
01
@@ -125,7 +125,7 @@ export default function RfcsPage() { {/* FEATURES */}
-
// built for proposals
+
{"// built for proposals"}
[✓]
@@ -182,7 +182,7 @@ export default function RfcsPage() { {/* EXAMPLE WORKFLOW */}
-
// example: planning agent creates RFC
+
{"// example: planning agent creates RFC"}
agent creates RFC
diff --git a/src/app/share-markdown-online/page.tsx b/src/app/share-markdown-online/page.tsx index 9f30b49..badcbdc 100644 --- a/src/app/share-markdown-online/page.tsx +++ b/src/app/share-markdown-online/page.tsx @@ -132,7 +132,7 @@ export default function ShareMarkdownOnlinePage() { {/* HOW */}
-
// how it works
+
{"// how it works"}
01
@@ -166,7 +166,7 @@ export default function ShareMarkdownOnlinePage() { {/* WHY */}
-
// why draftmark
+
{"// why draftmark"}
[⚡]
@@ -224,7 +224,7 @@ export default function ShareMarkdownOnlinePage() { {/* FAQ */}
-
// faq
+
{"// faq"}
{faq.map((item) => (
diff --git a/src/app/share/[slug]/page.tsx b/src/app/share/[slug]/page.tsx index 397f354..def364d 100644 --- a/src/app/share/[slug]/page.tsx +++ b/src/app/share/[slug]/page.tsx @@ -1,6 +1,8 @@ import { notFound } from "next/navigation"; +import { cookies, headers } from "next/headers"; import { prisma } from "@/lib/prisma"; import { getSession } from "@/lib/session"; +import { evaluateDocAccess } from "@/lib/auth"; import { buildMetaDescription } from "@/lib/seo"; import Nav from "@/components/Nav"; import DocView from "@/components/DocView"; @@ -74,29 +76,37 @@ export default async function DocPage({ params, searchParams }: Props) { data: { viewsCount: { increment: 1 } }, }).catch(() => {}); - // Check access: magic token, share token, or account ownership. - // The magic token normally arrives via the httpOnly cookie set by the - // middleware token->cookie exchange (kept out of the URL); ?token= is still - // honored for the first hop / API clients. - const { hashToken, safeCompare } = await import("@/lib/tokens"); - const { cookies } = await import("next/headers"); - const cookieMagic = (await cookies()).get(`dm_tok_${slug}`)?.value; - const hasValidToken = - !!(token && doc.magicToken === hashToken(token)) || - !!(cookieMagic && doc.magicToken === hashToken(cookieMagic)); - // Share token can come via dedicated param or via token param (from prompt - // form). Stored unhashed, so compare in constant time. - const hasShareToken = - !!(share_token && doc.shareToken && safeCompare(doc.shareToken, share_token)) || - !!(token && token.startsWith("share_") && doc.shareToken && safeCompare(doc.shareToken, token)); + // Access is resolved by the same evaluator the API uses. The magic token + // normally arrives via the httpOnly cookie set by the middleware token->cookie + // exchange (kept out of the URL); ?token= is still honored for the first hop. + const cookieStore = await cookies(); + const headerStore = await headers(); + const headerMagic = headerStore.get("x-magic-token"); + const tokenParam = token ?? null; + const magicTokens = [ + headerMagic, + tokenParam, + cookieStore.get(`dm_tok_${slug}`)?.value ?? null, + ].filter((t): t is string => !!t); + const shareTokens = [ + share_token ?? null, + headerStore.get("x-share-token"), + ...[tokenParam, headerMagic].filter((t): t is string => !!t && t.startsWith("share_")), + ].filter((t): t is string => !!t); + const authHeader = headerStore.get("authorization"); + const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; const session = await getSession(); - const isAccountOwner = !!(session && doc.userId && session.userId === doc.userId); - const isOwner = hasValidToken || isAccountOwner; - const hasReadAccess = isOwner || hasShareToken; + const access = evaluateDocAccess(doc, { + magicTokens, + shareTokens, + apiKey: bearerToken && !bearerToken.startsWith("acct_") ? bearerToken : null, + userId: session?.userId ?? null, + }); + const isOwner = access.owner; // Private doc without access — show token prompt - if (doc.visibility === "private" && !hasReadAccess) { + if (!access.readable) { return ( <>