Skip to content
Merged

Dev #38

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ea07d9d
Add shared cycle-detection module in utils/
andesvel Sep 21, 2026
7544fa9
Add CLAUDE.md: project structure, verified constraints, and pending i…
andesvel Sep 21, 2026
6529d3b
Use shared cycle detection in api.links
andesvel Sep 21, 2026
01b9c27
Use shared cycle detection in redirect
andesvel Sep 21, 2026
41948c4
Add repository layer for D1 and KV data access
andesvel Sep 21, 2026
20a9454
Route api.links data access through the repository
andesvel Sep 21, 2026
b956dfc
Route redirect data access through the repository
andesvel Sep 21, 2026
7e5647b
Route Clerk webhook data access through the repository
andesvel Sep 21, 2026
998710e
Route dashboard data access through the repository
andesvel Sep 21, 2026
c124f6d
Document repository layer and cycle detection in CLAUDE.md
andesvel Sep 21, 2026
e3012f8
Add unit tests for the shared cycle-detection module
andesvel Sep 22, 2026
c325be4
Add urls repository tests
andesvel Sep 22, 2026
a9446aa
Add clicks repository tests
andesvel Sep 22, 2026
0d1c050
Add users repository tests
andesvel Sep 22, 2026
f382a32
Add url-cache repository tests
andesvel Sep 22, 2026
3fa9c98
Record phase-1 audit findings in CLAUDE.md Pending
andesvel Sep 22, 2026
86114c6
Rename CLAUDE.md to AGENTS.md and restructure as repository context
andesvel Sep 23, 2026
8013852
Merge pull request #34 from andesvel/docs/claude-md
andesvel Sep 23, 2026
7a2a5be
Merge pull request #35 from andesvel/refactor/consolidate-cycle-detec…
andesvel Sep 23, 2026
67199bc
Merge pull request #36 from andesvel/refactor/extract-repository-layer
andesvel Sep 23, 2026
8b5eee8
Merge pull request #37 from andesvel/test/cycle-detection-regression
andesvel Sep 23, 2026
c793975
Merge branch 'main' into dev
andesvel Sep 23, 2026
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
149 changes: 149 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Nuto Repository Context

## Project Overview

Nuto is a URL shortener that runs entirely on Cloudflare Workers. It's a React Router v7 app in framework mode with SSR enabled: loaders and actions are the backend, Clerk handles auth, D1 provides persistence, and KV acts as the redirect cache.

**Live site:** <https://nuto.dev>

It's not a standalone API and has no separate server. All backend logic lives inside the React Router routes, and it should stay that way.

## Tech Stack

- **Framework:** React Router v7 (framework mode, `ssr: true`)
- **Auth:** Clerk (`@clerk/react-router`)
- **Database:** Cloudflare D1
- **Cache:** Cloudflare KV (`URL_STORE`, the redirect cache)
- **UI:** Tailwind CSS with shadcn/ui (Radix)
- **Language:** TypeScript in strict mode
- **Testing:** Vitest with `@cloudflare/vitest-pool-workers` (real D1 and KV via miniflare)
- **Package manager:** pnpm (CI uses 10.14.0, there's a `pnpm-lock.yaml`). Never npm or yarn.

## Project Structure

```text
app/ # React Router v7 app (root, entry.server, css)
routes/ # Routes with loaders/actions (the project's real backend)
__tests__/ # Route integration tests (vitest-pool-workers + real D1/KV)
repository/ # Data-access layer (urls, clicks, users, KV redirect cache)
__tests__/ # Repository tests against real D1/KV
components/ # React components (landing, dashboard, links)
ui/ # shadcn/ui primitives (Radix)
links/ # Link management UI (create/edit/delete/sort)
icons/ # Brand logos
lib/ # cn() helper (tailwind-merge + clsx)
utils/ # Shared utilities (AES-GCM crypto, cycle detection,
__tests__/ # short code validation, link limits) + unit tests
workers/ # Worker entry point (workers/app.ts: fetch + scheduled)
types/ # Cloudflare Env type augmentation
public/ # Static assets
.github/workflows/ # CI (eslint + typecheck + vitest; deploy on push to main)
schema.sql # D1 schema (users, urls, clicks, settings, user_limits
# + trigger that enforces per-user link limits)
```

## Key Commands

### Development

```bash
pnpm dev # Start dev server (React Router + Cloudflare Vite plugin)
pnpm build # Production build (react-router build + wrangler build)
pnpm preview # Build and preview the production bundle
pnpm deploy # Build + wrangler deploy
```

### Code quality

```bash
pnpm run typecheck # wrangler types + react-router typegen + tsc -b
pnpm exec eslint . # Lint (CI runs this on fresh checkouts)
```

### Testing

```bash
pnpm vitest run # Full test suite (this is what CI runs)
pnpm test # Same suite through vitest directly
```

Route and repository tests run against real D1 and KV bindings via miniflare, applying `schema.sql?raw` to a fresh database in `beforeAll`.

## Git Workflow

Feature branches live in `dev`. `main` is the production branch and CI deploys to Cloudflare Workers on every push to it.

Standing rules:

- One branch per task, named by purpose: `fix/`, `refactor/`, `test/`, `docs/`.
- Base feature branches on `dev` (or on another feature branch when tasks build on each other; review stacked branches bottom-up).
- Small, atomic commits. Each commit is one related set of changes with a message that says what changed and why.
- Run the full test suite before every commit, not just at the end.
- Never open a PR, merge, or deploy anything without explicit approval.
- Never commit secrets. `.dev.vars` and `.env` are gitignored; keep it that way.

## Environment Setup

Local secrets live in `.dev.vars` (gitignored):

- `CLERK_SECRET_KEY` — Clerk backend API key
- `CLERK_WEBHOOK_SECRET` — svix secret used to verify Clerk webhooks
- `PASSCODE_ENC_KEY` — base64 32-byte key for AES-GCM link-password encryption

Non-secret vars (`VITE_CLERK_PUBLISHABLE_KEY`, Clerk redirect URLs, `MAX_LINKS_PER_USER`) are defined in `wrangler.jsonc` under `vars`.

`GITHUB_CLIENT_*` and `GOOGLE_CLIENT_*` appear in the Env type augmentation but are not referenced by any source file.

## Backend conventions

- All backend logic lives in React Router loaders and actions. Don't add a separate server framework (Express, Fastify, etc.).
- All data access goes through `app/repository/`. Never call `context.cloudflare.env.DB.prepare()` (or `URL_STORE`) directly from a loader or action.
- `workers/app.ts` stays a thin entry point: `fetch` delegates to the React Router request handler, and `scheduled` only runs the expired-links cleanup. No business logic there.
- The redirect flow is KV first, D1 on miss. Expired entries get deleted and return 410; KV hits are repopulated with a 30-day TTL.
- Every query uses `.bind()` parameters. Never concatenate user input into a SQL string.

## Security and privacy

- The `clicks` table stores only `url_id`, `clicked_at`, `country`, and `user_agent`. Never add IP storage to click analytics.
- Treat any webhook payload as untrusted until verified with svix. Verification happens before the first DB write in `app/routes/webhooks.clerk.tsx`; keep that ordering.
- Never log or expose env secrets (`PASSCODE_ENC_KEY`, `CLERK_WEBHOOK_SECRET`, etc.).
- Never change the `wrangler.jsonc` bindings (D1 `DB`, KV `URL_STORE`) without confirmation. Production data is tied to those bindings.

## Known issues (discuss before changing)

- Link password hashing is unsalted SHA-256 (`hashPassword` in api.links.tsx, verification in redirect.tsx). Hardening is on hold for compatibility with already-saved passwords. The hash is also stored in KV (`storedHash`), and its first 16 chars are the verifier for the `pw_<slug>` cookie.
- The dashboard route in `app/routes/dashboard.tsx` has a dead action (no form posts to `/dashboard`; the UI posts to `/api/links`). If invoked, it would insert links with zero validation and store the password in plaintext (unhashed).
- The `user.updated` handler in `app/routes/webhooks.clerk.tsx` binds `undefined` for `subscription_plan`. D1 rejects `undefined` in bind, so the event likely fails at runtime with a 500.
- The dashboard loader decrypts `password_enc` and returns the plaintext passwords to the client in the loader data (so users can see them). This is the current design, but it's worth confirming it's intentional.
- The update path in api.links.tsx (handleUpdate) writes KV entries without `storedHash`, while the redirect loader's password wall requires `hasPassword && storedHash`. After editing a protected link, KV hits skip the password wall and redirect without a password until the entry expires or gets repopulated from a DB fallback.
- The GET loader of `/api/links` (`?id=...`) is unused by the UI (the dashboard uses its own loader; the UI only submits POST/PUT/DELETE forms), and its response includes the stored SHA-256 `password` hash. Worth deciding whether to remove the endpoint or drop the hash from the response.
- `app/routes/webhooks.clerk.tsx` logs user emails and IDs via `console.log`, and Workers observability is enabled, so PII persists in logs.

## Important files

| File | Purpose |
| --- | --- |
| `wrangler.jsonc` | Worker config: D1 (`DB`), KV (`URL_STORE`), cron, non-secret vars. Production bindings live here. |
| `schema.sql` | D1 schema and the link-limit trigger |
| `app/routes/` | All backend logic (loaders/actions) |
| `app/repository/` | Data-access layer for D1 and KV |
| `utils/cycle-detection.ts` | Shared redirect-loop detection used by api.links and redirect |
| `utils/crypto.ts` | AES-GCM encrypt/decrypt for link passwords |
| `workers/app.ts` | Thin Worker entry point (fetch + scheduled cleanup) |
| `react-router.config.ts` | Enables SSR |
| `vitest.config.ts` | Cloudflare test pool with real D1/KV bindings |

## Build and deploy

1. `pnpm build` runs `react-router build` and `wrangler build`.
2. `pnpm deploy` builds and deploys via Wrangler.
3. CI (`.github/workflows/ci.yml`) runs eslint, typecheck, and `pnpm vitest run` on every push/PR to `main` and `dev`, then deploys to Workers on pushes to `main`.
4. The `scheduled` handler runs on the `0 0 * * *` cron and deletes expired links.

## Notes for AI assistants

- `worker-configuration.d.ts` is generated by `wrangler types`. Running typecheck regenerates it and usually leaves local diff noise from wrangler version drift; discard that diff unless the update is intentional.
- `dist/`, `build/`, and `.react-router/` are build artifacts. The eslint config doesn't ignore them, so a local `eslint .` lints them and reports thousands of pre-existing problems; lint the changed source files instead.
- Don't switch the meta-framework, auth provider, or data stores: React Router v7, Clerk, D1, and KV are fixed choices.
- Use pnpm for everything that needs a package manager.
- TypeScript strict mode is on across all tsconfig projects; keep it that way.
95 changes: 95 additions & 0 deletions app/repository/__tests__/clicks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/// <reference types="@cloudflare/vitest-pool-workers" />
import { env } from "cloudflare:test";
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
import schema from "../../../schema.sql?raw";
import { insertClick, updateLastClicked } from "../clicks";
import { insertLink } from "../urls";

describe("clicks repository", () => {
beforeAll(async () => {
const cleanSchema = schema
.replace(/--.*/g, "")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/PRAGMA[^;]*;/gi, "")
.replace(/\s+/g, " ")
.trim();

await env.DB.exec(cleanSchema);
});

beforeEach(async () => {
await env.DB.prepare("DELETE FROM urls").run();
await env.DB.prepare("DELETE FROM users").run();
await env.DB.prepare(
`INSERT INTO users (id, email, subscription_plan, created_at)
VALUES ('user_1', 'owner@nuto.com', 'free', datetime('now'))`,
).run();
});

it("insertClick stores only the privacy-safe fields (no IP)", async () => {
await insertLink(env.DB, {
id: "track-1",
longUrl: "https://example.com",
userId: "user_1",
expiresAt: null,
password: null,
passwordEnc: null,
});

await insertClick(env.DB, {
urlId: "track-1",
country: "MX",
userAgent: "UA/1",
});
await insertClick(env.DB, {
urlId: "track-1",
country: null,
userAgent: "UA/2",
});

const rows = (await env.DB
.prepare("SELECT * FROM clicks WHERE url_id = 'track-1'")
.all()) as unknown as {
results: Array<Record<string, unknown>>;
};

expect(rows.results).toHaveLength(2);
for (const row of rows.results) {
expect(Object.keys(row).sort()).toEqual([
"clicked_at",
"country",
"id",
"url_id",
"user_agent",
]);
}
expect(rows.results[0]).toMatchObject({
url_id: "track-1",
country: "MX",
user_agent: "UA/1",
});
expect(rows.results[1]).toMatchObject({
url_id: "track-1",
country: null,
user_agent: "UA/2",
});
});

it("updateLastClicked stamps the link's last_clicked", async () => {
await insertLink(env.DB, {
id: "stamp-1",
longUrl: "https://example.com",
userId: "user_1",
expiresAt: null,
password: null,
passwordEnc: null,
});

await updateLastClicked(env.DB, "stamp-1");

const row = await env.DB
.prepare("SELECT last_clicked FROM urls WHERE id = 'stamp-1'")
.first<{ last_clicked: string | null }>();
expect(row?.last_clicked).not.toBeNull();
});
});
8 changes: 8 additions & 0 deletions app/repository/__tests__/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/// <reference types="@cloudflare/vitest-pool-workers" />
/// <reference types="vite/client" />

declare module "cloudflare:test" {
export const env: Env;
export function createExecutionContext(): ExecutionContext;
export function waitOnExecutionContext(ctx: ExecutionContext): Promise<void>;
}
67 changes: 67 additions & 0 deletions app/repository/__tests__/url-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/// <reference types="@cloudflare/vitest-pool-workers" />
import { env } from "cloudflare:test";
import { describe, it, expect, beforeEach } from "vitest";
import {
getRedirectEntry,
putRedirectEntry,
deleteRedirectEntry,
} from "../url-cache";

describe("url-cache repository", () => {
beforeEach(async () => {
await env.URL_STORE.delete("cache-slug");
await env.URL_STORE.delete("legacy-slug");
});

it("putRedirectEntry and getRedirectEntry round-trip an entry with storedHash", async () => {
await putRedirectEntry(env.URL_STORE, "cache-slug", {
longUrl: "https://example.com",
hasPassword: true,
storedHash: "abc123",
});

const entry = await getRedirectEntry(env.URL_STORE, "cache-slug");
expect(entry).toEqual({
longUrl: "https://example.com",
hasPassword: true,
storedHash: "abc123",
});
});

it("getRedirectEntry returns null on a cache miss", async () => {
expect(await getRedirectEntry(env.URL_STORE, "missing")).toBeNull();
});

it("getRedirectEntry falls back to the raw string for legacy non-JSON values", async () => {
await env.URL_STORE.put("legacy-slug", "https://legacy.example.com");

const entry = await getRedirectEntry(env.URL_STORE, "legacy-slug");
expect(entry).toEqual({
longUrl: "https://legacy.example.com",
hasPassword: false,
storedHash: null,
});
});

it("getRedirectEntry falls back to the raw string for JSON without longUrl", async () => {
await env.URL_STORE.put("legacy-slug", '{"unexpected":"shape"}');

const entry = await getRedirectEntry(env.URL_STORE, "legacy-slug");
expect(entry).toEqual({
longUrl: '{"unexpected":"shape"}',
hasPassword: false,
storedHash: null,
});
});

it("deleteRedirectEntry removes the cached entry", async () => {
await putRedirectEntry(env.URL_STORE, "cache-slug", {
longUrl: "https://example.com",
hasPassword: false,
});

await deleteRedirectEntry(env.URL_STORE, "cache-slug");

expect(await getRedirectEntry(env.URL_STORE, "cache-slug")).toBeNull();
});
});
Loading
Loading