Skip to content

ddyy/minshop

Repository files navigation

minshop

An open-source store an agent can pay for itself. Agents read the catalog and pay a Lightning invoice with no human in the loop; people check out the normal way with Stripe. Merchants can run it over MCP too.

It's a small, server-rendered store for Cloudflare Workers (D1 + R2) with a full admin, multiple payment rails, and an optional MCP server, within the free tier to start. No brand is baked in: store name and time zone are set during onboarding, so it clones cleanly as a template.

Live demo (agent API at /api/products, /api/checkout). Uses test payments; do not enter real personal information.

It's intentionally lightweight and cheap to start. The default deployment uses Cloudflare Workers, D1, and R2 and can fit within their free-plan allowances; payment providers and optional services have their own pricing.

Scaffold a store

Requires Node ≥ 22.12 and Git.

npm create minshop@latest my-store
cd my-store
npm run provision:local -- --seed
npm run dev

The initializer creates a clean repository and installs both the storefront and MCP dependencies. Use --no-install to only scaffold the files, or --ref <branch-or-tag> to start from a specific revision.

Features

  • Storefront — server-rendered product list + product detail, near-zero client JS
  • Cart — HttpOnly cookie-based, with a progressively enhanced drawer plus no-JS form fallbacks
  • Search — FTS5 full-text (bm25, prefix match, typo-correct), no JS — or optional semantic search via Workers AI + Vectorize (see Search)
  • Categories — nested (arbitrary-depth tree), many-to-many with products; storefront category pages with breadcrumbs + sub-category drill-down (recursive descendant queries)
  • Admin — products, categories, pages, media, orders, customers, fulfillment, CSV export, and runtime store settings
  • Payments — Stripe Checkout, self-hosted Bitcoin Lightning (phoenixd or LNbits), hosted OpenNode, or a built-in demo rail; configure enabled methods and the default in Admin (see Payments)
  • Shipping — merchant-managed zones, flat or weight-banded rates, and free-over-threshold rules, edited in Admin with no redeploy; supported checkout flows capture the address, chosen service, and shipping cost on the order
  • Discount codes — promo-code field enabled from Admin; codes are created/managed in the Stripe Dashboard, and the applied discount is captured onto the order
  • Tax — sales tax / VAT via Stripe Tax (off by default — activate Stripe Tax in the Dashboard first); computed from the customer address and captured onto the order
  • Order email — confirmation email behind an EmailProvider seam with two adapters: Resend (HTTPS API, works on the Workers free plan) or Cloudflare Email (binding, paid plan); configured in Admin and a safe no-op until a provider is ready
  • Pages — merchant-authored Markdown pages at /pages/<slug>, with a split-view editor, live preview, and layout presets (see Pages)
  • Media library — one place that owns every uploaded file; products, pages, and the logo record how each is used (see Media library)
  • Images — uploaded to R2, served through the app (no public bucket required)
  • Swappable seams — payments and storage sit behind interfaces (see Architecture)

Stack

Piece Choice
Framework Astro (SSR) on Cloudflare Workers via @astrojs/cloudflare
Styling Tailwind CSS v4 (@tailwindcss/vite)
Data Cloudflare D1 (SQLite) — products, orders
Images Cloudflare R2 — zero egress
Payments Stripe Checkout, Bitcoin Lightning (phoenixd / LNbits), OpenNode, or demo

Quick start (local)

Requires Node ≥ 22.12 — use Node 22, the tested/supported release line.

npm install

# One shot: build, migrate + seed the local DB, and generate the two local
# secrets (SECRETS_KEK + AUTH_SECRET) into .dev.vars
npm run provision:local -- --seed

npm run dev                        # http://localhost:4321

Storefront is at /, admin at /admin — first visit lands on the setup wizard. There are no provider secrets to fill in by hand: payment/email/Turnstile keys are pasted in Admin → Settings and stored encrypted in D1 (see Settings). Granular scripts also exist (db:migrate, db:seed), npm run preview runs the built worker in prod-mode (admin gate active), and npm run destroy:local resets the local store.

Tests

npm test          # vitest run (unit tests for pure logic)
npm run test:watch
npm run test:d1   # fresh migrations + seed + built Worker against isolated D1

Covers the pure functions — slugify, the FTS search sanitizer + edit-distance, parseProductForm, image validation, cart counting, reservation target aggregation, the order-number scheme, the Access-JWT verifier, pagination clamping, and the whitelisted orderByClause sort builders (the SQL-injection boundary for sortable tables). npm run test:d1 adds clean-room D1 gates for reservation concurrency/release/settlement/legacy compatibility, then boots the production Worker against an isolated database and runs a demo checkout through paid-order settlement and confirmation. npm run verify runs both suites, full Astro diagnostics, the production build, and the MCP typecheck/deployment dry run.

Testing payments locally

Paste your sk_test_… key in Admin → Settings → Payments → Card (Stripe), then forward Stripe events to your dev server with the Stripe CLI:

stripe listen --forward-to localhost:4321/api/webhook
# paste the printed whsec_… into Settings → Payments → Card (Stripe) → webhook signing secret
stripe trigger checkout.session.completed

The order shows up in /admin (and in D1: npx wrangler d1 execute minshop-db --local --command "SELECT * FROM orders").

Deploy

One-click (Deploy to Cloudflare)

Deploy to Cloudflare

Forks the repo, provisions D1 (minshop-db) + R2 (minshop-images), applies migrations, and deploys the free-plan default. Then finish onboarding at /admin/setup (below). Cloudflare Images, semantic search, and Cloudflare Email are opt-in; their bindings are commented in wrangler.jsonc.

Fields the deploy form shows:

  • SECRETS_KEK, AUTH_SECRET (masked, required) — the only two values you must supply. Paste a fresh random value into each (they must differ): run openssl rand -base64 32 twice. The button can't generate them, and masked-field hints are hidden, so a leading label field named USE_A_LONG_RANDOM_STRING_FOR_SECRETS_KEK_AND_AUTH_SECRET carries the instruction above them; its value is unused.
  • Location hint — Cloudflare's own field for where to place the D1 database; pick the region nearest your shoppers.
  • No store name / time zone / search fields — those are runtime settings you configure in the setup wizard and Admin → Settings (stored in D1), not at deploy time. Defaults until then: My Shop / UTC / keyword (FTS) search.

CLI (one shot)

One shot — provisions a fresh, fully-independent instance (its own D1, R2, Vectorize index, Worker) and sets both Worker secrets:

npm create minshop@latest my-store
cd my-store
npx wrangler login
npm run provision:cf my-store      # scripts/provision-cf.sh <slug>

Or manually:

npx wrangler login

# Provision real resources. The committed wrangler.jsonc declares D1/R2 by NAME
# with no ids (so a one-click / Workers Builds deploy auto-provisions them); for a
# manual deploy, add the printed database_id to the "DB" entry — or let
# `wrangler deploy` create it.
npx wrangler d1 create minshop-db
npx wrangler r2 bucket create minshop-images
npm run db:migrate:remote          # applies migrations/ to the production DB

# The ONLY two Worker secrets. Everything else (Stripe, OpenNode, Lightning,
# Resend, Turnstile keys + their config) is entered in Admin → Settings and
# stored encrypted in D1 under SECRETS_KEK.
openssl rand -base64 32 | npx wrangler secret put AUTH_SECRET   # signs sessions
openssl rand -base64 32 | npx wrangler secret put SECRETS_KEK   # encrypts the key vault

npm run deploy                     # astro build && wrangler deploy

Then open the site — it funnels to the setup wizard: set the admin password (required to finish; until then /admin/setup is open, so do it right away or front /admin with Access), then paste your payment keys in Settings → Payments. Finally point a production Stripe webhook at https://<your-host>/api/webhook/stripe (events: checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, checkout.session.expired, and charge.refunded; async success fulfils delayed methods, failure/expiry releases held inventory, and charge.refunded keeps refunds made in the Stripe Dashboard — including partial ones — in sync with your order totals) and paste its whsec_… signing secret in the same card. Tear an instance down with npm run destroy:cf <slug>; reset its data in place with npm run reset:remote.

Admin auth

Both /admin and /api/admin/* are gated by src/middleware.ts. You authenticate one of two ways:

  • Setup-wizard password (built-in). On a fresh store the setup wizard at /admin/setup is reachable so you can create an admin password; it's stored as a PBKDF2 hash in D1 (there is no ADMIN_PASSWORD env var). The moment you save it the gate activates: /admin + /api/admin require sign-in at /admin/login (a form issuing an HMAC-signed, HttpOnly session cookie; /api/admin returns 401 instead of redirecting), and saving logs you in immediately. First-run is open — until a password exists, /admin/setup is publicly reachable, so set it promptly on a public deploy (or front /admin with Access, below).
    • Optional Turnstile on the login form: enable it in Admin → Settings → Bot protection (toggle + sitekey, with the secret stored in the encrypted vault) to add Cloudflare's bot challenge before the password is checked. The same toggle also gates the customer account sign-in. Cloudflare's documented always-pass test keys work for local trials.
  • Cloudflare Access (recommended for production). Edge SSO/MFA, no app passwords, and it protects first-run setup too. In the Zero Trust dashboard add a self-hosted application covering both paths your-host/admin and your-host/api/admin (the API is a separate path — gating only /admin leaves the mutation endpoints open), add an identity provider (One-time PIN is enough), and an Allow policy for your email. Set both CF_ACCESS_TEAM_DOMAIN and CF_ACCESS_AUD in wrangler.jsonc vars; they are required for Access mode so the middleware can verify every JWT signature (zero-dependency, Web Crypto — see src/features/auth/access.ts). With either variable present, requests fail closed unless both are set and a valid assertion is supplied—including direct *.workers.dev requests with no header. (Use Access or the wizard password; if a password is set, password login takes precedence.)

Forgot the admin password? The hash lives in your D1 database, so the store owner can always reset it — clearing it drops the store back into the open setup wizard:

npm run admin:reset:remote   # deployed store  (or: npm run admin:reset for local)

Then reload /admin/setup and set a new one. (Same effect as running DELETE FROM settings WHERE key='admin_password_hash'; in the Cloudflare dashboard → D1 console.) It requires Cloudflare account access, so it isn't a public reset — and if you run Cloudflare Access you're never locked out in the first place.

Local dev (astro dev) bypasses the gate so you're never locked out. Don't make /admin a "secret" path — that's security-through-obscurity; the gate is what protects it.

The Worker also applies native edge rate limits to anonymous login POSTs (10/minute per store, route, and connecting client), checkout/invoice POSTs (20/minute), and cache-missing public searches (60/minute). Search input is normalized and capped at 200 characters before FTS or Workers AI sees it; repeated catalog/search GETs are cached at the edge for 60 seconds. Webhooks and authenticated admin APIs are deliberately excluded; provider signatures and the admin gate protect those paths without disrupting legitimate retries or bulk administration. The limits are declared as AUTH_RATE_LIMITER, CHECKOUT_RATE_LIMITER, and SEARCH_RATE_LIMITER bindings in the provisioning template; existing manually maintained Wrangler configs need the same ratelimits block.

Public catalog HTML uses canonical cache keys: tracking/unknown parameters are discarded and page/sort/search controls are normalized, so equivalent URLs share one edge entry. The shell remains shared even when a shopper has cookies. Its only customer-specific header value, the cart count, loads from a tiny private fragment; the complete cart stays on private, no-store routes and is fetched only when opened.

Payments

Checkout and the webhook depend on a PaymentProvider port (src/features/payments). Every rail is configured in Admin → Settings → Payments — its keys go in the encrypted vault, its config (default rail, node URLs) in D1 settings; a rail's checkout button appears automatically once its key is set, and the always-on Demo checkout works with zero keys. The "default rail" selector picks which one is offered first:

Rail What it is Setup (all in Settings → Payments)
stripe (default) Hosted card checkout paste the secret key + webhook signing secret
lightning Bitcoin Lightning via a self-hosted node — minshop mints a BOLT11 invoice and renders its own /pay page (QR + lightning: link), then confirms settlement run phoenixd or LNbits (below); enter its URL + key
opennode Hosted Lightning checkout (custodial processor) — redirect + webhook, like Stripe paste the OpenNode API key

Two ports, nested: the outer PaymentProvider (stripe / lightning / opennode) and an inner LightningBackend (phoenixd / lnbits) shared by the self-rendered flow. Adding another node is one adapter file.

How settlement is trusted. Lightning webhooks are treated as an untrusted nudge — on receipt minshop re-polls the node for the payment (the authority), so a forged webhook can't fake a sale. The /pay page also settles on load by polling, so it works even with no public webhook (e.g. local dev). Orders stay "paid-only": unpaid invoices live in a pending_payments table, never in orders.

Shipping (in-app rails). When shipping is enabled, the Lightning, OpenNode, and Demo carts route through minshop's own /checkout page — a server-rendered address + shipping-option step priced from the Admin-managed zones (see Shipping) — so the invoice total includes shipping and the address + email land on the order, same as Stripe. The Demo rail arrives with a sample address prefilled (it ships nowhere; the step exists to exercise the real rate configuration). Programmatic checkout accepts the same address as a ship_to object. (Stripe keeps its own hosted address/shipping collection, unchanged.)

Limitations (Lightning). Tax and promo codes are still Stripe-Checkout features and are skipped on the Lightning path — to charge tax on a non-Stripe rail you'd compute it yourself (e.g. Stripe's Tax Calculation API) and add it to the total. No automatic refunds (Lightning can't reverse in place). Invoices are priced in sats from a BTC spot rate fetched at checkout (payments.lightning.rateUrl, default Coinbase — no key).

Running phoenixd (recommended self-hosted backend)

phoenixd is ACINQ's self-custodial headless daemon — no full Bitcoin node, no manual channel management (ACINQ is the LSP), mainnet (real sats).

  1. Run phoenixd on an always-on host (small VPS / Pi). First launch writes a seed to ~/.phoenix/ — back it up.
  2. Expose its API (127.0.0.1:9740) to the Worker over HTTPS — a Cloudflare Tunnel (cloudflared) is the cleanest, no open ports.
  3. In Settings → Payments → Lightning, pick the phoenixd node, enter its public URL, and paste the password — use the http-password-limited-access value (~/.phoenix/phoenix.conf): receive-only, can't spend.
  4. Point phoenixd's webhook-url at https://<your-host>/api/webhook/lightning.

LNbits instead: pick LNbits in the same card and enter its URL + the wallet's Invoice/read key. OpenNode: paste the API key in its card and set the default rail to OpenNode.

Architecture

Organized as feature folders (vertical slices) — each owns its data access, types, and components.

src/
  config.ts                  build-time defaults and feature toggles
  store.config.ts            per-store build-time overrides
  layouts/Layout.astro
  features/
    auth/       admin, customer sessions, Access, Turnstile
    media/      the shared file library — the only feature that deletes objects
    pages/      Markdown pages: rendering, slugs, layout presets, save/media sync
    products/   catalog data, forms, inventory, image handling
    orders/     order data, reservations, tracking
    payments/   provider port + Stripe, Lightning, OpenNode, demo adapters
    settings/   runtime D1 settings
    storage/    provider port + R2 adapter
  pages/
    index.astro               storefront
    products/index.astro       catalog list (also rendered at /)
    products/[slug].astro
    categories/[slug].astro
    pages/[slug].astro        merchant-authored Markdown pages
    product/[slug].astro      301 → /products/[slug] (retired URL)
    category/[slug].astro     301 → /categories/[slug] (retired URL)
    admin/                    products, categories, pages, media, orders, customers, settings
    images/[...key].ts        serves private R2 objects
    api/                      catalog, checkout, webhooks, admin mutations

Two ports-and-adapters seams keep vendor code at the edges:

  • PaymentProvider — checkout and webhook routes depend on the interface rather than a single provider. Adding another rail means implementing an adapter and wiring its settings and webhook route.
  • StorageProvider — the app stores and serves images by key; R2 is one adapter.

Deliberately no runtime plugin system — Workers bundle at build time, so composition is just imports + a factory.

Database migrations

Schema lives in migrations/ as numbered, additive SQL files (D1 tracks which have run, so deploys only apply new ones). To evolve the schema, add a new file — never edit an applied one:

npx wrangler d1 migrations create minshop-db add_product_sku   # writes migrations/0002_…
# edit it (ALTER TABLE / CREATE TABLE — additive, no destructive DROP)
npm run db:migrate            # local
npm run db:migrate:remote     # production

seed.sql is dev-only sample data (re-runnable), kept separate from schema.

Settings

Most settings are runtime — changed in /admin/settings, stored in a D1 settings table, applied instantly, no redeploy. Secret keys are stored in the same table AES-256-GCM-encrypted under the SECRETS_KEK Worker secret (write-only from the UI, never echoed back), which is what makes an instance fully configurable — and clonable — from the dashboard:

  • General — store name.
  • Features — cart/checkout, "Buy now", discounts, tax, customer accounts, shipping, and upload optimization.
  • Images — original delivery or responsive Cloudflare on-demand transformations.
  • Payments — one card per rail (Stripe / Lightning / OpenNode / Demo): its on/off toggle, config (node URLs, default rail), and keys.
  • Email — on/off, provider (Resend / Cloudflare), from-address, key, send-test button.
  • Bot protection — Turnstile toggle + sitekey + secret.
  • Search — keyword (FTS5) vs semantic (Workers AI + Vectorize) + reindex.

A few data-coupled settings stay build-time (change + npm run deploy):

Setting Where Effect
Currency currency in src/store.config.ts One store-wide currency — prices are stored as integer minor-units bound to it, so it can't safely flip at runtime
Image width images.maxWidth in src/store.config.ts Maximum width used when image optimization is enabled in Admin
Order number orderNumber.{offset,step,randomStep} in src/store.config.ts Friendly customer-facing number derived from the internal id (e.g. #1000). step spaces them out; randomStep adds jitter to obscure the count (keep step > randomStep). The URL/security uses the random public_id, not this number
Favicon replace public/favicon.svg and regenerate public/favicon.ico Browser tab icon with a legacy-client fallback

Change a value, then npm run deploy. Currency uses Intl.NumberFormat, so usd → $, eur → €, gbp → £, jpy → ¥ (decimals handled per-currency).

Optional upload optimization

The default deployment stores original uploads in R2 and serves them through the Worker. Astro uses its passthrough image service, so deploying minshop does not automatically provision an IMAGES binding.

To optimize new uploads:

  1. Uncomment "images": { "binding": "IMAGES" } in wrangler.jsonc (or add the same binding to a provisioned instance config).
  2. Run npm run deploy.
  3. Enable Optimize images on upload in Admin → Settings. Optionally override images.maxWidth in src/store.config.ts.

Cloudflare Images transformations are available on Free and Paid plans. The Free plan currently includes 5,000 unique transformations per month; when the binding is missing or a transformation fails, minshop stores the original upload instead. Existing R2 objects are not retroactively transformed by this upload-time option.

Serving images off the Worker (R2 custom domain)

By default all uploads — product images, page images, and the logo — are served through the Worker's /images/<key> route. That's zero-config, but every image request still invokes the Worker (the caches.default edge cache saves the R2 fetch, not the invocation). For higher traffic, serve images straight from R2 so they bypass the Worker entirely — Cloudflare CDN-caches them and your Worker request count drops to page + API traffic only.

  1. Connect a public custom domain to the bucket: Cloudflare dashboard → R2 → your bucket → Settings → Public access → Connect a custom domain (e.g. images.example.com, on a zone in your account). Set Minimum TLS to 1.2. This makes the bucket's objects publicly readable through that domain — fine for product images, but don't point it at a bucket holding anything private.
  2. Add the var to wrangler.jsonc (absolute, no trailing slash) and redeploy:
    "vars": { "IMAGE_BASE_URL": "https://images.example.com" }

Every image URL — storefront, product galleries, /api/products, order emails, Stripe line items, page bodies, and the header logo — then points at that domain. Page bodies store the root-relative /images/<key> form and the origin is applied at render time, so changing IMAGE_BASE_URL never requires rewriting content. Unset, images keep serving through the Worker route (nothing else changes).

Caching: uploads store a 1-year immutable Cache-Control (keys are unique per file), so the R2 domain serves them with that TTL. Objects uploaded before this change — or via wrangler r2 object put without --cache-control — keep R2's shorter default TTL until re-uploaded (e.g. --cache-control 'public, max-age=31536000, immutable') or covered by a Cache Rule.

Responsive on-demand images

With an R2 custom domain configured, the storefront can serve a responsive 128/384/768/1024 width ladder through Cloudflare Image Transformations. The browser gets AVIF, WebP, or a standard format according to its Accept header; email receipts use one fixed 96px JPEG thumbnail URL. Originals remain in R2 and are the fallback if a transformation fails.

  1. In the Cloudflare dashboard, open Images → Transformations, select the storefront zone, and enable transformations.
  2. Under Sources, allow the R2 image hostname configured by IMAGE_BASE_URL (for example, images.example.com). Keep sources restricted rather than allowing arbitrary origins.
  3. In Admin → Settings → Images, select Cloudflare on-demand.

The URL interface does not require an IMAGES Worker binding. That binding is only needed for the separate Optimize images on upload feature. Existing product images work immediately without a backfill, including legacy slug-named objects. New uploads and replacements always receive unique object keys, so a source URL never serves different bytes and needs no cache-busting parameter.

Each source-and-parameter combination is a unique transformation. The application emits only its fixed width ladder and uses onerror=redirect, but /cdn-cgi/image/ parameters remain public. Review Cloudflare Images pricing before enabling paid usage; a preset-only Worker route or WAF rules are reasonable hardening for a high-traffic paid deployment.

Shipping and order email

  • Shipping — managed at Admin → Shipping (zones, rates, free-over thresholds, packaging weight). Each zone has countries (ISO alpha-2, or "Rest of world", matched top-to-bottom), one or more rates, and an optional free-shipping threshold. A rate is priced either flat or by order weight (ordered bands: the shipment weight selects one band, they are not accumulated). src/store.config.ts supplies the defaults for a brand-new store and is ignored once shipping is saved in Admin.

    A zone offers at most five options including a synthesized "Free shipping" — Stripe Checkout's maximum, applied to every rail so all rails quote identically.

  • Shipping weight — set a product's packed weight (and a per-variant override; blank inherits) on the product form. Weights are stored in grams and entered in the store's unit, set in the Admin → Settings shipping card — it defaults from the store's time zone (lb for US stores, g elsewhere), and changing it only changes display: stored grams re-render converted. Uncheck Requires shipping for digital goods so they are excluded from the shipment weight instead of blocking checkout. An unknown weight is never treated as zero: weight-priced services are withheld and, if nothing else can carry the order, checkout is blocked and the products are named.

  • Email — configured in Admin → Settings → Email (on/off, provider, from-address; the key in the encrypted vault). Unconfigured it's a safe no-op — checkout still succeeds. A Send test email button verifies real delivery. Two providers:

    • Resend (default, works on the Workers free plan — a plain HTTPS call): get a free key at resend.com, paste it in Settings → Email, and set the from-address (a Resend-verified domain, or onboarding@resend.dev to test to your own address).
    • Cloudflare (Workers Paid plan): onboard a sender domain, add the commented send_email binding from wrangler.jsonc, redeploy, then pick Cloudflare in Settings → Email with a from-address on that domain. The section flags whether the binding is wired.

Media library

Every upload lands in Admin → Media, whichever screen it came from — the product form, a product gallery, the page editor, or the logo picker. One file can then be reused anywhere without uploading it twice.

The library owns the files. Products, pages, and the logo only record how a file is used:

  • Removing an image from a product gallery, deleting a product, replacing a photo, or deleting a page removes the association only. The file stays.
  • Only Admin → Media deletes an object, and it refuses while anything still references it — the grid links straight to the product, page, or Settings that is holding it.
  • That's why the file survives being removed from one product: it may be the logo, or in a page, or on another product.

Uploads accept JPEG, PNG, WebP, and GIF up to 5 MB. SVG is not accepted — uploads are served from the store's own origin, where an SVG is a script-execution vector.

Existing product images are adopted into the library by migration 0026 without moving or renaming a single R2 object. Those rows predate the library, so they carry no file type or size and the grid shows them as Legacy image; everything else about them works normally.

Deleting a file is the one action with no undo. If you remove an image from a product and then delete it from Media within the same minute, a cached storefront page can briefly show a broken image (see the 60-second note under Pages).

Pages

Merchant-authored content — About, Shipping, Returns, Privacy — written in Markdown at Admin → Pages and served at /pages/<slug>. The prefix keeps page slugs in their own namespace, so a page can never collide with a storefront route.

Nothing to enable: an empty table means the feature is simply unused, and no page links appear anywhere.

Editing. The editor has three views — Markdown, Split, Preview — remembered between pages. Split shows the source beside a live preview that renders through the same server-side renderer the storefront uses, so what you see is what will ship rather than an approximation. Clicking anywhere in the preview selects the word that produced it in the source; clicking an image selects its whole ![alt](src) span. A toolbar inserts headings, bold, italic, links, lists, quotes, code, and images chosen from the media library.

Safety. Raw HTML in a page body is escaped, not parsed (html: false). A page cannot inject a <script>, an iframe, or an event handler — not even from a compromised admin session — and markdown-it refuses javascript: and vbscript: link targets. This is a property of the parser, not a filter that has to be kept correct.

Layout presets. Each page picks Standard (narrow, left title), Editorial (narrow, centred title), or Wide (full width, for size charts and image grids). Adding a preset is one entry in src/features/pages/layouts.ts — the dropdown, validation, storefront, and preview all derive from it, and no CSS is needed.

Drafts and publishing. A page is a draft until published. Drafts 404 on the storefront. Published pages appear in the footer (up to 50, ordered by title), the sitemap, and llms.txt.

Missing images. Your text always saves. What is blocked is a draft going live with images that would render broken — the save is kept and publishing is refused with a warning naming the count. An already-published page is never silently unpublished over a missing image; it stays live and the warning tells you to fix it.

As the home page. / shows the product list by default; Admin → Settings → Home page can point it at a published page or an active product instead. The catalog keeps its own URL at /products, and the header gains a Shop link to it whenever / has been taken over — otherwise the product list would be reachable only through search or a category. The chosen target is stored by id, so renaming it does not break the setting, and anything unresolvable — unpublished, deactivated, deleted — falls back to the product list rather than serving a 404 homepage. The content keeps one canonical URL (/pages/<slug> or /products/<slug>), so serving it at / does not create a second indexable copy.

Caching. Storefront HTML is cached at the edge for 60 seconds, so a published edit — like any settings or catalog change — can take up to a minute to appear. Draft and missing pages send no-store, so publishing is visible immediately even to someone who hit the URL beforehand.

Theming

Brand color, accent, font, and corner radius are design tokens in src/styles/theme.css (a Tailwind v4 @theme block) — rebrand a clone by editing a few values, no component changes:

@theme {
  --color-brand: #111827;        /* solid buttons, focus ring */
  --color-brand-hover: #374151;  /* its hover shade */
  --color-accent: #2563eb;       /* text links */
  --font-sans: ...;
}

These generate the bg-brand / border-brand / text-accent utilities used across the UI, so changing one value re-skins every button and link at once. There is no runtime templating or theme engine: the components are the template, and tokens are the knobs.

Gotchas

Collected while building (the kind of thing that costs an afternoon):

  • Node ≥ 22.12 required — use Node 22, the tested/supported release line.
  • Bindings via import { env } from 'cloudflare:workers'Astro.locals.runtime.env was removed in Astro v6.
  • No main / assets in wrangler.jsonc — the Cloudflare adapter supplies the worker entry itself; setting main breaks a clean build.
  • CSRF is on by default — Astro rejects cross-origin form POSTs (403). Real browsers send Origin and pass; only scripted requests need to set it.
  • Webhooks need constructEventAsync — the sync verifier uses Node crypto, which isn't available on Workers.
  • One local dev server at a time — two astro dev instances share the same .wrangler local-D1 state and race, surfacing as transient no such table errors. Use one server, or pass a separate persist dir.
  • wrangler d1 export fails with FTS5 virtual tables — D1 can't export a DB that has virtual tables. To back up: drop products_fts, export, then recreate it (re-run migration 0003).
  • FTS5 MATCH throws on raw input — special chars (-, ", :, *) cause fts5: syntax error. Sanitize to alphanumeric prefix tokens before querying (see features/products/search.ts).

Search

Product search sits behind a SearchProvider seam (features/search). Choose the active backend in Admin → Settings → Search (or use the SEARCH_PROVIDER build-time fallback):

  • fts (default) — SQLite FTS5 keyword search: $0, fully local, exact-match + typo-correction ("did you mean"). Nothing to set up.
  • vectorsemantic search via Workers AI embeddings + Vectorize: matches by meaning ("something to drink coffee from" → mug), and powers similarity. Needs the AI + Vectorize bindings, so:
# 1. Create the index (dimensions must match the model — bge-base = 768)
wrangler vectorize create minshop-products --dimensions=768 --metric=cosine
# 2. Uncomment the "ai" + "vectorize" bindings in wrangler.jsonc
# 3. Select semantic search in Admin → Settings → Search
# 4. Use the Reindex button there to backfill existing products

The same embeddings also power "you may also like" on product pages — semantic similarity (the product's nearest neighbours in Vectorize), falling back to category-based related when vector search is off. After enabling, products are embedded automatically on create/update (and removed on delete). If vector is selected but the bindings are missing, it falls back to FTS rather than breaking. FTS stays available either way — keep it for exact/SKU lookups. Cost: Workers AI is pay-per-inference (cheap embeddings, free daily allocation) and Vectorize is usage-billed beyond a free tier — so semantic search trades the strict $0 for meaning-based matching.

Accounts (passwordless customer login)

Optional customer accounts via magic-link sign-in — no passwords, so no hashing/reset/breach liability. Enable them in Admin → Settings; guest checkout is unaffected (login is only for order history). When on they need the AUTH_SECRET secret (signs the login token + session cookie) and email configured (to send the link).

How it works: /account/login emails a single-use, 15-min link → /account/verify sets a 30-day signed session cookie → /account lists the customer's orders (queried by their verified email — which orders already store, so there's barely any new schema). Reuses the Web-Crypto HMAC token primitive and the EmailProvider seam; swapping to OAuth = replacing features/auth/customer.ts.

# needs: the AUTH_SECRET worker secret (provision scripts set it automatically)
#        + email configured in Admin → Settings → Email
# enable: Admin → Settings → Customer accounts

In local dev the magic link is also logged to the server console, so you can test without email delivery.

MCP server (operate the store from an assistant)

mcp/ is a standalone Cloudflare Worker that exposes store operations as MCP tools, so compatible MCP clients can run the store conversationally — "what was revenue this week?", "mark order 1142 shipped", "create a product". It binds the same D1 as the storefront and reuses features/*/db.ts (no duplicated logic).

Why a separate Worker (own mcp/package.json, own node_modules): the Astro adapter owns the storefront Worker's entry, and the Agents SDK pulls in workerd/miniflare deps that perturb Astro's build if hoisted into the root tree. Keeping it a sibling Worker isolates both.

Tools: list_products, get_product, list_orders, get_order, order_stats, daily_totals (reads) + create_product, update_product, fulfill_order (writes).

The two list tools accept limit and offset and return the page alongside total, limit, and offset, so clients can walk large catalogs and order histories without one oversized response.

Auth: a bearer token. Set MCP_TOKEN; clients send Authorization: Bearer <token>. Unset = the server returns 503 (fail-closed). (Cloudflare's Workers OAuth Provider is the upgrade for scoped/multi-user access.)

cd mcp && npm install && cd ..
cp mcp/.dev.vars.example mcp/.dev.vars      # set MCP_TOKEN
npm run mcp:dev                              # local, shares the storefront's local D1
npm run mcp:check                            # dry-run build
# deploy: wrangler secret put MCP_TOKEN --config mcp/wrangler.jsonc ; npm run mcp:deploy

In production both Workers bind the same D1 (by database_id), so the MCP server operates the real store automatically. Connect any MCP client to https://<your-mcp-host>/mcp (streamable HTTP) with the bearer header. Note: write tools (create/fulfill) change live data — guard the token like a password.

Agent API (catalog + checkout)

A small, public, machine-readable API so an AI agent can browse and buy without scraping HTML or driving a browser — the "agent shops the store" counterpart to the MCP server's "agent operates the store". Clean JSON, absolute URLs, open CORS.

Method & path Purpose
GET /api/products Active catalog. ?q= (uses the active search backend — semantic when on), ?limit= (1–100, default 24), ?offset=
GET /api/products/:slug One product as JSON (404 if missing/inactive)
GET /api/checkout Available payment methods and the current default
POST /api/checkout Programmatic checkout. Body { "items": [{ "slug", "quantity" }] }{ checkout_url, … }

Each product is self-describing, price in both major + minor units:

{ "slug": "merino-wool-beanie", "name": "Merino Wool Beanie",
  "price": { "amount": 32, "cents": 3200, "currency": "USD" },
  "in_stock": true, "categories": ["Apparel"],
  "image": "https://…/images/products/merino-wool-beanie.webp",
  "url": "https://…/products/merino-wool-beanie" }

POST /api/checkout recalculates price and stock from D1. Set method to one of the values returned by GET /api/checkout; omitting it uses the default. Stripe and OpenNode return flow: "redirect" with a hosted checkout_url. Lightning returns flow: "invoice", a browser/QR fallback in checkout_url, and a directly payable BOLT11 invoice:

{
  "method": "lightning",
  "items": [
    { "slug": "merino-wool-beanie", "quantity": 1, "variant_id": 12, "extras": [3] }
  ],
  "ship_to": {
    "email": "buyer@example.com",
    "name": "Example Buyer",
    "line1": "123 Main Street",
    "line2": null,
    "city": "Portland",
    "state": "OR",
    "postal": "97205",
    "country": "US"
  },
  "shipping_label": "Standard"
}

When shipping is enabled, ship_to is required for every rail that collects the address itself (Lightning, OpenNode, Demo); shipping_label is optional and defaults to the first rate for ship_to.country. For Stripe, pass an optional ship_country (ISO alpha-2) instead — the session is priced for that country and Stripe collects an address only there; without it the first Stripe-supported configured country is used. When shipping is off, omit these fields. A successful Lightning response includes:

{
  "flow": "invoice",
  "checkout_url": "https://shop.example/pay/…",
  "lightning": {
    "invoice": "lnbc…",
    "amount_sat": 12345,
    "payment_hash": "",
    "expires_at": "2026-07-23T20:30:00.000Z"
  },
  "shipping_cents": 500,
  "total_cents": 3700
}

The order is recorded only after the existing settlement verifier confirms payment. The browser form checkout is unchanged; the JSON path triggers only on Content-Type: application/json.

Demo — an agent shops the store

node scripts/agent-demo.mjs https://<your-host> "warm hat" 40
# Search "warm hat" under 40: 3 in-stock candidate(s)
#   USD 32  Merino Wool Beanie  [merino-wool-beanie]   ← picked (most relevant in budget)
# → prints the full Stripe checkout URL

Or just ask any tool-using LLM:

"Using <host>/api/products, find a warm hat under $40, then POST /api/checkout with {items:[{slug,quantity}]} and give me the checkout URL."

Complete payment with the Stripe test card 4242 4242 4242 4242 (any future expiry/CVC/postal) — prod runs Stripe test keys, so nothing is charged; the order then appears in /admin → Orders.

Gotcha: the checkout URL's #-fragment carries the session token — copy/print it whole. A truncated link yields Stripe's "Something went wrong".

Cost

The default Worker, D1 database, and R2 bucket can run within Cloudflare's free-plan allowances for a small store. Usage above those allowances is billed under Cloudflare's current pricing. Payment processors, email providers, semantic search, and optional image transformations may have separate free allowances or fees, so review the services you enable before going live.

About

Full-stack ecommerce on Cloudflare Workers, D1, and R2 - with admin, Stripe, OpenNode, Lightning payments, and agent-ready APIs.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages