Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Change Link

A small URL shortener built for Cloudflare Workers.

Stack

  • Next.js 16 and React 19
  • Tailwind CSS 4 and shadcn/ui (Base UI)
  • OpenNext for Cloudflare Workers
  • Cloudflare D1 as the source of truth
  • Cloudflare KV as a disposable redirect cache
  • Drizzle ORM for schema and query types
  • Wrangler for local bindings and migration execution

Architecture

The App Router is kept as a thin transport layer. D1 table declarations are centralized under src/db/schema, while feature modules own domain policy, application behavior, and feature-specific UI.

src/
├── app/                         # Next.js pages and route adapters
├── components/                  # shared application shell and shadcn/ui
├── db/
│   ├── client.ts               # shared Drizzle D1 client
│   ├── schema-helpers.ts       # shared column helpers
│   └── schema/                 # users, sessions, and links tables
└── features/
    ├── auth/                    # credential policy, D1 sessions, auth UI
    └── links/
        ├── components/          # link-owned dashboard and form UI
        ├── domain/              # slug, URL, validation, and cache policy
        └── server/              # D1/KV queries, mutations, and redirect behavior

All active users currently manage one global link collection. Link creator and updater fields are audit data, not ownership. A future workspace feature can add workspaces, workspace_members, and links.workspace_id together and backfill the existing links into a default workspace.

The initial D1 model contains:

  • users: application-managed administrator identities
  • sessions: revocable, expiring login sessions; raw session secrets are never stored
  • links: case-sensitive short-link back-halves with active, disabled, and archived states

Archived slugs remain reserved and cannot be reused. KV stores only disposable active-link cache entries; D1 remains the source of truth. The administration list uses D1-backed search and pagination instead of loading only a fixed recent subset into the browser.

See docs/data-model.md for constraints, relationships, cache semantics, and the future workspace migration plan.

Local development

Node.js 20.9 or newer is required. Local development uses Wrangler's local D1 and KV implementations, so a Cloudflare login or provisioned remote resources are not required.

First-time setup

npm install
cp .dev.vars.example .dev.vars
npm run cf:typegen
npm run db:migrate:local
npm run db:seed:local
npm run dev

Only copy .dev.vars.example when .dev.vars does not already exist. It contains a local-only ADMIN_SETUP_TOKEN; replace it with a private value if the local server is reachable by anyone else. The seed is safe to run repeatedly: it updates the same local records instead of creating duplicates.

With the development server running, verify:

The seed creates a disabled local-only administrator record. It is audit data for the demo link and cannot be used to sign in. The first administrator created through /setup can manage that global link collection. Once one active user exists, /setup no longer accepts another initial administrator.

Local D1 and KV data is stored in .wrangler/state and is isolated from remote Cloudflare resources.

Daily development

After the first-time setup, start the application with:

npm run dev

Run npm run db:migrate:local again whenever new committed migrations are added. Stop the server with Ctrl+C.

Database lifecycle

Edit the table declaration under src/db/schema and export new tables from src/db/schema/index.ts. Then generate and apply a reviewed SQL migration.

npm run db:generate
npm run db:migrate:local

Production migrations are applied explicitly:

npm run db:migrate:remote

Do not use drizzle-kit push against production. Migration SQL is committed to Git and Wrangler owns migration application history.

Authentication and redirect lifecycle

Passwords are stored as salted PBKDF2-HMAC-SHA256 hashes. A browser receives an HttpOnly, SameSite=Lax session cookie containing a random session ID and secret; D1 stores only the SHA-256 digest of that secret. Disabled users, expired sessions, and revoked sessions cannot enter /admin, and every Server Action performs its own D1-backed authorization check. A successful login also removes expired and revoked session rows so local authentication data does not grow without bound.

ADMIN_SETUP_TOKEN protects the one-time /setup route from being claimed by the first public visitor after deployment. Wrangler declares it for generated binding types and local missing-secret warnings, while the setup action fails closed when the binding is absent. Set the production Secret before opening the setup route. Do not put its value in wrangler.jsonc, source code, or Git.

Before exposing /login and /setup on a public production domain, provision a Cloudflare Turnstile widget for that hostname and connect server-side token validation. The widget and its managed verification Worker are external Cloudflare resources, so they are intentionally not represented by placeholder keys in this repository. Client-side validation alone is not sufficient; follow the Turnstile server-side validation guide.

Link mutations write to D1 and then attempt to delete link:{slug} from KV. Cache failures are logged but do not turn a committed D1 mutation or a valid D1-backed redirect into an application error. Cache reads use a 30-second edge TTL, and stored values expire after five minutes to bound the stale value that a concurrent cache miss can reintroduce. KV remains eventually consistent across locations. Disabled and archived links return 404 after cache convergence; restored active links redirect again. Public /{case-sensitive-slug} requests use a temporary 302 because administrators can change their destinations.

Cloudflare runtime verification

Daily development uses the fast Next.js server. Before deployment, verify the Worker build in the local workerd runtime:

npm run check
npm run preview

Open the local URL printed by the preview command. This builds and runs the OpenNext Worker and is closer to the production Cloudflare runtime than npm run dev; it still uses local bindings unless a binding is explicitly configured as remote.

The D1 and KV bindings intentionally omit remote resource IDs for local development. Before production automation, create the resources explicitly and copy the returned IDs into wrangler.jsonc; this allows migrations to run before the schema-dependent Worker is deployed.

For a first production deployment, use a high-entropy setup token and enter it interactively when prompted:

npm run test:all
npx wrangler d1 create change-link-db
npx wrangler kv namespace create LINK_CACHE
npm run db:migrate:remote
npm run deploy
npx wrangler secret put ADMIN_SETUP_TOKEN

Copy the D1 database_id and KV namespace id printed by the create commands into their existing bindings in wrangler.jsonc, then regenerate bindings with npm run cf:typegen. Do not visit /setup until the remote migrations and Worker deployment are complete and the setup Secret has been set. For CI/CD, pin those resource IDs and run db:migrate:remote as an explicit step before deploying a schema-dependent version. Use expand/migrate/contract migrations when an old and new Worker need to overlap during a rollout.

After migration, open the deployed /setup URL once, create the administrator, then use /login. Keeping the setup Secret configured is safe because the application refuses initial setup whenever an active administrator exists; it also keeps future deployments compatible with the required-secret declaration.

Testing

The test layers intentionally cover different failure modes:

  • npm test runs fast Vitest unit tests for auth crypto/validation and link domain policy.
  • npm run test:integration builds the OpenNext Worker, starts Cloudflare's local Test Harness, applies D1 migrations, seeds D1, and verifies D1/KV behavior.
  • npm run test:e2e builds the same Worker and drives it through headless Chromium with Playwright.
  • npm run test:all runs lint, type checking, unit, integration, and E2E tests.

Install Chromium once before the first E2E run:

npm run test:e2e:install

Useful interactive commands are npm run test:watch, npm run test:e2e:headed, and npm run test:e2e:ui.

Integration and E2E tests recreate isolated local D1 and KV storage for every test, apply the committed migrations, and seed only the data each test needs. They do not read or mutate the local development state under .wrangler/state, and they never access remote Cloudflare resources. These same commands can be placed in CI later without changing the test architecture.

About

[Project] change link.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Used by

Contributors

Languages