Skip to content

Changelog

Every release, and what changed

Generated from the repository changelog. A version that is in development is listed separately, because it is not in a purchased archive yet.

Unreleased

In development

Present in the repository, not yet in a purchased archive. A version is published on the downloads page when the archive is built and verified.

Four passes over the application. The audit pass found the defects that are listed under Fixed. The provider pass turned the kit into a set of modules with more than one vendor behind each concern. The product pass made the kit rebrandable, so a deployment can put its own name on it. The security pass closed the paths that answered without the controls of the application. A fifth pass audited the provider layer for the vendors it claimed to support, added the account lifecycle and integrations a production SaaS needs, and made the database clean up after itself.

Added

  • One-time orders in the payment contract. verifyWebhook can return orders (paid, refunded, dispute lost) next to the subscription events, and a provider can implement createOrderCheckoutSession for a guest checkout. Dodo implements both.
  • An agent playbook. docs/AGENT-PLAYBOOK.md tells an AI coding agent where each kind of code belongs, gives step by step recipes for the common changes (a protected page, an API route, a table, a plan gated feature, an email, a provider, a public form, a scheduled job), records the traps that caused real bugs here, and explains how to start a new product from the kit. CLAUDE.md imports it for Claude Code and .github/copilot-instructions.md gives Copilot the essentials.
  • `bun run verify` runs the lint, the type check, the tests, the UI snapshot check and the production build in one command.
  • The guides are tested. src/config/documentation-integrity.test.ts fails when a guide names a path, a bun run script or an identifier that does not exist.
  • Sign in by email. The sign in page offers a single use link that expires after 15 minutes. POST /api/auth/magic-link answers every address the same way, is limited per client address and per mailbox, and mails the same hashed handoff token a provider sign in uses, so the handoff route still asks for a confirmation and a mail scanner cannot use the link up. It is on by default and, in production, only offered when an email provider is configured. AUTH_MAGIC_LINK=disabled turns it off.
  • Account deletion. The security settings have a danger zone, and POST /api/profile/delete deletes the account after the owner types its address and, for an account with a password, enters it. It is refused while a subscription can still be charged. Every record of the account is erased in one D1 batch through the new AccountErasureStore, and every session cookie is cleared.
  • Cloudflare Turnstile. With NEXT_PUBLIC_TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY set, sign up, password reset and sign in links verify a Turnstile proof with Siteverify before they create an account or send an email. Off by default; fails closed when Siteverify is unreachable.
  • Product analytics providers. NEXT_PUBLIC_ANALYTICS_PROVIDER selects Plausible, Umami, PostHog or Cloudflare Web Analytics. Nothing loads until the provider's variables are set, every provider is cookieless by default, and trackEvent forwards custom events to the provider.
  • A daily data retention job. worker.ts wraps the OpenNext handler and adds a scheduled handler, and wrangler.jsonc declares a Cron Trigger. It deletes expired sessions, tokens, handoff tokens and closed rate limit windows in bounded batches, and forgets webhook deliveries after 90 days. Migration 0006 adds the expiry indexes it deletes by.
  • The health endpoint reports the new modules: whether sign in links and Turnstile are enabled and which analytics provider is selected.
  • `.gitattributes` keeps internal files out of the customer archive. git archive honours export-ignore, so Backend/ stays in the repository and never reaches a buyer.
  • The buying decision survives the account flow. A pricing call to action carries its plan and intent into /signup, the signup form rebuilds them and hands them to /signin, and an allowlisted sign in sends the buyer to /billing?plan=… instead of the dashboard. Both parameters are rebuilt from the pricing config, so a plan that does not exist cannot reach the billing page, and the destination still passes the sign in allowlist.
  • The changelog is public. /changelog renders this file, separates released versions from work that is still in development, and links from the navigation and the sitemap. Pricing, the licence page and the terms already referred to a public changelog; the page now exists.
  • Every page has a real social card. app/opengraph-image.tsx renders a 1200x630 card from the product identity and kit config, and each kit segment renders its own card with the kit name, tagline, version and provider count. The previous metadata pointed a summary_large_image card at a square logo.
  • Pricing publishes `FAQPage` structured data. The schema is built from the same questions the page renders, so the markup and the visible content cannot diverge.
  • The Aback UI component library ships with the kit. The components and the shared tokens are vendored under src/vendor/aback-ui at a pinned commit, so the source is in the archive and every Aback product renders the same components. bun run ui:sync refreshes the snapshot and bun run ui:check fails when it drifts. src/app/globals.css now imports the library tokens instead of repeating the colour, type and radius values.
  • `/components` renders the live library. The page groups the vendored components by job, shows the import contract, and documents how to update the snapshot. It is linked from the navigation, the sitemap and the home page.
  • The kit page generates the environment block. /kits/nextjs-cloudflare#configure lets a buyer pick a payment, email and sign in provider and copies the exact variables to fill in. The catalogue comes from the same registries the application resolves, so the block cannot name a provider the code does not support.
  • The kit page and pricing now carry the buying decision. The kit page states included against excluded side by side, the prerequisites, the supported versions and the shared ecosystem. Pricing adds a comparison table derived from the plan config, a FAQ, and billing copy that matches the provider contract instead of naming one vendor.
  • The home page states the provider coverage. The payment, email and sign in registries drive the provider section, so the page cannot list a vendor that the code does not implement.
  • The kit is now delivered. A Launch or Scale subscription grants the kit.source.download capability, and the new /downloads page offers the current release. The archive is streamed from a private R2 bucket by GET /api/kits/[kitId]/download after the session and the entitlement are resolved. Nothing is granted from a checkout redirect.
  • Releases are packaged reproducibly. bun run kit:package builds the archive from tracked files with git archive, refuses a dirty tree and writes a manifest with the commit, the size and the SHA-256. The release registry is the only source of artifact keys.
  • The deployment declares its delivery bucket. wrangler.jsonc binds KIT_BUCKET for local, staging and production, and GET /api/health reports whether the bucket is configured.
  • The kit page explains the delivery path. It names the current release, links to the plans and the downloads page, and the installation guide starts from the release archive instead of a repository that was never public.
  • A delivery guide and a contributing guide document the release and the verification workflow.
  • A test for the webhook route. The route is the only place where a provider outside the browser can change a subscription, and it had no coverage at all.
  • A test for the cross site request check. The check is the only defence against a cross site request on the endpoints that do not need a session, and it had no coverage at all.
  • A test for the sign in providers against the contract of the framework. A provider that is missing its token endpoint fails at the callback, after the person has already approved the sign in.
  • A test that runs the lifecycle handlers against the durable repository. The suite used the in memory store, where the guard that refuses an older event is inert, so the handlers were never exercised against the guard.
  • A test that the credentials provider of the framework is not registered.
  • A timeout of thirty seconds for the suite. The suite hashes passwords with bcrypt, and the default timeout of five seconds was enough for one hash but not for a test that hashes many of them on a loaded machine.
  • An email provider module. src/lib/providers/email/ holds the email contract, the registry and one directory per vendor. Resend stays the default, Postmark and SendGrid were added. The deployment names the provider with EMAIL_PROVIDER, and an unknown name is a configuration error rather than a silent fallback.
  • A sign in provider module. src/lib/providers/auth/ holds the sign in contract, the registry and one directory per vendor. Google stays the default, GitHub and Microsoft were added. The deployment names the providers with AUTH_PROVIDERS.
  • A provider owns the translation of its own profile. The sign in callback never reads a vendor field. The provider maps the profile of the vendor into the account of the application, and the address is only trusted when the provider says it verified it.
  • A sign in button that is the same for every provider, so a deployment that adds a provider does not have to build a button for it.
  • The product identity is one configuration. src/config/product-identity.ts holds the name, the legal entity, the origin and the contact addresses of the product. The pages, the metadata, the sitemap, the manifest and the contact page read it, so a deployment can rebrand the kit from the environment.
  • The health endpoint reports the email and the sign in providers, the ones that are usable and the ones that were asked for.
  • An account scoped sign in limit and an account lockout, so an attack against one account cannot be spread over many addresses.
  • `peek` and `clear` on the rate limit store, so the lockout can be checked before the cost of a password comparison and cleared after a successful sign in.
  • A `Security` guide in the documentation, next to the internal security notes.
  • A migration that records the time of the last applied subscription event.
  • A shared cross site request guard and a shared Dodo redirect rule, so the server and the browser can never disagree about what a safe request is.
  • Security response headers and a disabled X-Powered-By, so the site cannot be framed and the runtime is not advertised.
  • A test that fails when an ARIA reference points at a missing element.
  • A test that fails when the search request is dispatched from a listener.
  • `GET /api/auth/session`, the endpoint the static shell uses to resolve the session in the browser. It reports whether an account is signed in and never exposes an account identifier.
  • A branded error boundary and a root not found page.
  • A test for the dashboard overview endpoint, the OAuth handoff route and the provider sign in route, which had no coverage at all.
  • A test that fails when a resend verification answer could reveal that an address is registered.
  • Continuous integration. Every push and pull request runs the lint, the type check, the tests and the production build.
  • A test that fails when a locale is missing a translation, when a translation is still in English, or when the copy describes another product.
  • A provider layer. Every concern that can be served by more than one vendor is a contract, and every vendor is an implementation behind it. The application asks the registry for a provider, so a vendor is never imported directly.
  • Lemon Squeezy. A merchant of record, with a hosted checkout, a customer area and signed webhooks.
  • Stripe. A card processor, with a hosted checkout, a billing portal session and signed webhooks.
  • A shared lifecycle vocabulary. Every provider maps its own event names onto one vocabulary, so the lifecycle handlers never learn a vendor.
  • `PAYMENTS_PROVIDER`. The deployment names its payment provider. The default is Dodo Payments, so an existing deployment keeps working unchanged, and an unknown name is a configuration error rather than a silent fallback.
  • A provider aware webhook route. /api/webhooks/[provider] verifies a delivery with the provider that owns it.
  • A module guide at docs/MODULES.md, with the rules, the layout and the steps to add a provider.
  • A provider double the tests use, so no test ever touches the network.
  • The proprietary licence. The kit is a private, commercial product. The licence lets a buyer build and sell their own products with it and does not let them redistribute the kit itself.

Changed

  • The subscription record is provider neutral. Migration 0005 renames dodo_customer_id and dodo_subscription_id to provider_customer_id and provider_subscription_id and adds the provider that issued them. The Dodo prefix is removed from every type, error and function outside the Dodo module (DodoAuthSessionRecordOwnershipError is now D1AuthSessionRecordOwnershipError, dodoCustomerId is providerCustomerId, and so on), and dodo-lifecycle-plan-mapper.ts is now subscription-lifecycle-plan-mapper.ts.
  • The customer portal refuses a customer of another provider. After a deployment moves to a different PAYMENTS_PROVIDER, a subscription that is still billed by the previous provider is not sent to the new one.
  • `ui:sync` records honest provenance. It refuses to vendor from an aback-ui checkout with uncommitted changes unless --allow-dirty is passed, marks such a snapshot as dirty, and fails when a component imports a package this repository does not declare. ui:check skips the comparison while aback-ui has work in progress instead of failing every check.
  • The sign out cookie clearing is shared from src/lib/auth/server/session-cookie-clearing.ts, so a sign out and an account deletion clear the same cookies.
  • The application primitives now delegate to Aback UI. Button, ButtonLink, Badge, Tag, Card, Input, Label, Textarea, Alert, Separator, Progress, Skeleton and Table are thin wrappers over the vendored library, so the account area and the marketing site render one implementation. The local dialog, tabs and accordion copies were removed in favour of the library components. src/components/ui now holds only composition: the labelled field shells, a native select, native toggles and the sheet wrapper.
  • The paid offer is legible. Starter includes the documentation and the changelog; Launch and Scale include the source archive, the maintained versions and support. The pricing catalogue carries an explicit provisional notice until the owner confirms the plans.
  • The README no longer says the kit is MIT licensed. The kit is a proprietary product under LICENSE.md, and the README now says so.
  • The sign in page is rendered per request. It reads the enabled providers from the environment, so a build time render could not bake the providers of the build machine into the page.
  • The sign in copy does not name a provider. The feedback and the guidance are the same for every provider, because the deployment chooses the provider.
  • The environment template, the deployment guide and the readme describe every provider of every module.
  • The sign up endpoint answers with 202 Accepted instead of 201 Created, and with one message for every address.
  • The sign in endpoint is limited per address and per account.
  • The pricing plans describe what a subscription to the kit buys. The "Projects" and the "Team Seats" entitlements and the "Advanced billing workflows" capability are gone, because nothing gated on them. The 14 day trial the Launch plan advertises is now applied to the checkout session.
  • The entitlement limits live in the code, not in the marketing copy.
  • The right rail reads the navigation configuration instead of repeating the links, and the call to action of the pricing page records the click, so the identifier in the configuration is used.
  • The documentation, the security notes, the deployment guide, the environment template and the readme describe the behaviour that is actually implemented. The privacy page names every sub processor, the licences page lists every dependency, and the MIT licence file ships with the repository.
  • The deploy scripts target the production and the staging environment, so the localhost values in the configuration are never uploaded.
  • The licence is no longer MIT. The terms and the licences pages describe the proprietary licence, and the licence text ships in LICENSE.md.
  • A plan declares a product per provider. The plan to product mapping moved from the pricing configuration to the provider module, and the catalogue resolves the product of the provider that is selected.
  • The health endpoint reports the payment provider, the providers that are usable and the plans that cannot be sold.
  • The documentation describes the providers instead of one vendor.

Removed

  • The CI workflow. .github/workflows/ci.yml is gone, so a push no longer runs the checks on GitHub. Run bun run verify before committing; deployment stays a manual bun run deploy. The kit page no longer lists continuous integration as included.

Fixed

  • No Dodo webhook ever changed a subscription. The provider matched made-up event names such as activated and payment_succeeded, while Dodo sends subscription.active, payment.succeeded and the rest, so every real delivery was acknowledged and ignored. It also read the customer as a flat string, while Dodo nests it as customer.customer_id, so even a mapped event had no customer. The event map follows the Dodo reference, the customer is read from the nested object, a payment's amount from total_amount, and a test sends payloads shaped like the official SDK types.
  • The header overflowed at 1024 pixels. The search and sign in buttons now appear from the widest breakpoint.
  • The suite failed in some file orders. Every file shares the in memory store. One suite left a malformed account behind, suites that restored "the seed" restored a snapshot taken when they loaded, which captured what earlier files had changed, and the sign in suite inherited a demo account that other files had locked. resetInMemoryCredentialsUsersForTests restores the original seed, the sign in suite also empties the rate limit counters, and bun run test:random runs the suite in a random order. It passes under 20 random orders.
  • A Stripe or Lemon Squeezy subscription that stopped paying kept its plan. The status map only knew the Dodo vocabulary, so Stripe's canceled, unpaid, incomplete_expired and paused and Lemon Squeezy's on_trial and unpaid made the update fail permanently and left the account active. Every status every registered provider documents now maps to a billing state, and a test enumerates them.
  • A plan change at Stripe or Lemon Squeezy was ignored. The renewal and update handlers resolved the product in the Dodo catalogue, so an upgrade in the provider portal kept the old plan and the renewal at the new price was rejected as an amount mismatch. The product is resolved in the catalogue of the provider that sent the event.
  • A permanent webhook conflict was retried for days in production. The D1 repository raised its own error classes, which the handlers did not recognise, so an ownership conflict became a 503 and the provider kept redelivering it. The D1 errors extend the lifecycle contract errors.
  • The D1 owner upsert dropped the event time, so a write through it lost to any recorded event. It now carries the event time like the in memory driver.
  • A provider sign in always landed on the dashboard. The handoff route read the form body twice; the second read failed and the destination fell back to /dashboard, so a buyer coming from pricing never reached /billing?plan=…. The form is read once.
  • A stolen session could guess the password through the password change. The current password was compared with no attempt limit. Proving the password again is now limited per account (AUTH_ACCOUNT_REAUTHENTICATION_RATE_LIMIT_*).
  • `dodo.com` was on the payment redirect allow list. Dodo Payments serves its checkout and portal from dodopayments.com; dodo.com is an unrelated domain. It is removed and a test rejects it.
  • The theme was never applied before the first paint. The inline head script interpolated themeStorageKey from a "use client" module, which is a client reference on the server, so the script failed to parse on every page and a dark mode visitor saw the light theme flash. The key now lives in src/config/theme.ts.
  • An expired sign in link showed a generic error. It now says the link expired or was already used.
  • `docs/ARCHITECTURE.md` described files that no longer exist. The email and payment sections describe the provider modules.
  • An expired session lost the page you were on. Every protected page sends an expired session to /signin?next=<path>, but the sign in form only read redirectTo, so a customer returning from /settings/security or /downloads landed on the dashboard instead. The form now accepts either name, prefers redirectTo, and validates both against the same allowlist. /downloads is on the sign in and OAuth allowlists, so the destination survives whichever provider the customer uses.
  • The NextAuth credentials provider bypassed every sign in control. It answered a password without the rate limit, without the per account limit and without the account lockout, so a script could guess a password without limit. The application signs a person in with credentials at POST /api/auth/signin and that route owns the controls, so the credentials provider of the framework is gone.
  • The lockout confirmed that an address is registered. A locked account answered with a different status and a different message, so seven requests per candidate address were enough to tell a registered address from an unknown one. The lockout is now only reported to a caller that proves it knows the password, and the comparison always runs, so the answer does not depend on the state of the account.
  • The cross site check failed open. A request with no Origin and no Sec-Fetch-Site header was allowed, and the opaque origin a sandboxed frame sends was treated as a missing header. Both are refused now, and a malformed origin is a mismatch.
  • The resend verification endpoint had no rate limit. The cooldown of the account bounds the messages for one address, and a caller could still ask for an arbitrary list of addresses. The endpoint is limited now.
  • The bucket key of the password reset limit carried the address. The address is hashed, so the table of the rate limits is not a list of every address that ever asked for a reset.
  • The sign out left the provider session behind. The cookie of the provider session is cleared now.
  • The picture of a provider was stored without the rule of the application. A provider that answers with a javascript: address could store one. The picture goes through the same rule as a picture the person set.
  • The webhook route accepted a delivery from a provider the deployment does not use. A deployment that still carried the secrets of another provider accepted its events, and they wrote into the same subscription record. The route only accepts the provider the deployment selected.
  • Only the last event of a delivery decided the answer. A delivery with more than one event answered as applied when an earlier event had failed, so the provider stopped retrying. Every event of the delivery decides the answer now.
  • A claim that was never finished lost the event for good. A worker that was killed between the claim and the write left the row behind, and every redelivery was skipped. A claim that is older than the lease is taken again.
  • The plan of an event was resolved in the catalogue of Dodo. A Stripe event and a Lemon Squeezy event never mapped to a plan, so the customer paid and the application granted no entitlement. The plan is resolved in the catalogue of the provider that sent the event, and the plan of the checkout is read from the metadata of the session.
  • The identifiers of Lemon Squeezy were dropped. They are numbers, and the reader only accepted strings, so every event was missing the customer and the product.
  • A test event was applied to a live deployment. The mode of the event is compared with the mode of the deployment now.
  • The amount of the first payment was never checked. A product that points at the wrong plan granted the more expensive plan from the first payment. The amount is checked on the activation as well as on the renewal.
  • A change of plan created a second subscription. The customer paid for both plans, and the account only remembered the last one. A change of plan happens in the customer area of the provider now.
  • The lifecycle writes were discarded in production. The handlers did not pass the time of the event with the write, so the repository treated every write after the activation as the oldest event and skipped it. A cancelled customer stayed active and kept the entitlements of a paid plan.
  • The checkout and the customer portal only accepted the hosts of Dodo. A Stripe and a Lemon Squeezy checkout could never open.
  • The sign in providers could not complete. The configuration of GitHub and of Microsoft carried no token endpoint, so the exchange failed at the callback. Every provider now carries the endpoints and the profile mapper the framework needs, and the type of the configuration is checked by the compiler.
  • GitHub and Microsoft could never create an account. The address of GitHub is verified in the endpoint of the addresses of the account, and the address of Microsoft is verified by the claim of the identity token.
  • The time of the email provider did not cover the reading of the body. A provider that answered with the headers and then stalled held the request until the platform killed it.
  • The message of a provider was copied into the log without a limit. A provider could flood the log of the application. The message is truncated and the control characters are removed.
  • A sender that is not an address was accepted. The health report said the provider was usable, and every message was refused.
  • The structural data could break out of the script element. A label that carried the closing sequence of the element was written into the page as markup. The characters are escaped now.
  • The page of a kit showed a template placeholder in the social card. The name of the product was not interpolated.
  • The licences page named three licences wrongly and left six dependencies out. The list matches the packages that are installed.
  • The pricing page said the kit was under the MIT licence while the same card said the licence was proprietary.
  • The general and the support address were the same mailbox. The identity has a separate address for each of them.
  • The privacy policy described a page view that was never recorded and named one email and one payment provider while the deployment can use three of them.
  • Every account page canonicalised to the home page. The canonical of a page points at the page itself.
  • The navigation overflowed between 640 and 768 pixels. The links of the desktop navigation and the menu button were visible at the same time.
  • The results of the search were tab stops. The focus stays in the search field and the active result is announced with aria-activedescendant.
  • The name of the product was hardcoded in the search index, in the licence of the kit and in two pages that bypassed the identity.
  • The title of the home page named the product twice. The root layout appends the name of the product to every title, and the home page set the whole title itself, so the page read "Aback Stack | Production starter kits for SaaS | Aback Stack". The page now sets only the part that is its own.
  • A test that failed when the machine was busy. The suite hashes passwords with bcrypt, and the default timeout of five seconds was enough for one hash but not for a test that hashes many of them on a loaded machine. The timeout is now thirty seconds, so a slow machine does not produce a failure that trains the team to ignore the suite.
  • The billing page never showed a subscription in production. The subscription was written to Cloudflare D1 by the webhook but read from the in memory store, so the billing page reported "No active plan", the customer portal was permanently unavailable and a paying customer could start a second checkout for the same plan. The read path now goes through the subscription lifecycle repository, so it uses the same durable store as the write path.
  • The checkout and the customer portal never opened. The browser only accepted checkout.dodo.com and billing.dodo.com, while the provider serves the links from checkout.dodopayments.com. The server accepted the URL and the browser replaced it with a local path. Both sides now share one rule, so a valid provider URL is never discarded.
  • The customer portal return landed on a not found page. The portal sends the customer back to /billing/manage, which did not exist. The route exists now and shows the billing workspace with a confirmation.
  • The webhook idempotency ledger was never written. The dispatcher used the in memory ledger, so the billing_webhook_events table stayed empty and a redelivery was processed again on every isolate. The ledger now resolves to Cloudflare D1 when a binding is available.
  • A permanent webhook failure blocked the event id forever. The claim was never resolved, and the claim is the existence of the row. Every terminal outcome is now recorded.
  • A stale webhook event could put a cancelled account back into a paid state. The subscription record now stores the time of the last applied event, and an older event is ignored. The guard runs in the write statement, so it is atomic.
  • An unknown product granted a paid plan. An activation for a product that is not in the catalogue fell back to the recommended paid plan. It now grants nothing and is reported.
  • A payment event could set a subscription to active. payment.succeeded was routed to the renewal handler. It has its own handler now, it never changes the subscription status, and it is skipped when the payment is not attached to a subscription.
  • A rotated subscription identifier could never be mapped. The lookup required the customer and the subscription identifier to match together, so a provider side rotation failed permanently. The subscription identifier decides the owner, the customer identifier is the fallback, and the write path still refuses to move an identifier to another account.
  • The charged amount was never checked. The catalogue carried a display string and no price, so a product that was pointed at the wrong plan charged one price and granted the entitlements of another. The catalogue carries the amount and the currency, and a renewal that does not match is reported.
  • The rate limit could be bypassed with a request header. The address was read from the first hop of X-Forwarded-For, which the client controls, so every attempt landed in a new bucket. The address now comes from a header the edge overwrites, and the last hop of the forwarded chain is the fallback.
  • A failed sign in never locked the account. The lockout column existed and was never written. Every failed credential check now counts against the account, the account locks after the threshold, and the response is the same as the response of a wrong password.
  • The sign up endpoint revealed which addresses are registered. It returned a conflict for an address that already existed. The response is now the same either way, and the uniqueness constraint still holds.
  • The verification resend revealed which addresses are registered. The response carried a cooldown that was only known when the address existed.
  • No cross site request protection. Every state changing route now checks the origin of the request and requires a JSON body, so a simple text/plain request cannot reach it.
  • A password longer than the 72 bytes bcrypt consumes was silently truncated, so two different passwords could be the same credential. A password that is too long is now rejected.
  • The action link of a logged email carried the raw token. The link is only written to the log when EMAIL_LOG_ACTION_LINKS=true is set.
  • The session cookie policy was never validated. A complete validation existed and was never called, so an insecure policy was accepted silently. A production deployment now refuses to start with an invalid policy, and the secure flag is required in production.
  • A production deployment could run the payment provider in test mode. It now needs the explicit DODO_PAYMENTS_ALLOW_TEST_MODE=true opt in.
  • The payment provider had no timeout, so a slow provider could hold the request open.
  • The webhook body was unbounded on an unauthenticated endpoint.
  • A webhook from the wrong environment was applied. The event now carries the provider mode and a disagreement is refused.
  • A placeholder product identifier could be sold in a production deployment.
  • The About and the Contact pages never appeared in the sidebar. The navigation group type did not include the group they were configured with.
  • The links in `docs/README.md` were broken. They resolved to docs/docs/….
  • The inline icons broke the design system. The breadcrumbs, the section headers, the accordion, the FAQ, the search dialog, the select and the overlay close button now use Tabler icons, as DESIGN.md requires.
  • The billing page had no visible title. It uses the shared page header now, like every other page.
  • The dashboard advertised two factor authentication, which is not a feature of the kit. The quick action is gone.
  • The search dialog never opened. The trigger asked for the dialog to open with an event, and a listener re-dispatched that same event, so every click recursed until the stack overflowed. The navbar owns the open state now, and a regression test fails if the request is ever dispatched from a listener.
  • The footer was inside the main element, so the site had no contentinfo landmark and the footer was inset twice.
  • The account pages were indexable and canonicalised to the home page.
  • The auth forms had no accessible name, because they pointed at elements that did not exist. Every reference now resolves, and a test keeps it that way.
  • The fallback focus ring was invisible. The global outline colour was a pale grey at 1.5:1, so the links without an explicit ring were unreachable for a keyboard user.
  • The three column grid started at 1024 pixels, where the shell leaves about 480 pixels for the content.
  • The account pages were inset twice, because the page container added a gutter the shell already owns.
  • The search dialog had no focus trap, so the tab order escaped into the page behind it.
  • The quick navigation tiles of the search dialog did nothing, because they looked for an entry in the filtered results, which are empty while the query is empty.
  • The search index had drifted and was missing three guides. It is derived from the documentation registry now.
  • The theme flashed the wrong colour, because the theme script ran at the end of the body. It runs in the document head now.
  • The stored locale was not applied to the document on the first render, so a screen reader read the page in the wrong language.
  • The public site was rendered on every request. The shell resolved the session on the server, so every marketing page was dynamic, every visitor waited for a database round trip and no page could be cached at the edge. The public site is statically generated now and the session is resolved in the browser.
  • The origin of a request was not checked on the authentication routes. Every state changing route now refuses a request that claims to come from another site.
  • There was no error boundary. A failure rendered the bare framework page without the shell and without a way forward. There is a branded boundary now, with a retry, a way home and the reference the operator needs.
  • An unknown address rendered the bare framework page. There is a root not found page now, next to the one of the public site.
  • `/onboarding` was a protected route that does not exist.
  • The billing page could not start a subscription. The plans were computed and then discarded in the empty state, and the only action linked to the page the customer was already on. The plans are rendered in both states now.
  • A plan change that could never succeed was offered as enabled. The free plan has no payment product, so a checkout for it always failed. A plan that cannot be bought is never offered as a transition.
  • The resend verification endpoint was an account enumeration oracle. A throttled request answered with a different status than a first one, which told a caller which addresses are registered. Every answer is the same now.
  • The OAuth handoff token could be redeemed by a link. The token is a bearer credential, so it is not redeemed by a plain navigation any more. The visitor confirms the sign in, and the confirmation is a request from this site. The landing path is restricted to the same allow list the provider callback uses.
  • A Google sign in could never be linked to an existing account with the in memory driver, because that driver did not know the credentials accounts. Both drivers resolve the same identity now.
  • A password change reported success when the other sessions could not be revoked. The failure is no longer swallowed, and the customer is told that the other devices may still be signed in.
  • The dashboard grid never used its layout. The column spans were applied to the card inside the section, and a column span only applies to a grid item.
  • The renewal date could show the wrong day. The label was built on the server without a time zone, so a customer west of UTC could see the day before their renewal date.
  • The raw subscription status was shown to the customer. The dashboard read "past_due" and the billing page read "canceled". The status is translated, and a subscription that needs attention no longer wears the badge of a healthy one.
  • The account summary claimed the account was unverified while it was loading. The badge is only rendered when the state is known.
  • A malformed response blanked the whole profile form and reported a successful save. The input is kept and the save is reported as unconfirmed.
  • A plan change could leave the button pending forever when the redirect did not happen.
  • The billing skeleton did not match the loaded page, so the whole page shifted when it arrived.
  • The quick actions for a trial and for an unfinished checkout were unreachable, so the wrong action was offered.
  • The sign in recovery links lost the customer's place by always returning to the dashboard.
  • The chrome copy described a different product. The shared dictionary was copied from the sibling product, so the search field asked for "tools" in every one of the seven languages. It asks for kits now.
  • The product updates confirmation could never be satisfied. An account that had turned the product updates off was asked to confirm it again on every later save, so no other preference could be saved. The confirmation belongs to the transition, and the field is only sent when it changed.
  • The reload button threw away unsaved input without a warning. It is disabled while the form has unsaved changes.
  • The payment environment, the product catalogue and the webhook environment are validated. GET /api/health reports them.
  • A placeholder product identifier was detected with a pattern, so a real Stripe price identifier was refused as a placeholder. The placeholders are now an exact list.
  • The health endpoint named the variables that are missing. It is a public endpoint, so the detail goes to the log and the response stays generic.
  • A misconfigured payment provider crashed the health endpoint instead of being reported.

v1.0.0

Released2026-10-21

The first maintained release. The repository is a production minded starter kit for SaaS, built on Next.js, Cloudflare and Dodo Payments.

Added

  • The dashboard overview endpoint. The dashboard asked for /api/dashboard/overview, which did not exist, so the dashboard was permanently in its error state. The endpoint exists now and the widgets show the real account, the real subscription and the real quick actions.
  • Transactional email. The verification and the password reset emails are actually delivered. The client speaks the Resend HTTP API over fetch, so it runs unchanged on Cloudflare Workers and adds no dependency. Outside production the message is written to the log, so a local run can complete the flow.
  • Durable rate limits. The authentication rate limits live in the database, so a limit survives an isolate eviction. The verification and the reset endpoints are rate limited too, so a token cannot be guessed.
  • OAuth handoff. A provider sign in issues a single use handoff token and a first party route exchanges it for an application session.
  • Health endpoint. GET /api/health reports the storage driver and the provider configuration without exposing a secret.
  • Starter kit catalogue. One documented kit, "Next.js on Cloudflare", with its inclusions, exclusions, prerequisites, supported versions, licence, update policy and support policy.
  • Cloudflare D1 storage layer. Repository contracts for users, OAuth linkages, sessions, password reset tokens, email verification tokens, subscriptions and webhook idempotency, with a D1 driver and an in memory driver for tests and local development.
  • Dodo Payments integration. Hosted checkout, customer portal sessions, the Standard Webhooks signature verifier, the subscription lifecycle dispatcher and the entitlement guards.
  • Session security. Rotating sessions, a replay detection lineage, hashed session tokens, single use verification and recovery tokens, and rate limits.
  • Design system. The Aback tokens, a square corner rule, the light and dark themes, the focus treatment, the shared chrome dictionary and the locale registry.
  • Application shell. A sticky navbar, a fixed sidebar that becomes a sheet on narrow viewports, a bottom pinned right rail and a footer that stops at the rail.
  • Public site. A home page, the kit catalogue, the kit detail page, pricing, the documentation guides and the legal pages.
  • Account surfaces. The dashboard, the billing page, the billing management page and the account settings pages.
  • Test suite. More than one thousand cases covering the domain services, the route handlers, the storage repositories against a real SQLite engine, the payment clients, the webhook verifier and the user interface components.
  • Operations. A Wrangler configuration with the D1 binding, the OpenNext build, the domain status command and the deployment guide.

Changed

  • The dashboard no longer shows placeholder data. The account, the subscription and the quick actions come from the signed in account.
  • Two requests that run at the same time no longer revoke each other's verification or password reset link. The revocation is bounded by the issue time of the new record.
  • The Cloudflare configuration is valid again. The local preview starts, and the lint no longer fails because the OpenNext output exists.
  • A failing email provider no longer fails the sign up. The account is created, the verification link can be requested again, and the provider failure is written to the log so an operator can see it.
  • The verification and the password reset emails are sent before the previous link is revoked. A provider that is down no longer kills the link the user already has.
  • The email provider request has a timeout, so a provider that does not answer cannot consume the whole request budget.
  • The verification link falls back to the origin of the request when APP_URL is not configured, so the link is always absolute. APP_URL is required in production, and the health endpoint reports it.
  • Every rate limit variable is documented in .env.example and in the security guide, and a test keeps the documentation honest.
  • The browser API client no longer uses axios. The application used axios without ever declaring it as a dependency, so it only worked because another package happened to install it. A small typed fetch client replaced it. The dependency surface is smaller and the build can no longer break because a transitive dependency changed.
  • The docs pages have a working copy control on every code block.

Removed

  • The unused jsdom development dependency.
  • The orphaned SectionHeading component, which duplicated the page header.
  • The billing provider moved from Stripe to Dodo Payments. The webhook verification now follows the Standard Webhooks specification and the event vocabulary follows the Dodo subscription lifecycle.
  • The product metadata no longer claims MongoDB. The database is Cloudflare D1.
  • The OAuth provider is optional. When the Google credentials are absent the build still succeeds and the application runs with credentials sign in only.
  • The NextAuth handler is created on the first request, so a build never depends on a secret being present.
  • The session middleware moved to the Next.js 16 proxy convention.

Security

  • The redirect after a provider sign in no longer accepts a path that could leave the origin. A percent encoded path segment is refused as well, so it cannot be used to reach an API path.
  • The storage repositories are resolved when they are used, not when the module is evaluated. A repository that was resolved before the Cloudflare binding was installed would have pointed at the wrong store.
  • The browser API client clears its timeout on every path. A pending timer kept the event loop alive after a failed request.
  • The email provider request clears its timeout on every path and covers the download of the body. A path with a backslash is refused, because the URL parser normalises it to a slash. The sign in, the sign out and the handoff now share one check.
  • The browser API client applies its timeout to the whole transaction, including the download of the body. A server that sends the headers and then stalls no longer leaves the form waiting forever.
  • The durable rate limit store fails closed. A missing counter can no longer silently disable a limit in production.
  • A password reset and a password change revoke the other sessions of the account, so a stolen cookie does not survive a recovery.
  • A production deployment without a Cloudflare D1 binding refuses to start. The in memory store would lose every session whenever an isolate was evicted.
  • A Google sign in that links an existing account marks the email address as verified, because the provider proved it.
  • The new token is created before the prior ones are invalidated, so a failure never leaves an account without a valid token.
  • The resend lockout is one cooldown window instead of the lifetime of the token, so a user is never locked out for a day.
  • The provider session and the application session no longer share a cookie.
  • The password is validated before the reset token is consumed, so a typo does not burn the single use token.
  • A Dodo checkout or portal redirect is only accepted when it points at a Dodo owned host and uses HTTPS. The check runs in the service, so it also applies to an injected provider.
  • The payment environment must be set explicitly in production.
  • Verification and password reset responses do not reveal whether an account exists.
  • Structured logs redact tokens, secrets, hashes, signatures and authorisation headers.