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
80 changes: 80 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
5 changes: 5 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ model Doc {
reviews Review[]
collectionDocs CollectionDoc[]

@@index([userId, updatedAt])
@@index([visibility, createdAt])
@@map("docs")
}

Expand All @@ -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")
}

Expand All @@ -108,6 +111,8 @@ model Comment {
replies Comment[] @relation("CommentReplies")
subscriptions CommentSubscription[]

@@index([docId, createdAt])
@@index([docId, status])
@@map("comments")
}

Expand Down
3 changes: 2 additions & 1 deletion src/__tests__/api/agent-dx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
meta?: Prisma.InputJsonValue;
} = {}
) {
const slug = generateSlug();
Expand Down
3 changes: 2 additions & 1 deletion src/__tests__/api/collections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -344,7 +345,7 @@ describe("Collections API", () => {
describe("GET /collections/:slug?format=okf", () => {
async function createDocWithMeta(overrides: {
visibility?: string;
meta?: Record<string, unknown>;
meta?: Prisma.InputJsonValue;
content?: string;
} = {}) {
const slug = generateSlug();
Expand Down
171 changes: 171 additions & 0 deletions src/__tests__/api/doc-access.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
} = {}
) {
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<string, string> = {}) {
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();
});
});
2 changes: 1 addition & 1 deletion src/__tests__/api/docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading