Vibecode ReSlug
track this build4 phases, 12 steps, beginner friendly0%The redirect is a slug, a lookup, a 302, and a row in a table · Cloudflare Workers with KV and D1 does the whole job on the free tier, and the QR code is one library call. Routing rules, expiry, and password links are an afternoon more. What you cannot build in a sitting is permanence: your links only outlive the project if you keep renewing the domain and the account. The wrinkle with ReSlug specifically is that its free plan already includes a custom domain and real analytics at personal scale, so the DIY build is competing with 0 USD rather than 9 · build it for the ownership, not the savings.
You are building a lean indie version of ReSlug. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # ReSlug · indie build A shortener whose hard problems are the free tier's arithmetic deciding where every byte is stored, an eventually consistent lookup that can serve a stale miss, and a redirect status code that determines whether you can ever change a link again. Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A Cloudflare Worker on a hostname you own, deployed with wrangler | the redirect has to be near the click and this tier costs nothing at personal scale | | Lookup | A key-value store for slug to destination, with the database behind it as the authority | key-value is read-optimised and rarely written, which is the shape of a link table | | Click log | One database row per click, written inside waitUntil after the response has gone | the redirect must never wait on a write, and waitUntil is what that costs you nothing | | Redirect | 302 with an explicit no-store header, never 301 | a permanent redirect gets cached and then you can neither retarget the link nor count it | | Admin | One password held as a deployment secret, on a separate path | one password is honest for one person and a login system is not the product | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Cloudflare account with Workers, a key-value namespace and a database** · free at personal scale - Why: This is where the whole thing runs, and the free tier's numbers are what the design is shaped around. - Get it: Sign up, create the namespace and the database, and note the current published limits. - Verify: wrangler whoami succeeds and both bindings appear in wrangler.toml - [ ] **The wrangler command line tool** · free - Why: Deploys the worker, manages bindings and holds the secrets that must never enter the configuration file. - Get it: Install it with your package manager of choice. - Verify: npx wrangler --version prints a version and npx wrangler dev serves locally - [ ] **A domain you intend to keep renewing** · roughly ten to fifteen pounds a year, indefinitely - Why: Every short link you print, paste or put on a poster stops working the day this domain lapses, which makes the renewal decision the most consequential part of the project. - Get it: Register or move a domain onto the same account and add the hostname route. - Verify: A request to the short hostname reaches your worker - [ ] **The current published free tier limits, written down with a date** · free - Why: Reads per day, writes per day, rows read, rows written and the per-invocation processing budget all decide this design, and all of them change. - Get it: Read the platform's own limits and pricing pages and copy the figures into the README. - Verify: Every figure in the README has a source URL and a checked date - [ ] **A second network to test from** · free - Why: The eventually consistent lookup only misbehaves when the request arrives somewhere your write has not reached, which you cannot see from one machine. - Get it: A phone on mobile data, or a shell on a host somewhere else. - Verify: A newly created link resolves from it within the window you documented ## Quick start ```sh npx wrangler dev npx wrangler deploy ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Link-in-bio pages. - Teams and shared workspaces. - A public API with tokens. - Per-visitor identifiers or cross-site tracking. - links that outlive your own infrastructure · a domain you stop renewing takes every link with it - bot filtering that actually works · the heuristics take iteration you will not do for one project - geo and device routing rules, scheduled links, and click caps behind a dashboard instead of a config file - bio pages and branded QR codes - a REST API and webhooks other tools can call If one of those is essential to you, that is the reason to keep paying for ReSlug, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · ReSlug The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a link shortener on a domain you own that redirects in one hop and logs the click without the redirect ever waiting for it to replace ReSlug. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: A Cloudflare Worker on a hostname you own, deployed with wrangler · the redirect has to be near the click and this tier costs nothing at personal scale - Lookup: A key-value store for slug to destination, with the database behind it as the authority · key-value is read-optimised and rarely written, which is the shape of a link table - Click log: One database row per click, written inside waitUntil after the response has gone · the redirect must never wait on a write, and waitUntil is what that costs you nothing - Redirect: 302 with an explicit no-store header, never 301 · a permanent redirect gets cached and then you can neither retarget the link nor count it - Admin: One password held as a deployment secret, on a separate path · one password is honest for one person and a login system is not the product ### Data model (create this before Phase 1) - links(slug PRIMARY KEY, destination, created_at, expires_at, click_cap, password_hash, fallback_url, note) · in the database, which is the authority - rules(id, slug, match_kind, match_value, destination) · country or device overrides, evaluated before the default destination - clicks(id, slug, at, country, region, device_class, referer_host, ua_hash, bot_label) · one row per click, and only the referring host - daily(slug, day, clicks, bots, PRIMARY KEY (slug, day)) · a rollup written by a scheduled job, which is what the dashboard reads - The key-value entry for a slug is one small JSON blob holding the destination and its rules, so a redirect is one lookup and zero database queries on the hot path. - No counter lives in the key-value store. The free tier allows one write per second to a single key and a low four-figure number of writes a day across all keys, which one popular link would exhaust before lunch. Check the current published figures before you design around them. - clicks keeps a hash of the user agent and the host part of the referrer, not the full string and not the query. Enough to split devices and sources, not enough to become a tracking database. ### Phase 1 · The redirect, with nothing on the hot path Build: Route the hostname to the worker. Read the slug from the key-value store, return a 302 with a no-store header, and write the click row inside waitUntil. On a lookup miss, read the database, repopulate the key-value entry, and only return a not-found when the database also has nothing. Trap: The key-value store is eventually consistent and its own documentation says changes may take up to sixty seconds or more to be visible at other locations as their cached copies time out, and it caches negative lookups too, so a slug that somebody hit one second before you created it can keep returning not-found in that region afterwards; treat a miss as a database read plus a repopulate rather than as a not-found, and check the current default cache duration before you decide how long that window is. Done when: A link created in one place resolves from a different network within the stated window, tested by hitting it from two locations. A lookup miss falls through to the database and repopulates the key-value entry, tested by deleting the entry directly. The response is a 302 carrying a no-store header and is never a 301, asserted in a test that checks the status and the header. The click row is written after the response, verified by timing the redirect with the database write stubbed to be slow. Do not build yet: No analytics, rules, expiry, QR codes or admin page yet. ### Phase 2 · A click log that fits inside the free tier Build: Insert one click row per redirect with the country from the request metadata, a device class, the referrer's host only and a hash of the user agent. Add a scheduled job that folds yesterday into the daily table. Index clicks on slug and time, and make the dashboard read daily. Trap: A dashboard that runs a count over the clicks table charges you for every row it scans rather than every row it returns, which on this database's free tier is metered as rows read per day in the millions and is trivially exhausted by one unindexed count over a busy month; index on slug and time, read the daily rollup for anything above a single link, and check both the rows-read and rows-written daily allowances now, because the rows-written figure is the real ceiling on how many clicks this design can record in a day. Done when: The dashboard makes no query that scans the clicks table, asserted by reviewing every statement and by an explain on each. The scheduled rollup is idempotent: running it twice for the same day leaves the same daily row, tested. Click rows carry a user agent hash and a referrer host, and a test fails if a full user agent string or a query string reaches the database. The README records the current daily rows-written allowance, the date checked, and what this build does when it is reached. Do not build yet: No routing rules, caps, passwords or QR codes yet. ### Phase 3 · Rules, expiry, caps and passwords Build: Put the rules in the same key-value blob as the destination so the hot path stays one lookup. Evaluate country and device rules before the default. Expire on a stored date. Enforce the click cap from the rollup. A password link serves a small form and then redirects. Trap: Enforcing a click cap by counting rows on every request puts a database query on the hot path and still gives you a stale answer, because the clicks are written asynchronously and the count lags; enforce the cap from the rolled-up figure the scheduled job maintains, accept that a capped link overshoots by whatever arrives between rollups, and write that overshoot into the README rather than implying the cap is exact. Done when: A country rule redirects to its own destination and the default is used when no rule matches, tested with a forged request metadata fixture. An expired link goes to the fallback URL rather than returning an error, tested at the boundary second. The click cap is enforced without a database read on the redirect path, asserted by counting queries in the test. A password link serves the form over the short domain and never puts the destination in the page source before the password is correct. Do not build yet: No QR codes, admin page, bot labelling or export yet. ### Phase 4 · QR codes and one admin page Build: Serve a PNG QR code for a slug from a cached response with a strong validator, generated once and reused. Put the admin page behind one deployment secret with a constant-time comparison, listing links with clicks per day, top countries, top referrer hosts and the device split. Trap: Generating the QR image on every request spends the worker's small per-invocation processing budget on work whose answer never changes, and the free tier gives you single-digit milliseconds of it; render once, store it, serve it from cache with a validator, and remember that a QR code printed on a poster is the most permanent thing this project will produce, so it must point at the short link and the slug behind it must never be reassigned to something else. Done when: A repeated QR request is served from cache and does not regenerate, verified by logging generation calls. The admin password is compared in constant time and a wrong password reveals nothing about the link set. The admin page reads only the daily rollup and per-link indexes, asserted by counting scanned rows. Deleting a link marks the slug as retired so it can never be reassigned, tested by attempting to recreate it. Do not build yet: No bot labelling, export or migration path yet. ### Phase 5 · Bots, export and the domain that outlives this Build: Label a click as probable bot rather than dropping it, and default the dashboard to excluding labelled clicks with a toggle. Export links, rules and clicks as CSV. Document the move to another host, and state plainly that these links die the day the domain lapses. Trap: Dropping clicks whose user agent looks like a bot means you can never audit the rule that dropped them, and a slightly wrong pattern quietly deletes real people on old or unusual browsers while you congratulate yourself on clean numbers; store a label on every click, exclude labelled clicks by default, keep the toggle, and treat the labelling rules as a thing you tune with evidence rather than a filter you trust. Done when: No click is ever deleted by the bot rule, only labelled, asserted by a test that counts rows with the rule on and off. The dashboard excludes labelled clicks by default and the toggle shows them. Export produces links, rules and clicks as CSV and reimports into an empty database with the same row counts, tested. The README states that every link stops working when the domain lapses, and names the renewal date. Do not build yet: Nothing after this. ### Out of scope (and why) - Link-in-bio pages, which are a small site builder wearing a shortener's clothes and belong in a different project. - Teams, roles and shared workspaces, which need accounts, and accounts are the thing this build deliberately does not have. - A public API with tokens, because the moment other tools can create links you have a spam problem and a moderation obligation. - Per-visitor identifiers or cross-site tracking, which is the line between analytics you can justify and a surveillance product. ### README must contain - That the database is the authority and the key-value store is a cache, including what happens on a miss - That the redirect is a 302 on purpose, and why a 301 would make a link permanently unchangeable - That the free tier's daily rows-written allowance is the real ceiling on click volume, with the figure and the date you checked it - That the click cap is enforced from a rollup and therefore approximate, with the size of the overshoot - That every link stops working when the domain lapses, with the renewal date written down ===== AGENTS.md ===== # Agent instructions · ReSlug indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A Cloudflare Worker on a hostname you own, deployed with wrangler, A key-value store for slug to destination, with the database behind it as the authority, One database row per click, written inside waitUntil after the response has gone, 302 with an explicit no-store header, never 301, One password held as a deployment secret, on a separate path. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - The key-value store is eventually consistent and its own documentation says changes may take up to sixty seconds or more to be visible at other locations as their cached copies time out, and it caches negative lookups too, so a slug that somebody hit one second before you created it can keep returning not-found in that region afterwards; treat a miss as a database read plus a repopulate rather than as a not-found, and check the current default cache duration before you decide how long that window is. - A dashboard that runs a count over the clicks table charges you for every row it scans rather than every row it returns, which on this database's free tier is metered as rows read per day in the millions and is trivially exhausted by one unindexed count over a busy month; index on slug and time, read the daily rollup for anything above a single link, and check both the rows-read and rows-written daily allowances now, because the rows-written figure is the real ceiling on how many clicks this design can record in a day. - Enforcing a click cap by counting rows on every request puts a database query on the hot path and still gives you a stale answer, because the clicks are written asynchronously and the count lags; enforce the cap from the rolled-up figure the scheduled job maintains, accept that a capped link overshoots by whatever arrives between rollups, and write that overshoot into the README rather than implying the cap is exact. - Generating the QR image on every request spends the worker's small per-invocation processing budget on work whose answer never changes, and the free tier gives you single-digit milliseconds of it; render once, store it, serve it from cache with a validator, and remember that a QR code printed on a poster is the most permanent thing this project will produce, so it must point at the short link and the slug behind it must never be reassigned to something else. ===== BUILD_PLAN.md ===== # Build plan · ReSlug A shortener whose hard problems are the free tier's arithmetic deciding where every byte is stored, an eventually consistent lookup that can serve a stale miss, and a redirect status code that determines whether you can ever change a link again. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · The redirect, with nothing on the hot path A hit on a short link resolves and redirects before any write happens, and a fresh link works everywhere. ### Steps 1. Return the redirect before writing anything waitUntil keeps the write alive after the response has gone. Files: `src/worker.ts`, `wrangler.toml` ```sh npx wrangler dev npx wrangler deploy ``` 2. Treat a key-value miss as a database read, not a 404 Negative lookups are cached and will outlive your mistake. Files: `src/lookup.ts`, `tests/miss-fallthrough.test.ts` 3. Use 302 and say no-store out loud A 301 is cacheable by default and is forever in somebody's browser. Files: `src/worker.ts`, `tests/status-code.test.ts` ### Done when - [ ] A new link resolves from a second network - [ ] A store miss falls through and repopulates - [ ] The status is 302 with no-store, never 301 - [ ] The redirect returns before the click is written ### Watch out - The key-value store is eventually consistent and its own documentation says changes may take up to sixty seconds or more to be visible at other locations as their cached copies time out, and it caches negative lookups too, so a slug that somebody hit one second before you created it can keep returning not-found in that region afterwards; treat a miss as a database read plus a repopulate rather than as a not-found, and check the current default cache duration before you decide how long that window is. ## Phase 2 · A click log that fits inside the free tier Every click is recorded, and the dashboard reads a rollup rather than scanning the log. ### Steps 1. Index on slug and time before writing the dashboard Otherwise every chart is a table scan you are billed for. Files: `migrations/0001_clicks.sql` 2. Roll up daily on a schedule, idempotently Running it twice must not double yesterday. Files: `src/rollup.ts`, `tests/rollup-idempotent.test.ts` 3. Store a hash and a host, not the string and the URL You want device split, not a record of where people came from in detail. Files: `src/click.ts`, `tests/no-pii.test.ts` ### Done when - [ ] No dashboard query scans the clicks table - [ ] The rollup is idempotent - [ ] No full user agent or query string is stored - [ ] The written-rows allowance is recorded with a date ### Watch out - A dashboard that runs a count over the clicks table charges you for every row it scans rather than every row it returns, which on this database's free tier is metered as rows read per day in the millions and is trivially exhausted by one unindexed count over a busy month; index on slug and time, read the daily rollup for anything above a single link, and check both the rows-read and rows-written daily allowances now, because the rows-written figure is the real ceiling on how many clicks this design can record in a day. ## Phase 3 · Rules, expiry, caps and passwords A link can retarget by country or device, expire, cap out or ask for a password, all decided without a database read. ### Steps 1. Keep rules inside the key-value blob One lookup for the whole decision, or the design is pointless. Files: `src/rules.ts`, `tests/rules.test.ts` 2. Cap from the rollup and document the overshoot An honest approximate cap beats a hot-path query. Files: `src/cap.ts`, `README.md` 3. Never send the destination before the password checks A view-source bypass makes the password decorative. Files: `src/password.ts`, `tests/password-leak.test.ts` ### Done when - [ ] Country and device rules beat the default - [ ] An expired link uses the fallback - [ ] The cap costs no database read on the hot path - [ ] The destination is absent until the password is right ### Watch out - Enforcing a click cap by counting rows on every request puts a database query on the hot path and still gives you a stale answer, because the clicks are written asynchronously and the count lags; enforce the cap from the rolled-up figure the scheduled job maintains, accept that a capped link overshoots by whatever arrives between rollups, and write that overshoot into the README rather than implying the cap is exact. ## Phase 4 · QR codes and one admin page Any link has a cached QR image and a single password-protected page shows what happened. ### Steps 1. Generate the QR once and cache the bytes The processing budget on this tier is measured in milliseconds. Files: `src/qr.ts` 2. Compare the admin password in constant time A timing side channel on one password is still a side channel. Files: `src/admin.ts` 3. Retire slugs instead of freeing them Somewhere there is a poster with that slug on it. Files: `migrations/0002_retired.sql`, `tests/no-reuse.test.ts` ### Done when - [ ] Repeat QR requests do not regenerate - [ ] Password comparison is constant time - [ ] Admin reads rollups, not the raw log - [ ] A deleted slug cannot be reused ### Watch out - Generating the QR image on every request spends the worker's small per-invocation processing budget on work whose answer never changes, and the free tier gives you single-digit milliseconds of it; render once, store it, serve it from cache with a validator, and remember that a QR code printed on a poster is the most permanent thing this project will produce, so it must point at the short link and the slug behind it must never be reassigned to something else. ## Not in this build - Link-in-bio pages. - Teams and shared workspaces. - A public API with tokens. - Per-visitor identifiers or cross-site tracking. ## After v1, if you want it - A scheduled export to storage you own, so the links table survives losing the account entirely - A second hostname for a different context, which mostly tests whether the slug namespace was ever per-domain - Bot labelling tuned against a week of real traffic, with the before and after counts written down ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Set with wrangler secret, never in wrangler.toml. The only credential here. ADMIN_PASSWORD=a-long-random-string-redacted # Required. The hostname routed to the worker. Appears in every QR code you print. SHORT_HOSTNAME=go.example.com # Required. Where an expired or capped link goes when it has no fallback of its own. DEFAULT_FALLBACK_URL=https://example.com/expired # Required. How long a lookup is cached at the edge. Longer is faster and staler. KV_CACHE_TTL=60 # Required. Random slug length. Longer costs nothing and collides less. SLUG_LENGTH=6 # Required. After this, raw clicks are deleted and the daily rollup remains. RETENTION_DAYS=400
You are building a lean indie version of ReSlug. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # ReSlug · indie build A shortener whose hard problems are the free tier's arithmetic deciding where every byte is stored, an eventually consistent lookup that can serve a stale miss, and a redirect status code that determines whether you can ever change a link again. Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A Cloudflare Worker on a hostname you own, deployed with wrangler | the redirect has to be near the click and this tier costs nothing at personal scale | | Lookup | A key-value store for slug to destination, with the database behind it as the authority | key-value is read-optimised and rarely written, which is the shape of a link table | | Click log | One database row per click, written inside waitUntil after the response has gone | the redirect must never wait on a write, and waitUntil is what that costs you nothing | | Redirect | 302 with an explicit no-store header, never 301 | a permanent redirect gets cached and then you can neither retarget the link nor count it | | Admin | One password held as a deployment secret, on a separate path | one password is honest for one person and a login system is not the product | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Cloudflare account with Workers, a key-value namespace and a database** · free at personal scale - Why: This is where the whole thing runs, and the free tier's numbers are what the design is shaped around. - Get it: Sign up, create the namespace and the database, and note the current published limits. - Verify: wrangler whoami succeeds and both bindings appear in wrangler.toml - [ ] **The wrangler command line tool** · free - Why: Deploys the worker, manages bindings and holds the secrets that must never enter the configuration file. - Get it: Install it with your package manager of choice. - Verify: npx wrangler --version prints a version and npx wrangler dev serves locally - [ ] **A domain you intend to keep renewing** · roughly ten to fifteen pounds a year, indefinitely - Why: Every short link you print, paste or put on a poster stops working the day this domain lapses, which makes the renewal decision the most consequential part of the project. - Get it: Register or move a domain onto the same account and add the hostname route. - Verify: A request to the short hostname reaches your worker - [ ] **The current published free tier limits, written down with a date** · free - Why: Reads per day, writes per day, rows read, rows written and the per-invocation processing budget all decide this design, and all of them change. - Get it: Read the platform's own limits and pricing pages and copy the figures into the README. - Verify: Every figure in the README has a source URL and a checked date - [ ] **A second network to test from** · free - Why: The eventually consistent lookup only misbehaves when the request arrives somewhere your write has not reached, which you cannot see from one machine. - Get it: A phone on mobile data, or a shell on a host somewhere else. - Verify: A newly created link resolves from it within the window you documented ## Quick start ```sh npx wrangler dev npx wrangler deploy ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Link-in-bio pages. - Teams and shared workspaces. - A public API with tokens. - Per-visitor identifiers or cross-site tracking. - links that outlive your own infrastructure · a domain you stop renewing takes every link with it - bot filtering that actually works · the heuristics take iteration you will not do for one project - geo and device routing rules, scheduled links, and click caps behind a dashboard instead of a config file - bio pages and branded QR codes - a REST API and webhooks other tools can call If one of those is essential to you, that is the reason to keep paying for ReSlug, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · ReSlug The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a link shortener on a domain you own that redirects in one hop and logs the click without the redirect ever waiting for it to replace ReSlug. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: A Cloudflare Worker on a hostname you own, deployed with wrangler · the redirect has to be near the click and this tier costs nothing at personal scale - Lookup: A key-value store for slug to destination, with the database behind it as the authority · key-value is read-optimised and rarely written, which is the shape of a link table - Click log: One database row per click, written inside waitUntil after the response has gone · the redirect must never wait on a write, and waitUntil is what that costs you nothing - Redirect: 302 with an explicit no-store header, never 301 · a permanent redirect gets cached and then you can neither retarget the link nor count it - Admin: One password held as a deployment secret, on a separate path · one password is honest for one person and a login system is not the product ### Data model (create this before Phase 1) - links(slug PRIMARY KEY, destination, created_at, expires_at, click_cap, password_hash, fallback_url, note) · in the database, which is the authority - rules(id, slug, match_kind, match_value, destination) · country or device overrides, evaluated before the default destination - clicks(id, slug, at, country, region, device_class, referer_host, ua_hash, bot_label) · one row per click, and only the referring host - daily(slug, day, clicks, bots, PRIMARY KEY (slug, day)) · a rollup written by a scheduled job, which is what the dashboard reads - The key-value entry for a slug is one small JSON blob holding the destination and its rules, so a redirect is one lookup and zero database queries on the hot path. - No counter lives in the key-value store. The free tier allows one write per second to a single key and a low four-figure number of writes a day across all keys, which one popular link would exhaust before lunch. Check the current published figures before you design around them. - clicks keeps a hash of the user agent and the host part of the referrer, not the full string and not the query. Enough to split devices and sources, not enough to become a tracking database. ### Phase 1 · The redirect, with nothing on the hot path Build: Route the hostname to the worker. Read the slug from the key-value store, return a 302 with a no-store header, and write the click row inside waitUntil. On a lookup miss, read the database, repopulate the key-value entry, and only return a not-found when the database also has nothing. Trap: The key-value store is eventually consistent and its own documentation says changes may take up to sixty seconds or more to be visible at other locations as their cached copies time out, and it caches negative lookups too, so a slug that somebody hit one second before you created it can keep returning not-found in that region afterwards; treat a miss as a database read plus a repopulate rather than as a not-found, and check the current default cache duration before you decide how long that window is. Done when: A link created in one place resolves from a different network within the stated window, tested by hitting it from two locations. A lookup miss falls through to the database and repopulates the key-value entry, tested by deleting the entry directly. The response is a 302 carrying a no-store header and is never a 301, asserted in a test that checks the status and the header. The click row is written after the response, verified by timing the redirect with the database write stubbed to be slow. Do not build yet: No analytics, rules, expiry, QR codes or admin page yet. ### Phase 2 · A click log that fits inside the free tier Build: Insert one click row per redirect with the country from the request metadata, a device class, the referrer's host only and a hash of the user agent. Add a scheduled job that folds yesterday into the daily table. Index clicks on slug and time, and make the dashboard read daily. Trap: A dashboard that runs a count over the clicks table charges you for every row it scans rather than every row it returns, which on this database's free tier is metered as rows read per day in the millions and is trivially exhausted by one unindexed count over a busy month; index on slug and time, read the daily rollup for anything above a single link, and check both the rows-read and rows-written daily allowances now, because the rows-written figure is the real ceiling on how many clicks this design can record in a day. Done when: The dashboard makes no query that scans the clicks table, asserted by reviewing every statement and by an explain on each. The scheduled rollup is idempotent: running it twice for the same day leaves the same daily row, tested. Click rows carry a user agent hash and a referrer host, and a test fails if a full user agent string or a query string reaches the database. The README records the current daily rows-written allowance, the date checked, and what this build does when it is reached. Do not build yet: No routing rules, caps, passwords or QR codes yet. ### Phase 3 · Rules, expiry, caps and passwords Build: Put the rules in the same key-value blob as the destination so the hot path stays one lookup. Evaluate country and device rules before the default. Expire on a stored date. Enforce the click cap from the rollup. A password link serves a small form and then redirects. Trap: Enforcing a click cap by counting rows on every request puts a database query on the hot path and still gives you a stale answer, because the clicks are written asynchronously and the count lags; enforce the cap from the rolled-up figure the scheduled job maintains, accept that a capped link overshoots by whatever arrives between rollups, and write that overshoot into the README rather than implying the cap is exact. Done when: A country rule redirects to its own destination and the default is used when no rule matches, tested with a forged request metadata fixture. An expired link goes to the fallback URL rather than returning an error, tested at the boundary second. The click cap is enforced without a database read on the redirect path, asserted by counting queries in the test. A password link serves the form over the short domain and never puts the destination in the page source before the password is correct. Do not build yet: No QR codes, admin page, bot labelling or export yet. ### Phase 4 · QR codes and one admin page Build: Serve a PNG QR code for a slug from a cached response with a strong validator, generated once and reused. Put the admin page behind one deployment secret with a constant-time comparison, listing links with clicks per day, top countries, top referrer hosts and the device split. Trap: Generating the QR image on every request spends the worker's small per-invocation processing budget on work whose answer never changes, and the free tier gives you single-digit milliseconds of it; render once, store it, serve it from cache with a validator, and remember that a QR code printed on a poster is the most permanent thing this project will produce, so it must point at the short link and the slug behind it must never be reassigned to something else. Done when: A repeated QR request is served from cache and does not regenerate, verified by logging generation calls. The admin password is compared in constant time and a wrong password reveals nothing about the link set. The admin page reads only the daily rollup and per-link indexes, asserted by counting scanned rows. Deleting a link marks the slug as retired so it can never be reassigned, tested by attempting to recreate it. Do not build yet: No bot labelling, export or migration path yet. ### Phase 5 · Bots, export and the domain that outlives this Build: Label a click as probable bot rather than dropping it, and default the dashboard to excluding labelled clicks with a toggle. Export links, rules and clicks as CSV. Document the move to another host, and state plainly that these links die the day the domain lapses. Trap: Dropping clicks whose user agent looks like a bot means you can never audit the rule that dropped them, and a slightly wrong pattern quietly deletes real people on old or unusual browsers while you congratulate yourself on clean numbers; store a label on every click, exclude labelled clicks by default, keep the toggle, and treat the labelling rules as a thing you tune with evidence rather than a filter you trust. Done when: No click is ever deleted by the bot rule, only labelled, asserted by a test that counts rows with the rule on and off. The dashboard excludes labelled clicks by default and the toggle shows them. Export produces links, rules and clicks as CSV and reimports into an empty database with the same row counts, tested. The README states that every link stops working when the domain lapses, and names the renewal date. Do not build yet: Nothing after this. ### Out of scope (and why) - Link-in-bio pages, which are a small site builder wearing a shortener's clothes and belong in a different project. - Teams, roles and shared workspaces, which need accounts, and accounts are the thing this build deliberately does not have. - A public API with tokens, because the moment other tools can create links you have a spam problem and a moderation obligation. - Per-visitor identifiers or cross-site tracking, which is the line between analytics you can justify and a surveillance product. ### README must contain - That the database is the authority and the key-value store is a cache, including what happens on a miss - That the redirect is a 302 on purpose, and why a 301 would make a link permanently unchangeable - That the free tier's daily rows-written allowance is the real ceiling on click volume, with the figure and the date you checked it - That the click cap is enforced from a rollup and therefore approximate, with the size of the overshoot - That every link stops working when the domain lapses, with the renewal date written down ===== AGENTS.md ===== # Agent instructions · ReSlug indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A Cloudflare Worker on a hostname you own, deployed with wrangler, A key-value store for slug to destination, with the database behind it as the authority, One database row per click, written inside waitUntil after the response has gone, 302 with an explicit no-store header, never 301, One password held as a deployment secret, on a separate path. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - The key-value store is eventually consistent and its own documentation says changes may take up to sixty seconds or more to be visible at other locations as their cached copies time out, and it caches negative lookups too, so a slug that somebody hit one second before you created it can keep returning not-found in that region afterwards; treat a miss as a database read plus a repopulate rather than as a not-found, and check the current default cache duration before you decide how long that window is. - A dashboard that runs a count over the clicks table charges you for every row it scans rather than every row it returns, which on this database's free tier is metered as rows read per day in the millions and is trivially exhausted by one unindexed count over a busy month; index on slug and time, read the daily rollup for anything above a single link, and check both the rows-read and rows-written daily allowances now, because the rows-written figure is the real ceiling on how many clicks this design can record in a day. - Enforcing a click cap by counting rows on every request puts a database query on the hot path and still gives you a stale answer, because the clicks are written asynchronously and the count lags; enforce the cap from the rolled-up figure the scheduled job maintains, accept that a capped link overshoots by whatever arrives between rollups, and write that overshoot into the README rather than implying the cap is exact. - Generating the QR image on every request spends the worker's small per-invocation processing budget on work whose answer never changes, and the free tier gives you single-digit milliseconds of it; render once, store it, serve it from cache with a validator, and remember that a QR code printed on a poster is the most permanent thing this project will produce, so it must point at the short link and the slug behind it must never be reassigned to something else. ===== BUILD_PLAN.md ===== # Build plan · ReSlug A shortener whose hard problems are the free tier's arithmetic deciding where every byte is stored, an eventually consistent lookup that can serve a stale miss, and a redirect status code that determines whether you can ever change a link again. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · The redirect, with nothing on the hot path A hit on a short link resolves and redirects before any write happens, and a fresh link works everywhere. ### Steps 1. Return the redirect before writing anything waitUntil keeps the write alive after the response has gone. Files: `src/worker.ts`, `wrangler.toml` ```sh npx wrangler dev npx wrangler deploy ``` 2. Treat a key-value miss as a database read, not a 404 Negative lookups are cached and will outlive your mistake. Files: `src/lookup.ts`, `tests/miss-fallthrough.test.ts` 3. Use 302 and say no-store out loud A 301 is cacheable by default and is forever in somebody's browser. Files: `src/worker.ts`, `tests/status-code.test.ts` ### Done when - [ ] A new link resolves from a second network - [ ] A store miss falls through and repopulates - [ ] The status is 302 with no-store, never 301 - [ ] The redirect returns before the click is written ### Watch out - The key-value store is eventually consistent and its own documentation says changes may take up to sixty seconds or more to be visible at other locations as their cached copies time out, and it caches negative lookups too, so a slug that somebody hit one second before you created it can keep returning not-found in that region afterwards; treat a miss as a database read plus a repopulate rather than as a not-found, and check the current default cache duration before you decide how long that window is. ## Phase 2 · A click log that fits inside the free tier Every click is recorded, and the dashboard reads a rollup rather than scanning the log. ### Steps 1. Index on slug and time before writing the dashboard Otherwise every chart is a table scan you are billed for. Files: `migrations/0001_clicks.sql` 2. Roll up daily on a schedule, idempotently Running it twice must not double yesterday. Files: `src/rollup.ts`, `tests/rollup-idempotent.test.ts` 3. Store a hash and a host, not the string and the URL You want device split, not a record of where people came from in detail. Files: `src/click.ts`, `tests/no-pii.test.ts` ### Done when - [ ] No dashboard query scans the clicks table - [ ] The rollup is idempotent - [ ] No full user agent or query string is stored - [ ] The written-rows allowance is recorded with a date ### Watch out - A dashboard that runs a count over the clicks table charges you for every row it scans rather than every row it returns, which on this database's free tier is metered as rows read per day in the millions and is trivially exhausted by one unindexed count over a busy month; index on slug and time, read the daily rollup for anything above a single link, and check both the rows-read and rows-written daily allowances now, because the rows-written figure is the real ceiling on how many clicks this design can record in a day. ## Phase 3 · Rules, expiry, caps and passwords A link can retarget by country or device, expire, cap out or ask for a password, all decided without a database read. ### Steps 1. Keep rules inside the key-value blob One lookup for the whole decision, or the design is pointless. Files: `src/rules.ts`, `tests/rules.test.ts` 2. Cap from the rollup and document the overshoot An honest approximate cap beats a hot-path query. Files: `src/cap.ts`, `README.md` 3. Never send the destination before the password checks A view-source bypass makes the password decorative. Files: `src/password.ts`, `tests/password-leak.test.ts` ### Done when - [ ] Country and device rules beat the default - [ ] An expired link uses the fallback - [ ] The cap costs no database read on the hot path - [ ] The destination is absent until the password is right ### Watch out - Enforcing a click cap by counting rows on every request puts a database query on the hot path and still gives you a stale answer, because the clicks are written asynchronously and the count lags; enforce the cap from the rolled-up figure the scheduled job maintains, accept that a capped link overshoots by whatever arrives between rollups, and write that overshoot into the README rather than implying the cap is exact. ## Phase 4 · QR codes and one admin page Any link has a cached QR image and a single password-protected page shows what happened. ### Steps 1. Generate the QR once and cache the bytes The processing budget on this tier is measured in milliseconds. Files: `src/qr.ts` 2. Compare the admin password in constant time A timing side channel on one password is still a side channel. Files: `src/admin.ts` 3. Retire slugs instead of freeing them Somewhere there is a poster with that slug on it. Files: `migrations/0002_retired.sql`, `tests/no-reuse.test.ts` ### Done when - [ ] Repeat QR requests do not regenerate - [ ] Password comparison is constant time - [ ] Admin reads rollups, not the raw log - [ ] A deleted slug cannot be reused ### Watch out - Generating the QR image on every request spends the worker's small per-invocation processing budget on work whose answer never changes, and the free tier gives you single-digit milliseconds of it; render once, store it, serve it from cache with a validator, and remember that a QR code printed on a poster is the most permanent thing this project will produce, so it must point at the short link and the slug behind it must never be reassigned to something else. ## Not in this build - Link-in-bio pages. - Teams and shared workspaces. - A public API with tokens. - Per-visitor identifiers or cross-site tracking. ## After v1, if you want it - A scheduled export to storage you own, so the links table survives losing the account entirely - A second hostname for a different context, which mostly tests whether the slug namespace was ever per-domain - Bot labelling tuned against a week of real traffic, with the before and after counts written down ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Set with wrangler secret, never in wrangler.toml. The only credential here. ADMIN_PASSWORD=a-long-random-string-redacted # Required. The hostname routed to the worker. Appears in every QR code you print. SHORT_HOSTNAME=go.example.com # Required. Where an expired or capped link goes when it has no fallback of its own. DEFAULT_FALLBACK_URL=https://example.com/expired # Required. How long a lookup is cached at the edge. Longer is faster and staler. KV_CACHE_TTL=60 # Required. Random slug length. Longer costs nothing and collides less. SLUG_LENGTH=6 # Required. After this, raw clicks are deleted and the daily rollup remains. RETENTION_DAYS=400
You are building a production product version of ReSlug. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # ReSlug · product brief ## Problem The redirect is a slug, a lookup, a 302, and a row in a table · Cloudflare Workers with KV and D1 does the whole job on the free tier, and the QR code is one library call. Routing rules, expiry, and password links are an afternoon more. What you cannot build in a sitting is permanence: your links only outlive the project if you keep renewing the domain and the account. The wrinkle with ReSlug specifically is that its free plan already includes a custom domain and real analytics at personal scale, so the DIY build is competing with 0 USD rather than 9 · build it for the ownership, not the savings. ## Product outcome A shortener that redirects before it writes anything, survives an eventually consistent lookup without serving a stale not-found, keeps its click log inside a free tier you have actually read, and tells you plainly that the links die with the domain. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Cloudflare account (Workers, KV, D1) - a domain you control - wrangler CLI ## Explicit non-goals for v1 - Link-in-bio pages. - Teams and shared workspaces. - A public API with tokens. - Per-visitor identifiers or cross-site tracking. - links that outlive your own infrastructure · a domain you stop renewing takes every link with it - bot filtering that actually works · the heuristics take iteration you will not do for one project - geo and device routing rules, scheduled links, and click caps behind a dashboard instead of a config file - bio pages and branded QR codes - a REST API and webhooks other tools can call ## Success criteria - A new link is confirmed to resolve from a second network - A cache miss is confirmed to fall through rather than 404 - The status is confirmed to be 302 with no-store - No dashboard query is confirmed to scan the click table - Bot clicks are confirmed labelled rather than deleted ===== BRIEF.md ===== # Build brief · ReSlug The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a link shortener on a domain you own that redirects in one hop and logs the click without the redirect ever waiting for it to replace ReSlug. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: A Cloudflare Worker on a hostname you own, deployed with wrangler · the redirect has to be near the click and this tier costs nothing at personal scale - Lookup: A key-value store for slug to destination, with the database behind it as the authority · key-value is read-optimised and rarely written, which is the shape of a link table - Click log: One database row per click, written inside waitUntil after the response has gone · the redirect must never wait on a write, and waitUntil is what that costs you nothing - Redirect: 302 with an explicit no-store header, never 301 · a permanent redirect gets cached and then you can neither retarget the link nor count it - Admin: One password held as a deployment secret, on a separate path · one password is honest for one person and a login system is not the product ### Data model (create this before Phase 1) - links(slug PRIMARY KEY, destination, created_at, expires_at, click_cap, password_hash, fallback_url, note) · in the database, which is the authority - rules(id, slug, match_kind, match_value, destination) · country or device overrides, evaluated before the default destination - clicks(id, slug, at, country, region, device_class, referer_host, ua_hash, bot_label) · one row per click, and only the referring host - daily(slug, day, clicks, bots, PRIMARY KEY (slug, day)) · a rollup written by a scheduled job, which is what the dashboard reads - The key-value entry for a slug is one small JSON blob holding the destination and its rules, so a redirect is one lookup and zero database queries on the hot path. - No counter lives in the key-value store. The free tier allows one write per second to a single key and a low four-figure number of writes a day across all keys, which one popular link would exhaust before lunch. Check the current published figures before you design around them. - clicks keeps a hash of the user agent and the host part of the referrer, not the full string and not the query. Enough to split devices and sources, not enough to become a tracking database. ### Phase 1 · The redirect, with nothing on the hot path Build: Route the hostname to the worker. Read the slug from the key-value store, return a 302 with a no-store header, and write the click row inside waitUntil. On a lookup miss, read the database, repopulate the key-value entry, and only return a not-found when the database also has nothing. Trap: The key-value store is eventually consistent and its own documentation says changes may take up to sixty seconds or more to be visible at other locations as their cached copies time out, and it caches negative lookups too, so a slug that somebody hit one second before you created it can keep returning not-found in that region afterwards; treat a miss as a database read plus a repopulate rather than as a not-found, and check the current default cache duration before you decide how long that window is. Done when: A link created in one place resolves from a different network within the stated window, tested by hitting it from two locations. A lookup miss falls through to the database and repopulates the key-value entry, tested by deleting the entry directly. The response is a 302 carrying a no-store header and is never a 301, asserted in a test that checks the status and the header. The click row is written after the response, verified by timing the redirect with the database write stubbed to be slow. Do not build yet: No analytics, rules, expiry, QR codes or admin page yet. ### Phase 2 · A click log that fits inside the free tier Build: Insert one click row per redirect with the country from the request metadata, a device class, the referrer's host only and a hash of the user agent. Add a scheduled job that folds yesterday into the daily table. Index clicks on slug and time, and make the dashboard read daily. Trap: A dashboard that runs a count over the clicks table charges you for every row it scans rather than every row it returns, which on this database's free tier is metered as rows read per day in the millions and is trivially exhausted by one unindexed count over a busy month; index on slug and time, read the daily rollup for anything above a single link, and check both the rows-read and rows-written daily allowances now, because the rows-written figure is the real ceiling on how many clicks this design can record in a day. Done when: The dashboard makes no query that scans the clicks table, asserted by reviewing every statement and by an explain on each. The scheduled rollup is idempotent: running it twice for the same day leaves the same daily row, tested. Click rows carry a user agent hash and a referrer host, and a test fails if a full user agent string or a query string reaches the database. The README records the current daily rows-written allowance, the date checked, and what this build does when it is reached. Do not build yet: No routing rules, caps, passwords or QR codes yet. ### Phase 3 · Rules, expiry, caps and passwords Build: Put the rules in the same key-value blob as the destination so the hot path stays one lookup. Evaluate country and device rules before the default. Expire on a stored date. Enforce the click cap from the rollup. A password link serves a small form and then redirects. Trap: Enforcing a click cap by counting rows on every request puts a database query on the hot path and still gives you a stale answer, because the clicks are written asynchronously and the count lags; enforce the cap from the rolled-up figure the scheduled job maintains, accept that a capped link overshoots by whatever arrives between rollups, and write that overshoot into the README rather than implying the cap is exact. Done when: A country rule redirects to its own destination and the default is used when no rule matches, tested with a forged request metadata fixture. An expired link goes to the fallback URL rather than returning an error, tested at the boundary second. The click cap is enforced without a database read on the redirect path, asserted by counting queries in the test. A password link serves the form over the short domain and never puts the destination in the page source before the password is correct. Do not build yet: No QR codes, admin page, bot labelling or export yet. ### Phase 4 · QR codes and one admin page Build: Serve a PNG QR code for a slug from a cached response with a strong validator, generated once and reused. Put the admin page behind one deployment secret with a constant-time comparison, listing links with clicks per day, top countries, top referrer hosts and the device split. Trap: Generating the QR image on every request spends the worker's small per-invocation processing budget on work whose answer never changes, and the free tier gives you single-digit milliseconds of it; render once, store it, serve it from cache with a validator, and remember that a QR code printed on a poster is the most permanent thing this project will produce, so it must point at the short link and the slug behind it must never be reassigned to something else. Done when: A repeated QR request is served from cache and does not regenerate, verified by logging generation calls. The admin password is compared in constant time and a wrong password reveals nothing about the link set. The admin page reads only the daily rollup and per-link indexes, asserted by counting scanned rows. Deleting a link marks the slug as retired so it can never be reassigned, tested by attempting to recreate it. Do not build yet: No bot labelling, export or migration path yet. ### Phase 5 · Bots, export and the domain that outlives this Build: Label a click as probable bot rather than dropping it, and default the dashboard to excluding labelled clicks with a toggle. Export links, rules and clicks as CSV. Document the move to another host, and state plainly that these links die the day the domain lapses. Trap: Dropping clicks whose user agent looks like a bot means you can never audit the rule that dropped them, and a slightly wrong pattern quietly deletes real people on old or unusual browsers while you congratulate yourself on clean numbers; store a label on every click, exclude labelled clicks by default, keep the toggle, and treat the labelling rules as a thing you tune with evidence rather than a filter you trust. Done when: No click is ever deleted by the bot rule, only labelled, asserted by a test that counts rows with the rule on and off. The dashboard excludes labelled clicks by default and the toggle shows them. Export produces links, rules and clicks as CSV and reimports into an empty database with the same row counts, tested. The README states that every link stops working when the domain lapses, and names the renewal date. Do not build yet: Nothing after this. ### Out of scope (and why) - Link-in-bio pages, which are a small site builder wearing a shortener's clothes and belong in a different project. - Teams, roles and shared workspaces, which need accounts, and accounts are the thing this build deliberately does not have. - A public API with tokens, because the moment other tools can create links you have a spam problem and a moderation obligation. - Per-visitor identifiers or cross-site tracking, which is the line between analytics you can justify and a surveillance product. ### README must contain - That the database is the authority and the key-value store is a cache, including what happens on a miss - That the redirect is a 302 on purpose, and why a 301 would make a link permanently unchangeable - That the free tier's daily rows-written allowance is the real ceiling on click volume, with the figure and the date you checked it - That the click cap is enforced from a rollup and therefore approximate, with the size of the overshoot - That every link stops working when the domain lapses, with the renewal date written down ===== ARCHITECTURE.md ===== # Architecture · ReSlug ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A Cloudflare Worker on a hostname you own, deployed with wrangler | the redirect has to be near the click and this tier costs nothing at personal scale | | Lookup | A key-value store for slug to destination, with the database behind it as the authority | key-value is read-optimised and rarely written, which is the shape of a link table | | Click log | One database row per click, written inside waitUntil after the response has gone | the redirect must never wait on a write, and waitUntil is what that costs you nothing | | Redirect | 302 with an explicit no-store header, never 301 | a permanent redirect gets cached and then you can neither retarget the link nor count it | | Admin | One password held as a deployment secret, on a separate path | one password is honest for one person and a login system is not the product | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Redirect | Lookup, rule evaluation, the 302. | Any edge runtime, provided the response precedes the write. | | Store | The key-value cache and the database behind it. | Any pair, provided the database stays the authority. | | Log | Click rows, the daily rollup, retention. | Any store, provided the dashboard reads the rollup. | | Admin | One password, per-link views, exports. | Any interface, provided it never scans the click table. | | Labels | Bot labelling and the toggle. | Not swappable for deletion, which destroys the evidence. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `ADMIN_PASSWORD` · required, secret · Set with wrangler secret, never in wrangler.toml. The only credential here. - `SHORT_HOSTNAME` · required · The hostname routed to the worker. Appears in every QR code you print. - `DEFAULT_FALLBACK_URL` · required · Where an expired or capped link goes when it has no fallback of its own. - `KV_CACHE_TTL` · required · How long a lookup is cached at the edge. Longer is faster and staler. - `SLUG_LENGTH` · required · Random slug length. Longer costs nothing and collides less. - `RETENTION_DAYS` · required · After this, raw clicks are deleted and the daily rollup remains. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · ReSlug product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A Cloudflare Worker on a hostname you own, deployed with wrangler, A key-value store for slug to destination, with the database behind it as the authority, One database row per click, written inside waitUntil after the response has gone, 302 with an explicit no-store header, never 301, One password held as a deployment secret, on a separate path. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - The key-value store is eventually consistent and its own documentation says changes may take up to sixty seconds or more to be visible at other locations as their cached copies time out, and it caches negative lookups too, so a slug that somebody hit one second before you created it can keep returning not-found in that region afterwards; treat a miss as a database read plus a repopulate rather than as a not-found, and check the current default cache duration before you decide how long that window is. - A dashboard that runs a count over the clicks table charges you for every row it scans rather than every row it returns, which on this database's free tier is metered as rows read per day in the millions and is trivially exhausted by one unindexed count over a busy month; index on slug and time, read the daily rollup for anything above a single link, and check both the rows-read and rows-written daily allowances now, because the rows-written figure is the real ceiling on how many clicks this design can record in a day. - Enforcing a click cap by counting rows on every request puts a database query on the hot path and still gives you a stale answer, because the clicks are written asynchronously and the count lags; enforce the cap from the rolled-up figure the scheduled job maintains, accept that a capped link overshoots by whatever arrives between rollups, and write that overshoot into the README rather than implying the cap is exact. - Generating the QR image on every request spends the worker's small per-invocation processing budget on work whose answer never changes, and the free tier gives you single-digit milliseconds of it; render once, store it, serve it from cache with a validator, and remember that a QR code printed on a poster is the most permanent thing this project will produce, so it must point at the short link and the slug behind it must never be reassigned to something else. - Dropping clicks whose user agent looks like a bot means you can never audit the rule that dropped them, and a slightly wrong pattern quietly deletes real people on old or unusual browsers while you congratulate yourself on clean numbers; store a label on every click, exclude labelled clicks by default, keep the toggle, and treat the labelling rules as a thing you tune with evidence rather than a filter you trust. ===== MILESTONES.md ===== # Delivery milestones · ReSlug Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · The redirect, with nothing on the hot path A hit on a short link resolves and redirects before any write happens, and a fresh link works everywhere. ### Steps 1. Return the redirect before writing anything waitUntil keeps the write alive after the response has gone. Files: `src/worker.ts`, `wrangler.toml` ```sh npx wrangler dev npx wrangler deploy ``` 2. Treat a key-value miss as a database read, not a 404 Negative lookups are cached and will outlive your mistake. Files: `src/lookup.ts`, `tests/miss-fallthrough.test.ts` 3. Use 302 and say no-store out loud A 301 is cacheable by default and is forever in somebody's browser. Files: `src/worker.ts`, `tests/status-code.test.ts` ### Done when - [ ] A new link resolves from a second network - [ ] A store miss falls through and repopulates - [ ] The status is 302 with no-store, never 301 - [ ] The redirect returns before the click is written ### Watch out - The key-value store is eventually consistent and its own documentation says changes may take up to sixty seconds or more to be visible at other locations as their cached copies time out, and it caches negative lookups too, so a slug that somebody hit one second before you created it can keep returning not-found in that region afterwards; treat a miss as a database read plus a repopulate rather than as a not-found, and check the current default cache duration before you decide how long that window is. ## M2 · A click log that fits inside the free tier Every click is recorded, and the dashboard reads a rollup rather than scanning the log. ### Steps 1. Index on slug and time before writing the dashboard Otherwise every chart is a table scan you are billed for. Files: `migrations/0001_clicks.sql` 2. Roll up daily on a schedule, idempotently Running it twice must not double yesterday. Files: `src/rollup.ts`, `tests/rollup-idempotent.test.ts` 3. Store a hash and a host, not the string and the URL You want device split, not a record of where people came from in detail. Files: `src/click.ts`, `tests/no-pii.test.ts` ### Done when - [ ] No dashboard query scans the clicks table - [ ] The rollup is idempotent - [ ] No full user agent or query string is stored - [ ] The written-rows allowance is recorded with a date ### Watch out - A dashboard that runs a count over the clicks table charges you for every row it scans rather than every row it returns, which on this database's free tier is metered as rows read per day in the millions and is trivially exhausted by one unindexed count over a busy month; index on slug and time, read the daily rollup for anything above a single link, and check both the rows-read and rows-written daily allowances now, because the rows-written figure is the real ceiling on how many clicks this design can record in a day. ## M3 · Rules, expiry, caps and passwords A link can retarget by country or device, expire, cap out or ask for a password, all decided without a database read. ### Steps 1. Keep rules inside the key-value blob One lookup for the whole decision, or the design is pointless. Files: `src/rules.ts`, `tests/rules.test.ts` 2. Cap from the rollup and document the overshoot An honest approximate cap beats a hot-path query. Files: `src/cap.ts`, `README.md` 3. Never send the destination before the password checks A view-source bypass makes the password decorative. Files: `src/password.ts`, `tests/password-leak.test.ts` ### Done when - [ ] Country and device rules beat the default - [ ] An expired link uses the fallback - [ ] The cap costs no database read on the hot path - [ ] The destination is absent until the password is right ### Watch out - Enforcing a click cap by counting rows on every request puts a database query on the hot path and still gives you a stale answer, because the clicks are written asynchronously and the count lags; enforce the cap from the rolled-up figure the scheduled job maintains, accept that a capped link overshoots by whatever arrives between rollups, and write that overshoot into the README rather than implying the cap is exact. ## M4 · QR codes and one admin page Any link has a cached QR image and a single password-protected page shows what happened. ### Steps 1. Generate the QR once and cache the bytes The processing budget on this tier is measured in milliseconds. Files: `src/qr.ts` 2. Compare the admin password in constant time A timing side channel on one password is still a side channel. Files: `src/admin.ts` 3. Retire slugs instead of freeing them Somewhere there is a poster with that slug on it. Files: `migrations/0002_retired.sql`, `tests/no-reuse.test.ts` ### Done when - [ ] Repeat QR requests do not regenerate - [ ] Password comparison is constant time - [ ] Admin reads rollups, not the raw log - [ ] A deleted slug cannot be reused ### Watch out - Generating the QR image on every request spends the worker's small per-invocation processing budget on work whose answer never changes, and the free tier gives you single-digit milliseconds of it; render once, store it, serve it from cache with a validator, and remember that a QR code printed on a poster is the most permanent thing this project will produce, so it must point at the short link and the slug behind it must never be reassigned to something else. ## M5 · Bots, export and the domain that outlives this (production only) Bot traffic is labelled rather than deleted, everything exports, and the README tells the truth about permanence. ### Steps 1. Label, never delete You cannot measure a filter whose evidence you threw away. Files: `src/bots.ts`, `tests/label-not-delete.test.ts` 2. Export every table and reimport it The database is the authority, so the export is the escape hatch. Files: `src/export.ts` 3. Write the renewal date in the README This is the only real limitation of the whole build. Files: `README.md` ### Done when - [ ] Bot clicks are labelled and never dropped - [ ] The dashboard excludes them by default with a toggle - [ ] Export and reimport preserve row counts - [ ] The domain warning and renewal date are in the README ### Watch out - Dropping clicks whose user agent looks like a bot means you can never audit the rule that dropped them, and a slightly wrong pattern quietly deletes real people on old or unusual browsers while you congratulate yourself on clean numbers; store a label on every click, exclude labelled clicks by default, keep the toggle, and treat the labelling rules as a thing you tune with evidence rather than a filter you trust. ===== OPERATIONS.md ===== # Operations · ReSlug ## Backup Export the links and rules tables weekly, because those are the irreplaceable part: the click log can be lost without anyone's link breaking, but a lost links table breaks every printed QR code you have ever handed out. ## Restore Recreate the database from the export, repopulate the key-value entries from it, then test a link from a second network before announcing anything. The key-value store is always rebuildable from the database, which is the point of the design. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Daily rows written against the allowance, lookup miss rate, and the age of the newest daily rollup. A rising miss rate usually means the repopulation path is failing quietly and you are about to hit the database on every request. ## Incident checklist The two incidents here are a link pointing somewhere wrong and the daily allowance running out mid-afternoon. For the first, fix the database, purge the key-value entry, then verify from a second network. For the second, the fallback is to stop writing clicks and keep redirecting, because the redirect is the product and the analytics are not. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A new link is confirmed to resolve from a second network - [ ] A cache miss is confirmed to fall through rather than 404 - [ ] The status is confirmed to be 302 with no-store - [ ] No dashboard query is confirmed to scan the click table - [ ] Bot clicks are confirmed labelled rather than deleted ## Launch constraint Do not market omitted ReSlug capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Set with wrangler secret, never in wrangler.toml. The only credential here. ADMIN_PASSWORD=a-long-random-string-redacted # Required. The hostname routed to the worker. Appears in every QR code you print. SHORT_HOSTNAME=go.example.com # Required. Where an expired or capped link goes when it has no fallback of its own. DEFAULT_FALLBACK_URL=https://example.com/expired # Required. How long a lookup is cached at the edge. Longer is faster and staler. KV_CACHE_TTL=60 # Required. Random slug length. Longer costs nothing and collides less. SLUG_LENGTH=6 # Required. After this, raw clicks are deleted and the daily rollup remains. RETENTION_DAYS=400
# ReSlug · indie build A shortener whose hard problems are the free tier's arithmetic deciding where every byte is stored, an eventually consistent lookup that can serve a stale miss, and a redirect status code that determines whether you can ever change a link again. Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A Cloudflare Worker on a hostname you own, deployed with wrangler | the redirect has to be near the click and this tier costs nothing at personal scale | | Lookup | A key-value store for slug to destination, with the database behind it as the authority | key-value is read-optimised and rarely written, which is the shape of a link table | | Click log | One database row per click, written inside waitUntil after the response has gone | the redirect must never wait on a write, and waitUntil is what that costs you nothing | | Redirect | 302 with an explicit no-store header, never 301 | a permanent redirect gets cached and then you can neither retarget the link nor count it | | Admin | One password held as a deployment secret, on a separate path | one password is honest for one person and a login system is not the product | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Cloudflare account with Workers, a key-value namespace and a database** · free at personal scale - Why: This is where the whole thing runs, and the free tier's numbers are what the design is shaped around. - Get it: Sign up, create the namespace and the database, and note the current published limits. - Verify: wrangler whoami succeeds and both bindings appear in wrangler.toml - [ ] **The wrangler command line tool** · free - Why: Deploys the worker, manages bindings and holds the secrets that must never enter the configuration file. - Get it: Install it with your package manager of choice. - Verify: npx wrangler --version prints a version and npx wrangler dev serves locally - [ ] **A domain you intend to keep renewing** · roughly ten to fifteen pounds a year, indefinitely - Why: Every short link you print, paste or put on a poster stops working the day this domain lapses, which makes the renewal decision the most consequential part of the project. - Get it: Register or move a domain onto the same account and add the hostname route. - Verify: A request to the short hostname reaches your worker - [ ] **The current published free tier limits, written down with a date** · free - Why: Reads per day, writes per day, rows read, rows written and the per-invocation processing budget all decide this design, and all of them change. - Get it: Read the platform's own limits and pricing pages and copy the figures into the README. - Verify: Every figure in the README has a source URL and a checked date - [ ] **A second network to test from** · free - Why: The eventually consistent lookup only misbehaves when the request arrives somewhere your write has not reached, which you cannot see from one machine. - Get it: A phone on mobile data, or a shell on a host somewhere else. - Verify: A newly created link resolves from it within the window you documented ## Quick start ```sh npx wrangler dev npx wrangler deploy ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Link-in-bio pages. - Teams and shared workspaces. - A public API with tokens. - Per-visitor identifiers or cross-site tracking. - links that outlive your own infrastructure · a domain you stop renewing takes every link with it - bot filtering that actually works · the heuristics take iteration you will not do for one project - geo and device routing rules, scheduled links, and click caps behind a dashboard instead of a config file - bio pages and branded QR codes - a REST API and webhooks other tools can call If one of those is essential to you, that is the reason to keep paying for ReSlug, and the README should say so rather than pretend.
# Build brief · ReSlug The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a link shortener on a domain you own that redirects in one hop and logs the click without the redirect ever waiting for it to replace ReSlug. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: A Cloudflare Worker on a hostname you own, deployed with wrangler · the redirect has to be near the click and this tier costs nothing at personal scale - Lookup: A key-value store for slug to destination, with the database behind it as the authority · key-value is read-optimised and rarely written, which is the shape of a link table - Click log: One database row per click, written inside waitUntil after the response has gone · the redirect must never wait on a write, and waitUntil is what that costs you nothing - Redirect: 302 with an explicit no-store header, never 301 · a permanent redirect gets cached and then you can neither retarget the link nor count it - Admin: One password held as a deployment secret, on a separate path · one password is honest for one person and a login system is not the product ### Data model (create this before Phase 1) - links(slug PRIMARY KEY, destination, created_at, expires_at, click_cap, password_hash, fallback_url, note) · in the database, which is the authority - rules(id, slug, match_kind, match_value, destination) · country or device overrides, evaluated before the default destination - clicks(id, slug, at, country, region, device_class, referer_host, ua_hash, bot_label) · one row per click, and only the referring host - daily(slug, day, clicks, bots, PRIMARY KEY (slug, day)) · a rollup written by a scheduled job, which is what the dashboard reads - The key-value entry for a slug is one small JSON blob holding the destination and its rules, so a redirect is one lookup and zero database queries on the hot path. - No counter lives in the key-value store. The free tier allows one write per second to a single key and a low four-figure number of writes a day across all keys, which one popular link would exhaust before lunch. Check the current published figures before you design around them. - clicks keeps a hash of the user agent and the host part of the referrer, not the full string and not the query. Enough to split devices and sources, not enough to become a tracking database. ### Phase 1 · The redirect, with nothing on the hot path Build: Route the hostname to the worker. Read the slug from the key-value store, return a 302 with a no-store header, and write the click row inside waitUntil. On a lookup miss, read the database, repopulate the key-value entry, and only return a not-found when the database also has nothing. Trap: The key-value store is eventually consistent and its own documentation says changes may take up to sixty seconds or more to be visible at other locations as their cached copies time out, and it caches negative lookups too, so a slug that somebody hit one second before you created it can keep returning not-found in that region afterwards; treat a miss as a database read plus a repopulate rather than as a not-found, and check the current default cache duration before you decide how long that window is. Done when: A link created in one place resolves from a different network within the stated window, tested by hitting it from two locations. A lookup miss falls through to the database and repopulates the key-value entry, tested by deleting the entry directly. The response is a 302 carrying a no-store header and is never a 301, asserted in a test that checks the status and the header. The click row is written after the response, verified by timing the redirect with the database write stubbed to be slow. Do not build yet: No analytics, rules, expiry, QR codes or admin page yet. ### Phase 2 · A click log that fits inside the free tier Build: Insert one click row per redirect with the country from the request metadata, a device class, the referrer's host only and a hash of the user agent. Add a scheduled job that folds yesterday into the daily table. Index clicks on slug and time, and make the dashboard read daily. Trap: A dashboard that runs a count over the clicks table charges you for every row it scans rather than every row it returns, which on this database's free tier is metered as rows read per day in the millions and is trivially exhausted by one unindexed count over a busy month; index on slug and time, read the daily rollup for anything above a single link, and check both the rows-read and rows-written daily allowances now, because the rows-written figure is the real ceiling on how many clicks this design can record in a day. Done when: The dashboard makes no query that scans the clicks table, asserted by reviewing every statement and by an explain on each. The scheduled rollup is idempotent: running it twice for the same day leaves the same daily row, tested. Click rows carry a user agent hash and a referrer host, and a test fails if a full user agent string or a query string reaches the database. The README records the current daily rows-written allowance, the date checked, and what this build does when it is reached. Do not build yet: No routing rules, caps, passwords or QR codes yet. ### Phase 3 · Rules, expiry, caps and passwords Build: Put the rules in the same key-value blob as the destination so the hot path stays one lookup. Evaluate country and device rules before the default. Expire on a stored date. Enforce the click cap from the rollup. A password link serves a small form and then redirects. Trap: Enforcing a click cap by counting rows on every request puts a database query on the hot path and still gives you a stale answer, because the clicks are written asynchronously and the count lags; enforce the cap from the rolled-up figure the scheduled job maintains, accept that a capped link overshoots by whatever arrives between rollups, and write that overshoot into the README rather than implying the cap is exact. Done when: A country rule redirects to its own destination and the default is used when no rule matches, tested with a forged request metadata fixture. An expired link goes to the fallback URL rather than returning an error, tested at the boundary second. The click cap is enforced without a database read on the redirect path, asserted by counting queries in the test. A password link serves the form over the short domain and never puts the destination in the page source before the password is correct. Do not build yet: No QR codes, admin page, bot labelling or export yet. ### Phase 4 · QR codes and one admin page Build: Serve a PNG QR code for a slug from a cached response with a strong validator, generated once and reused. Put the admin page behind one deployment secret with a constant-time comparison, listing links with clicks per day, top countries, top referrer hosts and the device split. Trap: Generating the QR image on every request spends the worker's small per-invocation processing budget on work whose answer never changes, and the free tier gives you single-digit milliseconds of it; render once, store it, serve it from cache with a validator, and remember that a QR code printed on a poster is the most permanent thing this project will produce, so it must point at the short link and the slug behind it must never be reassigned to something else. Done when: A repeated QR request is served from cache and does not regenerate, verified by logging generation calls. The admin password is compared in constant time and a wrong password reveals nothing about the link set. The admin page reads only the daily rollup and per-link indexes, asserted by counting scanned rows. Deleting a link marks the slug as retired so it can never be reassigned, tested by attempting to recreate it. Do not build yet: No bot labelling, export or migration path yet. ### Phase 5 · Bots, export and the domain that outlives this Build: Label a click as probable bot rather than dropping it, and default the dashboard to excluding labelled clicks with a toggle. Export links, rules and clicks as CSV. Document the move to another host, and state plainly that these links die the day the domain lapses. Trap: Dropping clicks whose user agent looks like a bot means you can never audit the rule that dropped them, and a slightly wrong pattern quietly deletes real people on old or unusual browsers while you congratulate yourself on clean numbers; store a label on every click, exclude labelled clicks by default, keep the toggle, and treat the labelling rules as a thing you tune with evidence rather than a filter you trust. Done when: No click is ever deleted by the bot rule, only labelled, asserted by a test that counts rows with the rule on and off. The dashboard excludes labelled clicks by default and the toggle shows them. Export produces links, rules and clicks as CSV and reimports into an empty database with the same row counts, tested. The README states that every link stops working when the domain lapses, and names the renewal date. Do not build yet: Nothing after this. ### Out of scope (and why) - Link-in-bio pages, which are a small site builder wearing a shortener's clothes and belong in a different project. - Teams, roles and shared workspaces, which need accounts, and accounts are the thing this build deliberately does not have. - A public API with tokens, because the moment other tools can create links you have a spam problem and a moderation obligation. - Per-visitor identifiers or cross-site tracking, which is the line between analytics you can justify and a surveillance product. ### README must contain - That the database is the authority and the key-value store is a cache, including what happens on a miss - That the redirect is a 302 on purpose, and why a 301 would make a link permanently unchangeable - That the free tier's daily rows-written allowance is the real ceiling on click volume, with the figure and the date you checked it - That the click cap is enforced from a rollup and therefore approximate, with the size of the overshoot - That every link stops working when the domain lapses, with the renewal date written down
# Agent instructions · ReSlug indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A Cloudflare Worker on a hostname you own, deployed with wrangler, A key-value store for slug to destination, with the database behind it as the authority, One database row per click, written inside waitUntil after the response has gone, 302 with an explicit no-store header, never 301, One password held as a deployment secret, on a separate path. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - The key-value store is eventually consistent and its own documentation says changes may take up to sixty seconds or more to be visible at other locations as their cached copies time out, and it caches negative lookups too, so a slug that somebody hit one second before you created it can keep returning not-found in that region afterwards; treat a miss as a database read plus a repopulate rather than as a not-found, and check the current default cache duration before you decide how long that window is. - A dashboard that runs a count over the clicks table charges you for every row it scans rather than every row it returns, which on this database's free tier is metered as rows read per day in the millions and is trivially exhausted by one unindexed count over a busy month; index on slug and time, read the daily rollup for anything above a single link, and check both the rows-read and rows-written daily allowances now, because the rows-written figure is the real ceiling on how many clicks this design can record in a day. - Enforcing a click cap by counting rows on every request puts a database query on the hot path and still gives you a stale answer, because the clicks are written asynchronously and the count lags; enforce the cap from the rolled-up figure the scheduled job maintains, accept that a capped link overshoots by whatever arrives between rollups, and write that overshoot into the README rather than implying the cap is exact. - Generating the QR image on every request spends the worker's small per-invocation processing budget on work whose answer never changes, and the free tier gives you single-digit milliseconds of it; render once, store it, serve it from cache with a validator, and remember that a QR code printed on a poster is the most permanent thing this project will produce, so it must point at the short link and the slug behind it must never be reassigned to something else.
# Build plan · ReSlug A shortener whose hard problems are the free tier's arithmetic deciding where every byte is stored, an eventually consistent lookup that can serve a stale miss, and a redirect status code that determines whether you can ever change a link again. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · The redirect, with nothing on the hot path A hit on a short link resolves and redirects before any write happens, and a fresh link works everywhere. ### Steps 1. Return the redirect before writing anything waitUntil keeps the write alive after the response has gone. Files: `src/worker.ts`, `wrangler.toml` ```sh npx wrangler dev npx wrangler deploy ``` 2. Treat a key-value miss as a database read, not a 404 Negative lookups are cached and will outlive your mistake. Files: `src/lookup.ts`, `tests/miss-fallthrough.test.ts` 3. Use 302 and say no-store out loud A 301 is cacheable by default and is forever in somebody's browser. Files: `src/worker.ts`, `tests/status-code.test.ts` ### Done when - [ ] A new link resolves from a second network - [ ] A store miss falls through and repopulates - [ ] The status is 302 with no-store, never 301 - [ ] The redirect returns before the click is written ### Watch out - The key-value store is eventually consistent and its own documentation says changes may take up to sixty seconds or more to be visible at other locations as their cached copies time out, and it caches negative lookups too, so a slug that somebody hit one second before you created it can keep returning not-found in that region afterwards; treat a miss as a database read plus a repopulate rather than as a not-found, and check the current default cache duration before you decide how long that window is. ## Phase 2 · A click log that fits inside the free tier Every click is recorded, and the dashboard reads a rollup rather than scanning the log. ### Steps 1. Index on slug and time before writing the dashboard Otherwise every chart is a table scan you are billed for. Files: `migrations/0001_clicks.sql` 2. Roll up daily on a schedule, idempotently Running it twice must not double yesterday. Files: `src/rollup.ts`, `tests/rollup-idempotent.test.ts` 3. Store a hash and a host, not the string and the URL You want device split, not a record of where people came from in detail. Files: `src/click.ts`, `tests/no-pii.test.ts` ### Done when - [ ] No dashboard query scans the clicks table - [ ] The rollup is idempotent - [ ] No full user agent or query string is stored - [ ] The written-rows allowance is recorded with a date ### Watch out - A dashboard that runs a count over the clicks table charges you for every row it scans rather than every row it returns, which on this database's free tier is metered as rows read per day in the millions and is trivially exhausted by one unindexed count over a busy month; index on slug and time, read the daily rollup for anything above a single link, and check both the rows-read and rows-written daily allowances now, because the rows-written figure is the real ceiling on how many clicks this design can record in a day. ## Phase 3 · Rules, expiry, caps and passwords A link can retarget by country or device, expire, cap out or ask for a password, all decided without a database read. ### Steps 1. Keep rules inside the key-value blob One lookup for the whole decision, or the design is pointless. Files: `src/rules.ts`, `tests/rules.test.ts` 2. Cap from the rollup and document the overshoot An honest approximate cap beats a hot-path query. Files: `src/cap.ts`, `README.md` 3. Never send the destination before the password checks A view-source bypass makes the password decorative. Files: `src/password.ts`, `tests/password-leak.test.ts` ### Done when - [ ] Country and device rules beat the default - [ ] An expired link uses the fallback - [ ] The cap costs no database read on the hot path - [ ] The destination is absent until the password is right ### Watch out - Enforcing a click cap by counting rows on every request puts a database query on the hot path and still gives you a stale answer, because the clicks are written asynchronously and the count lags; enforce the cap from the rolled-up figure the scheduled job maintains, accept that a capped link overshoots by whatever arrives between rollups, and write that overshoot into the README rather than implying the cap is exact. ## Phase 4 · QR codes and one admin page Any link has a cached QR image and a single password-protected page shows what happened. ### Steps 1. Generate the QR once and cache the bytes The processing budget on this tier is measured in milliseconds. Files: `src/qr.ts` 2. Compare the admin password in constant time A timing side channel on one password is still a side channel. Files: `src/admin.ts` 3. Retire slugs instead of freeing them Somewhere there is a poster with that slug on it. Files: `migrations/0002_retired.sql`, `tests/no-reuse.test.ts` ### Done when - [ ] Repeat QR requests do not regenerate - [ ] Password comparison is constant time - [ ] Admin reads rollups, not the raw log - [ ] A deleted slug cannot be reused ### Watch out - Generating the QR image on every request spends the worker's small per-invocation processing budget on work whose answer never changes, and the free tier gives you single-digit milliseconds of it; render once, store it, serve it from cache with a validator, and remember that a QR code printed on a poster is the most permanent thing this project will produce, so it must point at the short link and the slug behind it must never be reassigned to something else. ## Not in this build - Link-in-bio pages. - Teams and shared workspaces. - A public API with tokens. - Per-visitor identifiers or cross-site tracking. ## After v1, if you want it - A scheduled export to storage you own, so the links table survives losing the account entirely - A second hostname for a different context, which mostly tests whether the slug namespace was ever per-domain - Bot labelling tuned against a week of real traffic, with the before and after counts written down
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Set with wrangler secret, never in wrangler.toml. The only credential here. ADMIN_PASSWORD=a-long-random-string-redacted # Required. The hostname routed to the worker. Appears in every QR code you print. SHORT_HOSTNAME=go.example.com # Required. Where an expired or capped link goes when it has no fallback of its own. DEFAULT_FALLBACK_URL=https://example.com/expired # Required. How long a lookup is cached at the edge. Longer is faster and staler. KV_CACHE_TTL=60 # Required. Random slug length. Longer costs nothing and collides less. SLUG_LENGTH=6 # Required. After this, raw clicks are deleted and the daily rollup remains. RETENTION_DAYS=400
# ReSlug · product brief ## Problem The redirect is a slug, a lookup, a 302, and a row in a table · Cloudflare Workers with KV and D1 does the whole job on the free tier, and the QR code is one library call. Routing rules, expiry, and password links are an afternoon more. What you cannot build in a sitting is permanence: your links only outlive the project if you keep renewing the domain and the account. The wrinkle with ReSlug specifically is that its free plan already includes a custom domain and real analytics at personal scale, so the DIY build is competing with 0 USD rather than 9 · build it for the ownership, not the savings. ## Product outcome A shortener that redirects before it writes anything, survives an eventually consistent lookup without serving a stale not-found, keeps its click log inside a free tier you have actually read, and tells you plainly that the links die with the domain. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Cloudflare account (Workers, KV, D1) - a domain you control - wrangler CLI ## Explicit non-goals for v1 - Link-in-bio pages. - Teams and shared workspaces. - A public API with tokens. - Per-visitor identifiers or cross-site tracking. - links that outlive your own infrastructure · a domain you stop renewing takes every link with it - bot filtering that actually works · the heuristics take iteration you will not do for one project - geo and device routing rules, scheduled links, and click caps behind a dashboard instead of a config file - bio pages and branded QR codes - a REST API and webhooks other tools can call ## Success criteria - A new link is confirmed to resolve from a second network - A cache miss is confirmed to fall through rather than 404 - The status is confirmed to be 302 with no-store - No dashboard query is confirmed to scan the click table - Bot clicks are confirmed labelled rather than deleted
# Build brief · ReSlug The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a link shortener on a domain you own that redirects in one hop and logs the click without the redirect ever waiting for it to replace ReSlug. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: A Cloudflare Worker on a hostname you own, deployed with wrangler · the redirect has to be near the click and this tier costs nothing at personal scale - Lookup: A key-value store for slug to destination, with the database behind it as the authority · key-value is read-optimised and rarely written, which is the shape of a link table - Click log: One database row per click, written inside waitUntil after the response has gone · the redirect must never wait on a write, and waitUntil is what that costs you nothing - Redirect: 302 with an explicit no-store header, never 301 · a permanent redirect gets cached and then you can neither retarget the link nor count it - Admin: One password held as a deployment secret, on a separate path · one password is honest for one person and a login system is not the product ### Data model (create this before Phase 1) - links(slug PRIMARY KEY, destination, created_at, expires_at, click_cap, password_hash, fallback_url, note) · in the database, which is the authority - rules(id, slug, match_kind, match_value, destination) · country or device overrides, evaluated before the default destination - clicks(id, slug, at, country, region, device_class, referer_host, ua_hash, bot_label) · one row per click, and only the referring host - daily(slug, day, clicks, bots, PRIMARY KEY (slug, day)) · a rollup written by a scheduled job, which is what the dashboard reads - The key-value entry for a slug is one small JSON blob holding the destination and its rules, so a redirect is one lookup and zero database queries on the hot path. - No counter lives in the key-value store. The free tier allows one write per second to a single key and a low four-figure number of writes a day across all keys, which one popular link would exhaust before lunch. Check the current published figures before you design around them. - clicks keeps a hash of the user agent and the host part of the referrer, not the full string and not the query. Enough to split devices and sources, not enough to become a tracking database. ### Phase 1 · The redirect, with nothing on the hot path Build: Route the hostname to the worker. Read the slug from the key-value store, return a 302 with a no-store header, and write the click row inside waitUntil. On a lookup miss, read the database, repopulate the key-value entry, and only return a not-found when the database also has nothing. Trap: The key-value store is eventually consistent and its own documentation says changes may take up to sixty seconds or more to be visible at other locations as their cached copies time out, and it caches negative lookups too, so a slug that somebody hit one second before you created it can keep returning not-found in that region afterwards; treat a miss as a database read plus a repopulate rather than as a not-found, and check the current default cache duration before you decide how long that window is. Done when: A link created in one place resolves from a different network within the stated window, tested by hitting it from two locations. A lookup miss falls through to the database and repopulates the key-value entry, tested by deleting the entry directly. The response is a 302 carrying a no-store header and is never a 301, asserted in a test that checks the status and the header. The click row is written after the response, verified by timing the redirect with the database write stubbed to be slow. Do not build yet: No analytics, rules, expiry, QR codes or admin page yet. ### Phase 2 · A click log that fits inside the free tier Build: Insert one click row per redirect with the country from the request metadata, a device class, the referrer's host only and a hash of the user agent. Add a scheduled job that folds yesterday into the daily table. Index clicks on slug and time, and make the dashboard read daily. Trap: A dashboard that runs a count over the clicks table charges you for every row it scans rather than every row it returns, which on this database's free tier is metered as rows read per day in the millions and is trivially exhausted by one unindexed count over a busy month; index on slug and time, read the daily rollup for anything above a single link, and check both the rows-read and rows-written daily allowances now, because the rows-written figure is the real ceiling on how many clicks this design can record in a day. Done when: The dashboard makes no query that scans the clicks table, asserted by reviewing every statement and by an explain on each. The scheduled rollup is idempotent: running it twice for the same day leaves the same daily row, tested. Click rows carry a user agent hash and a referrer host, and a test fails if a full user agent string or a query string reaches the database. The README records the current daily rows-written allowance, the date checked, and what this build does when it is reached. Do not build yet: No routing rules, caps, passwords or QR codes yet. ### Phase 3 · Rules, expiry, caps and passwords Build: Put the rules in the same key-value blob as the destination so the hot path stays one lookup. Evaluate country and device rules before the default. Expire on a stored date. Enforce the click cap from the rollup. A password link serves a small form and then redirects. Trap: Enforcing a click cap by counting rows on every request puts a database query on the hot path and still gives you a stale answer, because the clicks are written asynchronously and the count lags; enforce the cap from the rolled-up figure the scheduled job maintains, accept that a capped link overshoots by whatever arrives between rollups, and write that overshoot into the README rather than implying the cap is exact. Done when: A country rule redirects to its own destination and the default is used when no rule matches, tested with a forged request metadata fixture. An expired link goes to the fallback URL rather than returning an error, tested at the boundary second. The click cap is enforced without a database read on the redirect path, asserted by counting queries in the test. A password link serves the form over the short domain and never puts the destination in the page source before the password is correct. Do not build yet: No QR codes, admin page, bot labelling or export yet. ### Phase 4 · QR codes and one admin page Build: Serve a PNG QR code for a slug from a cached response with a strong validator, generated once and reused. Put the admin page behind one deployment secret with a constant-time comparison, listing links with clicks per day, top countries, top referrer hosts and the device split. Trap: Generating the QR image on every request spends the worker's small per-invocation processing budget on work whose answer never changes, and the free tier gives you single-digit milliseconds of it; render once, store it, serve it from cache with a validator, and remember that a QR code printed on a poster is the most permanent thing this project will produce, so it must point at the short link and the slug behind it must never be reassigned to something else. Done when: A repeated QR request is served from cache and does not regenerate, verified by logging generation calls. The admin password is compared in constant time and a wrong password reveals nothing about the link set. The admin page reads only the daily rollup and per-link indexes, asserted by counting scanned rows. Deleting a link marks the slug as retired so it can never be reassigned, tested by attempting to recreate it. Do not build yet: No bot labelling, export or migration path yet. ### Phase 5 · Bots, export and the domain that outlives this Build: Label a click as probable bot rather than dropping it, and default the dashboard to excluding labelled clicks with a toggle. Export links, rules and clicks as CSV. Document the move to another host, and state plainly that these links die the day the domain lapses. Trap: Dropping clicks whose user agent looks like a bot means you can never audit the rule that dropped them, and a slightly wrong pattern quietly deletes real people on old or unusual browsers while you congratulate yourself on clean numbers; store a label on every click, exclude labelled clicks by default, keep the toggle, and treat the labelling rules as a thing you tune with evidence rather than a filter you trust. Done when: No click is ever deleted by the bot rule, only labelled, asserted by a test that counts rows with the rule on and off. The dashboard excludes labelled clicks by default and the toggle shows them. Export produces links, rules and clicks as CSV and reimports into an empty database with the same row counts, tested. The README states that every link stops working when the domain lapses, and names the renewal date. Do not build yet: Nothing after this. ### Out of scope (and why) - Link-in-bio pages, which are a small site builder wearing a shortener's clothes and belong in a different project. - Teams, roles and shared workspaces, which need accounts, and accounts are the thing this build deliberately does not have. - A public API with tokens, because the moment other tools can create links you have a spam problem and a moderation obligation. - Per-visitor identifiers or cross-site tracking, which is the line between analytics you can justify and a surveillance product. ### README must contain - That the database is the authority and the key-value store is a cache, including what happens on a miss - That the redirect is a 302 on purpose, and why a 301 would make a link permanently unchangeable - That the free tier's daily rows-written allowance is the real ceiling on click volume, with the figure and the date you checked it - That the click cap is enforced from a rollup and therefore approximate, with the size of the overshoot - That every link stops working when the domain lapses, with the renewal date written down
# Architecture · ReSlug ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A Cloudflare Worker on a hostname you own, deployed with wrangler | the redirect has to be near the click and this tier costs nothing at personal scale | | Lookup | A key-value store for slug to destination, with the database behind it as the authority | key-value is read-optimised and rarely written, which is the shape of a link table | | Click log | One database row per click, written inside waitUntil after the response has gone | the redirect must never wait on a write, and waitUntil is what that costs you nothing | | Redirect | 302 with an explicit no-store header, never 301 | a permanent redirect gets cached and then you can neither retarget the link nor count it | | Admin | One password held as a deployment secret, on a separate path | one password is honest for one person and a login system is not the product | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Redirect | Lookup, rule evaluation, the 302. | Any edge runtime, provided the response precedes the write. | | Store | The key-value cache and the database behind it. | Any pair, provided the database stays the authority. | | Log | Click rows, the daily rollup, retention. | Any store, provided the dashboard reads the rollup. | | Admin | One password, per-link views, exports. | Any interface, provided it never scans the click table. | | Labels | Bot labelling and the toggle. | Not swappable for deletion, which destroys the evidence. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `ADMIN_PASSWORD` · required, secret · Set with wrangler secret, never in wrangler.toml. The only credential here. - `SHORT_HOSTNAME` · required · The hostname routed to the worker. Appears in every QR code you print. - `DEFAULT_FALLBACK_URL` · required · Where an expired or capped link goes when it has no fallback of its own. - `KV_CACHE_TTL` · required · How long a lookup is cached at the edge. Longer is faster and staler. - `SLUG_LENGTH` · required · Random slug length. Longer costs nothing and collides less. - `RETENTION_DAYS` · required · After this, raw clicks are deleted and the daily rollup remains. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · ReSlug product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A Cloudflare Worker on a hostname you own, deployed with wrangler, A key-value store for slug to destination, with the database behind it as the authority, One database row per click, written inside waitUntil after the response has gone, 302 with an explicit no-store header, never 301, One password held as a deployment secret, on a separate path. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - The key-value store is eventually consistent and its own documentation says changes may take up to sixty seconds or more to be visible at other locations as their cached copies time out, and it caches negative lookups too, so a slug that somebody hit one second before you created it can keep returning not-found in that region afterwards; treat a miss as a database read plus a repopulate rather than as a not-found, and check the current default cache duration before you decide how long that window is. - A dashboard that runs a count over the clicks table charges you for every row it scans rather than every row it returns, which on this database's free tier is metered as rows read per day in the millions and is trivially exhausted by one unindexed count over a busy month; index on slug and time, read the daily rollup for anything above a single link, and check both the rows-read and rows-written daily allowances now, because the rows-written figure is the real ceiling on how many clicks this design can record in a day. - Enforcing a click cap by counting rows on every request puts a database query on the hot path and still gives you a stale answer, because the clicks are written asynchronously and the count lags; enforce the cap from the rolled-up figure the scheduled job maintains, accept that a capped link overshoots by whatever arrives between rollups, and write that overshoot into the README rather than implying the cap is exact. - Generating the QR image on every request spends the worker's small per-invocation processing budget on work whose answer never changes, and the free tier gives you single-digit milliseconds of it; render once, store it, serve it from cache with a validator, and remember that a QR code printed on a poster is the most permanent thing this project will produce, so it must point at the short link and the slug behind it must never be reassigned to something else. - Dropping clicks whose user agent looks like a bot means you can never audit the rule that dropped them, and a slightly wrong pattern quietly deletes real people on old or unusual browsers while you congratulate yourself on clean numbers; store a label on every click, exclude labelled clicks by default, keep the toggle, and treat the labelling rules as a thing you tune with evidence rather than a filter you trust.
# Delivery milestones · ReSlug Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · The redirect, with nothing on the hot path A hit on a short link resolves and redirects before any write happens, and a fresh link works everywhere. ### Steps 1. Return the redirect before writing anything waitUntil keeps the write alive after the response has gone. Files: `src/worker.ts`, `wrangler.toml` ```sh npx wrangler dev npx wrangler deploy ``` 2. Treat a key-value miss as a database read, not a 404 Negative lookups are cached and will outlive your mistake. Files: `src/lookup.ts`, `tests/miss-fallthrough.test.ts` 3. Use 302 and say no-store out loud A 301 is cacheable by default and is forever in somebody's browser. Files: `src/worker.ts`, `tests/status-code.test.ts` ### Done when - [ ] A new link resolves from a second network - [ ] A store miss falls through and repopulates - [ ] The status is 302 with no-store, never 301 - [ ] The redirect returns before the click is written ### Watch out - The key-value store is eventually consistent and its own documentation says changes may take up to sixty seconds or more to be visible at other locations as their cached copies time out, and it caches negative lookups too, so a slug that somebody hit one second before you created it can keep returning not-found in that region afterwards; treat a miss as a database read plus a repopulate rather than as a not-found, and check the current default cache duration before you decide how long that window is. ## M2 · A click log that fits inside the free tier Every click is recorded, and the dashboard reads a rollup rather than scanning the log. ### Steps 1. Index on slug and time before writing the dashboard Otherwise every chart is a table scan you are billed for. Files: `migrations/0001_clicks.sql` 2. Roll up daily on a schedule, idempotently Running it twice must not double yesterday. Files: `src/rollup.ts`, `tests/rollup-idempotent.test.ts` 3. Store a hash and a host, not the string and the URL You want device split, not a record of where people came from in detail. Files: `src/click.ts`, `tests/no-pii.test.ts` ### Done when - [ ] No dashboard query scans the clicks table - [ ] The rollup is idempotent - [ ] No full user agent or query string is stored - [ ] The written-rows allowance is recorded with a date ### Watch out - A dashboard that runs a count over the clicks table charges you for every row it scans rather than every row it returns, which on this database's free tier is metered as rows read per day in the millions and is trivially exhausted by one unindexed count over a busy month; index on slug and time, read the daily rollup for anything above a single link, and check both the rows-read and rows-written daily allowances now, because the rows-written figure is the real ceiling on how many clicks this design can record in a day. ## M3 · Rules, expiry, caps and passwords A link can retarget by country or device, expire, cap out or ask for a password, all decided without a database read. ### Steps 1. Keep rules inside the key-value blob One lookup for the whole decision, or the design is pointless. Files: `src/rules.ts`, `tests/rules.test.ts` 2. Cap from the rollup and document the overshoot An honest approximate cap beats a hot-path query. Files: `src/cap.ts`, `README.md` 3. Never send the destination before the password checks A view-source bypass makes the password decorative. Files: `src/password.ts`, `tests/password-leak.test.ts` ### Done when - [ ] Country and device rules beat the default - [ ] An expired link uses the fallback - [ ] The cap costs no database read on the hot path - [ ] The destination is absent until the password is right ### Watch out - Enforcing a click cap by counting rows on every request puts a database query on the hot path and still gives you a stale answer, because the clicks are written asynchronously and the count lags; enforce the cap from the rolled-up figure the scheduled job maintains, accept that a capped link overshoots by whatever arrives between rollups, and write that overshoot into the README rather than implying the cap is exact. ## M4 · QR codes and one admin page Any link has a cached QR image and a single password-protected page shows what happened. ### Steps 1. Generate the QR once and cache the bytes The processing budget on this tier is measured in milliseconds. Files: `src/qr.ts` 2. Compare the admin password in constant time A timing side channel on one password is still a side channel. Files: `src/admin.ts` 3. Retire slugs instead of freeing them Somewhere there is a poster with that slug on it. Files: `migrations/0002_retired.sql`, `tests/no-reuse.test.ts` ### Done when - [ ] Repeat QR requests do not regenerate - [ ] Password comparison is constant time - [ ] Admin reads rollups, not the raw log - [ ] A deleted slug cannot be reused ### Watch out - Generating the QR image on every request spends the worker's small per-invocation processing budget on work whose answer never changes, and the free tier gives you single-digit milliseconds of it; render once, store it, serve it from cache with a validator, and remember that a QR code printed on a poster is the most permanent thing this project will produce, so it must point at the short link and the slug behind it must never be reassigned to something else. ## M5 · Bots, export and the domain that outlives this (production only) Bot traffic is labelled rather than deleted, everything exports, and the README tells the truth about permanence. ### Steps 1. Label, never delete You cannot measure a filter whose evidence you threw away. Files: `src/bots.ts`, `tests/label-not-delete.test.ts` 2. Export every table and reimport it The database is the authority, so the export is the escape hatch. Files: `src/export.ts` 3. Write the renewal date in the README This is the only real limitation of the whole build. Files: `README.md` ### Done when - [ ] Bot clicks are labelled and never dropped - [ ] The dashboard excludes them by default with a toggle - [ ] Export and reimport preserve row counts - [ ] The domain warning and renewal date are in the README ### Watch out - Dropping clicks whose user agent looks like a bot means you can never audit the rule that dropped them, and a slightly wrong pattern quietly deletes real people on old or unusual browsers while you congratulate yourself on clean numbers; store a label on every click, exclude labelled clicks by default, keep the toggle, and treat the labelling rules as a thing you tune with evidence rather than a filter you trust.
# Operations · ReSlug ## Backup Export the links and rules tables weekly, because those are the irreplaceable part: the click log can be lost without anyone's link breaking, but a lost links table breaks every printed QR code you have ever handed out. ## Restore Recreate the database from the export, repopulate the key-value entries from it, then test a link from a second network before announcing anything. The key-value store is always rebuildable from the database, which is the point of the design. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Daily rows written against the allowance, lookup miss rate, and the age of the newest daily rollup. A rising miss rate usually means the repopulation path is failing quietly and you are about to hit the database on every request. ## Incident checklist The two incidents here are a link pointing somewhere wrong and the daily allowance running out mid-afternoon. For the first, fix the database, purge the key-value entry, then verify from a second network. For the second, the fallback is to stop writing clicks and keep redirecting, because the redirect is the product and the analytics are not. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A new link is confirmed to resolve from a second network - [ ] A cache miss is confirmed to fall through rather than 404 - [ ] The status is confirmed to be 302 with no-store - [ ] No dashboard query is confirmed to scan the click table - [ ] Bot clicks are confirmed labelled rather than deleted ## Launch constraint Do not market omitted ReSlug capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Set with wrangler secret, never in wrangler.toml. The only credential here. ADMIN_PASSWORD=a-long-random-string-redacted # Required. The hostname routed to the worker. Appears in every QR code you print. SHORT_HOSTNAME=go.example.com # Required. Where an expired or capped link goes when it has no fallback of its own. DEFAULT_FALLBACK_URL=https://example.com/expired # Required. How long a lookup is cached at the edge. Longer is faster and staler. KV_CACHE_TTL=60 # Required. Random slug length. Longer costs nothing and collides less. SLUG_LENGTH=6 # Required. After this, raw clicks are deleted and the daily rollup remains. RETENTION_DAYS=400
$ choose a build depth, inspect the files, then open the complete pack in your agent
The redirect was never the product. What the subscription buys is a redirect that stays up when your side project does not, analytics you did not have to build or debug, and routing, expiry, and password controls behind a dashboard instead of a config file. ReSlug's own twist is that most of that is on the free plan, custom domain included, so the paid tier is really about unlimited tracked clicks, longer analytics retention, and API access · the people paying 9 USD are past personal scale anyway.
xlinks that outlive your own infrastructure · a domain you stop renewing takes every link with it
xbot filtering that actually works · the heuristics take iteration you will not do for one project
xgeo and device routing rules, scheduled links, and click caps behind a dashboard instead of a config file
xbio pages and branded QR codes
xa REST API and webhooks other tools can call
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
ReSlug pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | 500 active links, 1,000 tracked clicks/month, 1 custom domain, 30-day analytics, 1 bio page, 25 tags, 3 folders, a 100-row CSV import/upload cap, 1 seat, and 50 lead captures/month |
| pro | $9/workspace | $6.58/workspace | Unlimited links and tracked clicks, 10 custom domains, 365-day analytics, 5 bio pages, 10 API keys at 200 requests/minute, a 1,000-row CSV import/upload cap, and 5 seats |
free tier500 active links; 1,000 tracked clicks/month; 1 custom domain; 30-day analytics; 1 bio page; 25 tags; 3 folders; 100-row CSV import/upload cap; 1 seat; 50 lead captures/month
billingmonthly + annual ($79/year, about 27% below $9 monthly); auto-renewal terms were not numerically detailed on the pricing page
verified 2026-08-12 · source ↗
Vibecode ReSlug
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal ReSlug replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does ReSlug cost?
ReSlug costs about $9/month (Pro, checked 2026-08-03), which is $108 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing ReSlug?
Honestly: links that outlive your own infrastructure · a domain you stop renewing takes every link with it; bot filtering that actually works · the heuristics take iteration you will not do for one project; geo and device routing rules, scheduled links, and click caps behind a dashboard instead of a config file; bio pages and branded QR codes; a REST API and webhooks other tools can call. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to ReSlug?
Yes: Short.io (Short links, analytics, QR codes, mobile routing and link-in-bio pages on a genuinely useful free plan.) The prompt is for when you want it exactly your way.