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
25 changes: 24 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,27 @@
# Copy to apps/web/.env.local for Node hosts. Cloudflare uses the D1 binding in wrangler.jsonc.
# Node hosts: copy to apps/web/.env.local. Cloudflare: put secrets in apps/web/.dev.vars locally
# and in `wrangler secret put` for deploys. The database on Cloudflare is the D1 binding in
# wrangler.jsonc.

# libsql URL of the SQLite database (Node hosts only). Relative paths resolve from apps/web.
DATABASE_URL=file:.data/gitwork.sqlite

# Better Auth. Both are required in production. Generate the secret with: openssl rand -base64 32
# The secret also encrypts stored GitHub tokens: rotating it makes every linked GitHub account
# need to be linked again.
BETTER_AUTH_SECRET=
# The public URL of this instance. Email links and the same-origin check use it.
BETTER_AUTH_URL=http://localhost:3000

# Node hosts behind a reverse proxy: the header that carries the real client IP (e.g. x-real-ip).
# Set it only if the proxy always overwrites that header. Unset = no per-IP limits; per-email
# limits still apply. Cloudflare Workers use cf-connecting-ip automatically.
TRUSTED_IP_HEADER=

# Email for sign-in codes: console (dev only) | smtp (Node) | cloudflare (Workers)
EMAIL_TRANSPORT=console
EMAIL_FROM=Gitwork <no-reply@example.com>
SMTP_URL=

# GitHub App OAuth credentials, for linking GitHub accounts. Phase 2's setup wizard replaces these.
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
12 changes: 9 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,21 @@ pnpm db:migrate # apply migrations to the local SQLite file
```
apps/web Next.js app, /api/v1 REST API, GitHub webhooks
apps/mac Native macOS app (AppKit + TextKit 2) — Phase 6, not created yet
packages/core Permissions, visibility filter, GitHub sync, attribution, markdown round trip
packages/db Drizzle schema + migrations (SQLite dialect), D1 and file drivers
packages/core Pure domain logic: role matrix (permissions.ts); later visibility, attribution, markdown
packages/server DB- and auth-aware code: Better Auth factory, services, /api/v1 router, email
packages/db Drizzle schema + migrations (SQLite dialect), D1 and file drivers, test helpers
docs/ INDEX.md → STATE.md → spec.md, decisions/
```

## Rules

- Every read and write goes through the permission and visibility checks in `packages/core`. Never
return GitHub data to a client without them.
return GitHub data to a client without them. Services load access with
`requireWorkspaceAccess()` and call `requireCapability()`.
- New `/api/v1` routes go in `packages/server/src/api/routes.ts`, with tests in
`packages/server/test/api.test.ts` (success, 401, 403, 400).
- DB tests run on both drivers: `describe.each(await allTestDrivers())` from `@gitwork/db/testing`.
- Pages read the request (`getSession()`/`connection()`) before any config, or `next build` fails.
- Webhook handlers verify the signature and are idempotent on the delivery ID.
- The markdown editors must leave untouched lines byte-identical. The round-trip fixture suite
guards this; never weaken it.
Expand Down
11 changes: 10 additions & 1 deletion apps/web/cloudflare-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types --env-interface=CloudflareEnv cloudflare-env.d.ts` (hash: fe8c79edc0ca2e1a55b87aa44ba71f28)
// Generated by Wrangler by running `wrangler types --env-interface=CloudflareEnv cloudflare-env.d.ts` (hash: 1cd12bfb9b3a0c857bfacfca3906b39f)
// Runtime types generated with workerd@1.20261012.1 2026-10-01 global_fetch_strictly_public,nodejs_compat
interface __BaseEnv_CloudflareEnv {
DB: D1Database;
EMAIL: SendEmail;
ASSETS: Fetcher;
EMAIL_TRANSPORT: "cloudflare";
EMAIL_FROM: "Gitwork <no-reply@example.com>";
WORKER_SELF_REFERENCE: Service<typeof import("./.open-next/worker").default>;
}
declare namespace Cloudflare {
Expand All @@ -13,6 +16,12 @@ declare namespace Cloudflare {
interface Env extends __BaseEnv_CloudflareEnv {}
}
interface CloudflareEnv extends __BaseEnv_CloudflareEnv {}
type StringifyValues<EnvType extends Record<string, unknown>> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
};
declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "EMAIL_TRANSPORT" | "EMAIL_FROM">> {}
}
declare module "*.txt" {
const value: string;
export default value;
Expand Down
70 changes: 70 additions & 0 deletions apps/web/e2e/identity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { type Page, expect, test } from "@playwright/test";
import { lastEmail, signInCode } from "./outbox";

async function signInWithCode(page: Page, email: string) {
await page.getByLabel("Email").fill(email);
await page.getByRole("button", { name: "Email me a sign-in code" }).click();
await page.getByLabel("Code").fill(await signInCode(email));
await page.getByRole("button", { name: "Sign in" }).click();
}

test("an owner creates a workspace and a client joins it by invite as Read Only", async ({ browser }) => {
// Owner: sign in, create the account and first workspace, invite the client.
const ownerCtx = await browser.newContext();
const owner = await ownerCtx.newPage();
await owner.goto("/");
await signInWithCode(owner, "owner@example.com");
await owner.getByLabel("Account name").fill("Acme Agency");
await owner.getByLabel("First workspace").fill("Client Website");
await owner.getByRole("button", { name: "Create" }).click();
await expect(owner.getByRole("heading", { name: "Client Website" })).toBeVisible();
await expect(owner.getByText("Your role: Owner")).toBeVisible();

await owner.getByRole("link", { name: "Members" }).click();
await owner.getByLabel("Email").fill("client@example.com");
await owner.getByLabel("Role").selectOption("read_only");
await owner.getByRole("button", { name: "Send invite" }).click();
await expect(owner.getByTestId("invite-row")).toContainText("client@example.com");

// Client: open the invite link, sign in as the invited email, accept.
const [, invitePath] = await lastEmail("client@example.com", /http:\/\/localhost:\d+(\/invite\/[A-Za-z0-9_-]+)/);
const clientCtx = await browser.newContext();
const client = await clientCtx.newPage();
await client.goto(invitePath!);
await expect(client.getByText("You are invited to Client Website as Read Only.")).toBeVisible();
await client.getByRole("link", { name: "Sign in as client@example.com to accept" }).click();
await expect(client.getByLabel("Email")).toHaveValue("client@example.com");
await signInWithCode(client, "client@example.com");
await client.getByRole("button", { name: "Accept invite" }).click();
await expect(client.getByRole("heading", { name: "Client Website" })).toBeVisible();
await expect(client.getByText("Your role: Read Only")).toBeVisible();

// Read Only sees the members but cannot invite.
await client.getByRole("link", { name: "Members" }).click();
await expect(client.getByTestId("member-row")).toHaveCount(2);
await expect(client.getByRole("button", { name: "Send invite" })).toHaveCount(0);

// The owner now sees the client as a member and no pending invite.
await owner.reload();
await expect(owner.getByTestId("member-row").filter({ hasText: "client@example.com" })).toContainText("Read Only");
await expect(owner.getByTestId("invite-row")).toHaveCount(0);

await ownerCtx.close();
await clientCtx.close();
});

test("the sign-in link signs in only after a click", async ({ page }) => {
await page.goto("/sign-in");
await page.getByLabel("Email").fill("linker@example.com");
await page.getByRole("button", { name: "Email me a sign-in code" }).click();
const [link] = await lastEmail("linker@example.com", /http:\/\/localhost:\d+\/sign-in\/verify#\S+/);
const url = new URL(link!);

await page.goto(url.pathname + url.hash);
// Loading the page must not sign in (link scanners load pages).
const session = await page.request.get("/api/auth/get-session");
expect(await session.json()).toBeNull();

await page.getByRole("button", { name: "Continue as linker@example.com" }).click();
await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible();
});
35 changes: 35 additions & 0 deletions apps/web/e2e/outbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { readFileSync } from "node:fs";
import { expect } from "@playwright/test";
import { E2E_OUTBOX } from "../playwright.config";

interface Message {
to: string;
subject: string;
text: string;
}

/** The newest email to `to` whose text matches `pattern`. Polls: sending is async. */
export async function lastEmail(to: string, pattern: RegExp): Promise<RegExpExecArray> {
let found: RegExpExecArray | null = null;
await expect
.poll(() => {
let lines: string[] = [];
try {
lines = readFileSync(E2E_OUTBOX, "utf8").trim().split("\n");
} catch {
return false;
}
const messages = lines.filter(Boolean).map((l) => JSON.parse(l) as Message);
const match = messages
.filter((m) => m.to === to)
.map((m) => pattern.exec(m.text))
.filter((m): m is RegExpExecArray => m !== null)
.at(-1);
found = match ?? null;
return found !== null;
})
.toBe(true);
return found!;
}

export const signInCode = async (email: string) => (await lastEmail(email, /sign-in code is (\d{6})/))[1]!;
5 changes: 3 additions & 2 deletions apps/web/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { expect, test } from "@playwright/test";

test("home page shows the product name", async ({ page }) => {
test("home redirects to sign-in when signed out", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Gitwork");
await expect(page).toHaveURL(/\/sign-in\?next=%2F$/);
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Sign in to Gitwork");
});

test("health endpoint reports the SQLite driver", async ({ request }) => {
Expand Down
16 changes: 8 additions & 8 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import type { NextConfig } from "next";

// Build-time driver split (decision 004). `pnpm cf:build` sets GITWORK_TARGET=cloudflare, so the
// Worker bundle gets only the D1 runtime; every other build gets only the libsql runtime. A runtime
// branch would let the bundler trace the native libsql module into the Worker.
const dbRuntime =
// Worker bundle gets only the D1 runtime; every other build gets only the libsql + nodemailer runtime. A runtime
// branch would let the bundler trace Node-only modules into the Worker.
const runtime =
process.env.GITWORK_TARGET === "cloudflare"
? "./src/server/db/runtime.workerd.ts"
: "./src/server/db/runtime.node.ts";
? "./src/server/runtime/runtime.workerd.ts"
: "./src/server/runtime/runtime.node.ts";

const nextConfig: NextConfig = {
// cacheComponents stays off: under workerd its setTimeout dependency hangs every request.
reactCompiler: true,
transpilePackages: ["@gitwork/core", "@gitwork/db"],
serverExternalPackages: ["@libsql/client", "libsql"],
transpilePackages: ["@gitwork/core", "@gitwork/db", "@gitwork/server"],
serverExternalPackages: ["@libsql/client", "libsql", "nodemailer"],
turbopack: {
resolveAlias: {
"@gitwork/db-runtime": dbRuntime,
"@gitwork/runtime": runtime,
},
rules: {
"*.css": {
Expand Down
5 changes: 4 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,15 @@
"dependencies": {
"@gitwork/core": "workspace:*",
"@gitwork/db": "workspace:*",
"@gitwork/server": "workspace:*",
"@opennextjs/cloudflare": "1.20.11",
"better-auth": "1.7.8",
"drizzle-orm": "0.45.4",
"next": "16.3.8",
"react": "19.3.0",
"react-dom": "19.3.0",
"server-only": "^0.0.1"
"server-only": "^0.0.1",
"zod": "4.6.7"
},
"devDependencies": {
"@playwright/test": "1.64.1",
Expand Down
15 changes: 12 additions & 3 deletions apps/web/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
import { defineConfig, devices } from "@playwright/test";

const PORT = 3124;
export const E2E_OUTBOX = ".data/e2e-outbox.jsonl";

// E2E against the Node runtime. scripts/smoke.sh covers the Worker runtime.
// E2E against the Node runtime with a fresh database. scripts/smoke.sh covers the Worker runtime.
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? "github" : "list",
use: { baseURL: `http://localhost:${PORT}`, trace: "on-first-retry" },
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: `pnpm db:migrate && pnpm build && pnpm start --port ${PORT}`,
command: `rm -f .data/e2e.sqlite* ${E2E_OUTBOX} && pnpm db:migrate && pnpm build && pnpm start --port ${PORT}`,
url: `http://localhost:${PORT}/api/v1/health`,
timeout: 240_000,
reuseExistingServer: false,
env: {
DATABASE_URL: "file:.data/e2e.sqlite",
EMAIL_TRANSPORT: "outbox",
EMAIL_OUTBOX_PATH: E2E_OUTBOX,
BETTER_AUTH_SECRET: "e2e-secret-e2e-secret-e2e-secret-e2e",
BETTER_AUTH_URL: `http://localhost:${PORT}`,
},
},
});
7 changes: 7 additions & 0 deletions apps/web/src/app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { getDeps } from "@/server/context";

// Better Auth's endpoints: email codes, sessions, GitHub linking.
const handle = (request: Request) => getDeps().auth.handler(request);

export const GET = handle;
export const POST = handle;
10 changes: 10 additions & 0 deletions apps/web/src/app/api/v1/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { handleApiRequest } from "@gitwork/server";
import { getDeps } from "@/server/context";

// Every /api/v1 route lives in @gitwork/server's router (decision 015).
const handle = (request: Request) => handleApiRequest(request, getDeps());

export const GET = handle;
export const POST = handle;
export const PATCH = handle;
export const DELETE = handle;
2 changes: 1 addition & 1 deletion apps/web/src/app/api/v1/health/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { APP_NAME } from "@gitwork/core";
import { instanceSettings } from "@gitwork/db";
import { count } from "drizzle-orm";
import { getDatabase } from "@/server/db";
import { getDatabase } from "@/server/runtime";

/** Liveness plus a database round trip. Reports which driver served the request. */
export async function GET() {
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/app/invite/[token]/accept-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button, ErrorText } from "@/components/ui";
import { api } from "@/lib/api";

export function AcceptButton({ token }: { token: string }) {
const router = useRouter();
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
return (
<div className="space-y-3">
<Button
className="w-full"
disabled={busy}
onClick={async () => {
setBusy(true);
try {
const { workspaceId } = await api<{ workspaceId: string }>("POST", `/invites/${encodeURIComponent(token)}/accept`);
router.push(`/w/${workspaceId}`);
router.refresh();
} catch (err) {
setError((err as Error).message);
setBusy(false);
}
}}
>
Accept invite
</Button>
<ErrorText>{error}</ErrorText>
</div>
);
}
Loading
Loading