Rebasely Beta: data may be purged

examples/storefront Public

Start the storefront API with a product catalog on D1

Products are listed by name, read one at a time, and written by the merchant through PUT /admin/products/:id with the admin token. Tests run the real migrations against node:sqlite.
7b21f05d632695e7bef4ee4f1822148dea223f23
The first commit Browse filesHistory

Changed files

17 files changed958 additions0 deletionsin the first commit

  1. Added.gitignore+3−0
  2. AddedLICENSE+21−0
  3. AddedREADME.md+8−0
  4. Addedmigrations/0001_products.sql+14−0
  5. Addedpackage-lock.json+420−0
  6. Addedpackage.json+22−0
  7. Addedsrc/app.ts+49−0
  8. Addedsrc/catalog.ts+96−0
  9. Addedsrc/env.ts+5−0
  10. Addedsrc/http.ts+64−0
  11. Addedsrc/index.ts+8−0
  12. Addedsrc/router.ts+50−0
  13. Addedtest/catalog.test.ts+56−0
  14. Addedtest/helpers/app.ts+35−0
  15. Addedtest/helpers/d1.ts+74−0
  16. Addedtsconfig.json+16−0
  17. Addedwrangler.jsonc+17−0
Added.gitignore+3−0
@@ -0,0 +1,3 @@
+node_modules/
+.wrangler/
+.dev.vars
View file
AddedLICENSE+21−0
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Justin Torre
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
View file
AddedREADME.md+8−0
@@ -0,0 +1,8 @@
+# storefront
+
+A storefront API on Cloudflare Workers. Products live in D1.
+
+```bash
+npm install
+npm test
+```
View file
Addedmigrations/0001_products.sql+14−0
@@ -0,0 +1,14 @@
+-- Products: the catalog. Prices are whole cents in the shop's one currency (src/money.ts).
+CREATE TABLE products (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ price_cents INTEGER NOT NULL CHECK (price_cents > 0),
+ stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
+ image_key TEXT,
+ active INTEGER NOT NULL DEFAULT 1,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+
+CREATE INDEX products_by_name ON products (active, name, id);
View file
Addedpackage-lock.jsonLong diff, collapsed+420−0
@@ -0,0 +1,420 @@
+{
+ "name": "storefront",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "storefront",
+ "version": "0.1.0",
+ "license": "MIT",
+ "devDependencies": {
+ "@cloudflare/workers-types": "^5.20261016.1",
+ "@types/node": "^24.19.2",
+ "typescript": "^7.0.2"
+ },
+ "engines": {
+ "node": ">=24"
+ }
+ },
+ "node_modules/@cloudflare/workers-types": {
+ "version": "5.20261016.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20261016.1.tgz",
+ "integrity": "sha512-I8OJdPl3M6TIl9ksU3wtEyWVhSE8E8txJ/vGOv3SkhjcvN8uMjyFmMvRsNOZgzMe5xYFQ0LctmiGqTwR7DbZ6w==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0"
+ },
+ "node_modules/@types/node": {
+ "version": "24.19.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
+ "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
View file
Addedpackage.json+22−0
@@ -0,0 +1,22 @@
+{
+ "name": "storefront",
+ "version": "0.1.0",
+ "private": true,
+ "description": "A storefront API on Cloudflare Workers: products, carts and checkout on D1.",
+ "license": "MIT",
+ "type": "module",
+ "engines": {
+ "node": ">=24"
+ },
+ "scripts": {
+ "test": "node --disable-warning=ExperimentalWarning --test \"test/**/*.test.ts\"",
+ "typecheck": "tsc",
+ "dev": "npx wrangler dev",
+ "db:migrate": "npx wrangler d1 migrations apply storefront --local"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "^5.20261016.1",
+ "@types/node": "^24.19.2",
+ "typescript": "^7.0.2"
+ }
+}
View file
Addedsrc/app.ts+49−0
@@ -0,0 +1,49 @@
+// The storefront's HTTP API. createApp takes the environment and the few things tests replace
+// (the clock and id generation) and returns a fetch handler.
+
+import { getProduct, listProducts, parseProductInput, upsertProduct } from "./catalog.ts";
+import type { Env } from "./env.ts";
+import { errorResponse, HttpError, json, readJson, requireAdmin } from "./http.ts";
+import { Router } from "./router.ts";
+
+export type Deps = {
+ now: () => number;
+ newId: () => string;
+};
+
+export const defaultDeps: Deps = { now: Date.now, newId: () => crypto.randomUUID() };
+
+export function createApp(env: Env, deps: Deps = defaultDeps) {
+ const r = new Router();
+
+ r.on("GET", "/products", async () => json({ products: await listProducts(env.DB) }));
+
+ r.on("GET", "/products/:id", async (_req, p) => {
+ const product = await getProduct(env.DB, p.id!);
+ if (!product || !product.active) throw new HttpError(404, "not_found", "No such product.");
+ return json({ product });
+ });
+
+ r.on("PUT", "/admin/products/:id", async (req, p) => {
+ requireAdmin(req, env.ADMIN_TOKEN);
+ const input = parseProductInput(p.id!, await readJson(req));
+ return json({ product: await upsertProduct(env.DB, input, deps.now()) });
+ });
+
+ return {
+ async fetch(req: Request): Promise<Response> {
+ const url = new URL(req.url);
+ try {
+ const hit = r.match(req.method, url.pathname);
+ if (hit === null) throw new HttpError(404, "not_found", "No such route.");
+ if (hit === "method_not_allowed") throw new HttpError(405, "method_not_allowed", `${req.method} is not allowed here.`);
+ return await hit.handler(req, hit.params);
+ } catch (e) {
+ if (e instanceof HttpError) return errorResponse(e);
+ if (e instanceof URIError) return errorResponse(new HttpError(400, "bad_path", "The path is not valid."));
+ console.error(JSON.stringify({ msg: "unhandled error", path: url.pathname, error: String(e) }));
+ return errorResponse(new HttpError(500, "internal", "Something went wrong."));
+ }
+ },
+ };
+}
View file
Addedsrc/catalog.ts+96−0
@@ -0,0 +1,96 @@
+// The product catalog: what the shop sells, at what price, and how many are left.
+
+import { HttpError } from "./http.ts";
+
+export type Product = {
+ id: string;
+ name: string;
+ description: string;
+ price_cents: number;
+ stock: number;
+ image_url: string | null;
+ active: boolean;
+};
+
+type Row = {
+ id: string;
+ name: string;
+ description: string;
+ price_cents: number;
+ stock: number;
+ image_key: string | null;
+ active: number;
+};
+
+const COLUMNS = "id, name, description, price_cents, stock, image_key, active";
+const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
+
+export function toProduct(r: Row): Product {
+ return {
+ id: r.id,
+ name: r.name,
+ description: r.description,
+ price_cents: r.price_cents,
+ stock: r.stock,
+ image_url: r.image_key ? `/images/${r.image_key}` : null,
+ active: r.active === 1,
+ };
+}
+
+// Active products by name. The first 100 for now; a catalog bigger than that needs pages.
+export async function listProducts(db: D1Database): Promise<Product[]> {
+ const { results } = await db
+ .prepare(`SELECT ${COLUMNS} FROM products WHERE active = 1 ORDER BY name, id LIMIT 100`)
+ .all<Row>();
+ return results.map(toProduct);
+}
+
+// Any product, active or not. Shopper routes check `active` themselves.
+export async function getProduct(db: D1Database, id: string): Promise<Product | null> {
+ const row = await db.prepare(`SELECT ${COLUMNS} FROM products WHERE id = ?`).bind(id).first<Row>();
+ return row ? toProduct(row) : null;
+}
+
+export type ProductInput = {
+ id: string;
+ name: string;
+ description: string;
+ price_cents: number;
+ stock: number;
+ active: boolean;
+};
+
+export function parseProductInput(id: string, body: Record<string, unknown>): ProductInput {
+ if (!SLUG.test(id) || id.length > 64) {
+ throw new HttpError(400, "bad_id", "A product id is a lowercase slug of at most 64 characters.");
+ }
+ const { name, description = "", price_cents, stock = 0, active = true } = body;
+ if (typeof name !== "string" || !name.trim() || name.length > 120) {
+ throw new HttpError(400, "bad_name", "name is a string of 1 to 120 characters.");
+ }
+ if (typeof description !== "string" || description.length > 2000) {
+ throw new HttpError(400, "bad_description", "description is a string of at most 2,000 characters.");
+ }
+ if (!Number.isInteger(price_cents) || (price_cents as number) <= 0) {
+ throw new HttpError(400, "bad_price", "price_cents is a whole number of cents above zero.");
+ }
+ if (!Number.isInteger(stock) || (stock as number) < 0) {
+ throw new HttpError(400, "bad_stock", "stock is a whole number, zero or more.");
+ }
+ if (typeof active !== "boolean") throw new HttpError(400, "bad_active", "active is true or false.");
+ return { id, name: name.trim(), description, price_cents: price_cents as number, stock: stock as number, active };
+}
+
+export async function upsertProduct(db: D1Database, p: ProductInput, now: number): Promise<Product> {
+ await db
+ .prepare(
+ `INSERT INTO products (id, name, description, price_cents, stock, active, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (id) DO UPDATE SET
+ name = excluded.name, description = excluded.description, price_cents = excluded.price_cents,
+ stock = excluded.stock, active = excluded.active, updated_at = excluded.updated_at`,
+ )
+ .bind(p.id, p.name, p.description, p.price_cents, p.stock, p.active ? 1 : 0, now, now)
+ .run();
+ return (await getProduct(db, p.id))!;
+}
View file
Addedsrc/env.ts+5−0
@@ -0,0 +1,5 @@
+// The bindings and secrets of the storefront-api Worker (wrangler.jsonc).
+export interface Env {
+ DB: D1Database;
+ ADMIN_TOKEN: string;
+}
View file
Addedsrc/http.ts+64−0
@@ -0,0 +1,64 @@
+// JSON responses and the errors the API answers with. Every error body is
+// { error, message } plus whatever details the error carries.
+
+export class HttpError extends Error {
+ readonly status: number;
+ readonly code: string;
+ readonly details: Record<string, unknown>;
+
+ constructor(status: number, code: string, message: string, details: Record<string, unknown> = {}) {
+ super(message);
+ this.status = status;
+ this.code = code;
+ this.details = details;
+ }
+}
+
+export function json(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json; charset=utf-8", ...headers },
+ });
+}
+
+export function errorResponse(e: HttpError): Response {
+ return json({ error: e.code, message: e.message, ...e.details }, e.status);
+}
+
+const MAX_BODY = 64 * 1024;
+
+export async function readJson(req: Request): Promise<Record<string, unknown>> {
+ if (!(req.headers.get("content-type") ?? "").startsWith("application/json")) {
+ throw new HttpError(415, "unsupported_media_type", "Send the body as application/json.");
+ }
+ const text = await req.text();
+ if (text.length > MAX_BODY) throw new HttpError(413, "too_large", "The body is at most 64 KiB.");
+ let value: unknown;
+ try {
+ value = JSON.parse(text);
+ } catch {
+ throw new HttpError(400, "bad_json", "The body is not valid JSON.");
+ }
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ throw new HttpError(400, "bad_json", "The body must be a JSON object.");
+ }
+ return value as Record<string, unknown>;
+}
+
+// Merchant routes (/admin/*) take the shop's admin token as a bearer token.
+export function requireAdmin(req: Request, token: string | undefined): void {
+ const got = req.headers.get("authorization") ?? "";
+ if (!token || !sameBytes(got, `Bearer ${token}`)) {
+ throw new HttpError(401, "unauthorized", "Merchant routes need the admin token.");
+ }
+}
+
+// Compares in time that depends only on the length, so the token can't be guessed byte by byte.
+function sameBytes(a: string, b: string): boolean {
+ const x = new TextEncoder().encode(a);
+ const y = new TextEncoder().encode(b);
+ if (x.length !== y.length) return false;
+ let diff = 0;
+ for (let i = 0; i < x.length; i++) diff |= x[i]! ^ y[i]!;
+ return diff === 0;
+}
View file
Addedsrc/index.ts+8−0
@@ -0,0 +1,8 @@
+import { createApp } from "./app.ts";
+import type { Env } from "./env.ts";
+
+export default {
+ fetch(req, env) {
+ return createApp(env).fetch(req);
+ },
+} satisfies ExportedHandler<Env>;
View file
Addedsrc/router.ts+50−0
@@ -0,0 +1,50 @@
+// A small method and path router. Patterns use :name for one segment and a final * for the
+// rest of the path, which arrives as params.rest.
+
+export type Params = Record<string, string>;
+export type Handler = (req: Request, params: Params) => Promise<Response> | Response;
+
+type Route = { method: string; re: RegExp; keys: string[]; handler: Handler };
+
+export type Match = { handler: Handler; params: Params } | "method_not_allowed" | null;
+
+export class Router {
+ private readonly routes: Route[] = [];
+
+ on(method: string, pattern: string, handler: Handler): this {
+ const keys: string[] = [];
+ const source = pattern
+ .split("/")
+ .map((seg) => {
+ if (seg === "*") {
+ keys.push("rest");
+ return "(.+)";
+ }
+ if (seg.startsWith(":")) {
+ keys.push(seg.slice(1));
+ return "([^/]+)";
+ }
+ return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ })
+ .join("/");
+ this.routes.push({ method, re: new RegExp(`^${source}$`), keys, handler });
+ return this;
+ }
+
+ // Throws URIError when a segment is not valid percent-encoding.
+ match(method: string, path: string): Match {
+ let pathMatched = false;
+ for (const r of this.routes) {
+ const m = r.re.exec(path);
+ if (!m) continue;
+ pathMatched = true;
+ if (r.method !== method && !(method === "HEAD" && r.method === "GET")) continue;
+ const params: Params = {};
+ r.keys.forEach((k, i) => {
+ params[k] = decodeURIComponent(m[i + 1]!);
+ });
+ return { handler: r.handler, params };
+ }
+ return pathMatched ? "method_not_allowed" : null;
+ }
+}
View file
Addedtest/catalog.test.ts+56−0
@@ -0,0 +1,56 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { setup } from "./helpers/app.ts";
+
+test("lists active products by name", async () => {
+ const s = setup();
+ await s.addProduct("wool-socks", { name: "Wool socks", price_cents: 1800 });
+ await s.addProduct("canvas-tote", { name: "Canvas tote", price_cents: 2400 });
+ await s.addProduct("old-mug", { name: "Old mug", active: false });
+
+ const res = await s.call("GET", "/products");
+ assert.equal(res.status, 200);
+ assert.deepEqual(res.body.products.map((p: { id: string }) => p.id), ["canvas-tote", "wool-socks"]);
+});
+
+test("an inactive product is not found by shoppers", async () => {
+ const s = setup();
+ await s.addProduct("old-mug", { active: false });
+ assert.equal((await s.call("GET", "/products/old-mug")).status, 404);
+});
+
+test("updating a product keeps its id and changes its fields", async () => {
+ const s = setup();
+ await s.addProduct("wool-socks", { price_cents: 1800 });
+ await s.addProduct("wool-socks", { price_cents: 1500, stock: 40 });
+ const { body } = await s.call("GET", "/products/wool-socks");
+ assert.equal(body.product.price_cents, 1500);
+ assert.equal(body.product.stock, 40);
+});
+
+test("merchant routes need the admin token", async () => {
+ const s = setup();
+ const res = await s.call("PUT", "/admin/products/wool-socks", { name: "Wool socks", price_cents: 1800 }, { authorization: "Bearer wrong" });
+ assert.equal(res.status, 401);
+ assert.equal(res.body.error, "unauthorized");
+});
+
+test("rejects a price that is not whole cents", async () => {
+ const s = setup();
+ const res = await s.call("PUT", "/admin/products/wool-socks", { name: "Wool socks", price_cents: 18.5 }, s.admin);
+ assert.equal(res.status, 400);
+ assert.equal(res.body.error, "bad_price");
+});
+
+test("rejects an id that is not a slug", async () => {
+ const s = setup();
+ const res = await s.call("PUT", "/admin/products/Wool%20Socks", { name: "Wool socks", price_cents: 1800 }, s.admin);
+ assert.equal(res.body.error, "bad_id");
+});
+
+test("unknown routes and methods", async () => {
+ const s = setup();
+ assert.equal((await s.call("GET", "/nope")).status, 404);
+ assert.equal((await s.call("DELETE", "/products")).status, 405);
+ assert.equal((await s.call("GET", "/products/%E0%A4%A")).status, 400);
+});
View file
Addedtest/helpers/app.ts+35−0
@@ -0,0 +1,35 @@
+// One storefront per test: a fresh database, a fixed clock and predictable ids.
+
+import { createApp } from "../../src/app.ts";
+import type { Env } from "../../src/env.ts";
+import { testDb } from "./d1.ts";
+
+export const ADMIN_TOKEN = "test-admin-token";
+export const NOW = Date.UTC(2026, 9, 1, 12, 0, 0);
+
+export function setup() {
+ const env: Env = { DB: testDb(), ADMIN_TOKEN };
+ let n = 0;
+ const app = createApp(env, { now: () => NOW, newId: () => `id-${++n}` });
+
+ async function call(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
+ const init: RequestInit = { method, headers: { ...headers } };
+ if (body !== undefined) {
+ init.body = JSON.stringify(body);
+ (init.headers as Record<string, string>)["content-type"] = "application/json";
+ }
+ const res = await app.fetch(new Request(`https://shop.test${path}`, init));
+ const text = await res.text();
+ return { status: res.status, headers: res.headers, body: text ? JSON.parse(text) : null };
+ }
+
+ const admin = { authorization: `Bearer ${ADMIN_TOKEN}` };
+
+ async function addProduct(id: string, fields: Record<string, unknown> = {}) {
+ const res = await call("PUT", `/admin/products/${id}`, { name: id, price_cents: 1200, stock: 5, ...fields }, admin);
+ if (res.status !== 200) throw new Error(`adding ${id}: ${JSON.stringify(res.body)}`);
+ return res.body.product;
+ }
+
+ return { env, app, call, admin, addProduct };
+}
View file
Addedtest/helpers/d1.ts+74−0
@@ -0,0 +1,74 @@
+// A D1 stand-in over node:sqlite with every file in migrations/ applied, so tests run the
+// storefront's real SQL. It covers what the code calls: prepare, bind, first, all, run and batch.
+// Foreign keys are on, as they are in D1.
+
+import { readdirSync, readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { DatabaseSync, type SQLInputValue } from "node:sqlite";
+import { fileURLToPath } from "node:url";
+
+const MIGRATIONS = join(dirname(fileURLToPath(import.meta.url)), "../../migrations");
+
+class Statement {
+ readonly db: DatabaseSync;
+ readonly sql: string;
+ readonly args: SQLInputValue[];
+
+ constructor(db: DatabaseSync, sql: string, args: SQLInputValue[] = []) {
+ this.db = db;
+ this.sql = sql;
+ this.args = args;
+ }
+
+ bind(...args: unknown[]): Statement {
+ return new Statement(this.db, this.sql, args as SQLInputValue[]);
+ }
+
+ async first<T>(column?: string): Promise<T | null> {
+ const row = this.db.prepare(this.sql).get(...this.args) as Record<string, unknown> | undefined;
+ if (!row) return null;
+ return (column ? row[column] : { ...row }) as T;
+ }
+
+ async all<T>(): Promise<{ results: T[]; success: true; meta: Record<string, unknown> }> {
+ const rows = this.db.prepare(this.sql).all(...this.args) as Record<string, unknown>[];
+ return { results: rows.map((r) => ({ ...r }) as T), success: true, meta: {} };
+ }
+
+ async run(): Promise<{ results: []; success: true; meta: { changes: number; last_row_id: number } }> {
+ const r = this.db.prepare(this.sql).run(...this.args);
+ return { results: [], success: true, meta: { changes: Number(r.changes), last_row_id: Number(r.lastInsertRowid) } };
+ }
+}
+
+export class TestD1 {
+ readonly sqlite = new DatabaseSync(":memory:");
+
+ constructor() {
+ this.sqlite.exec("PRAGMA foreign_keys = ON");
+ const files = readdirSync(MIGRATIONS).filter((f) => f.endsWith(".sql")).sort();
+ for (const f of files) this.sqlite.exec(readFileSync(join(MIGRATIONS, f), "utf8"));
+ }
+
+ prepare(sql: string): Statement {
+ return new Statement(this.sqlite, sql);
+ }
+
+ // Like D1, a batch is one transaction: a statement that fails rolls the others back.
+ async batch(statements: Statement[]) {
+ this.sqlite.exec("BEGIN");
+ try {
+ const out = [];
+ for (const s of statements) out.push(await s.run());
+ this.sqlite.exec("COMMIT");
+ return out;
+ } catch (e) {
+ this.sqlite.exec("ROLLBACK");
+ throw e;
+ }
+ }
+}
+
+export function testDb(): D1Database {
+ return new TestD1() as unknown as D1Database;
+}
View file
Addedtsconfig.json+16−0
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "es2023",
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "lib": ["es2023"],
+ "types": ["@cloudflare/workers-types", "node"],
+ "strict": true,
+ "noEmit": true,
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "erasableSyntaxOnly": true,
+ "skipLibCheck": true
+ },
+ "include": ["src", "workers", "test"]
+}
View file
Addedwrangler.jsonc+17−0
@@ -0,0 +1,17 @@
+// storefront-api: the public HTTP API. Create the database once with
+// `npx wrangler d1 create storefront` and put its id in database_id.
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "storefront-api",
+ "main": "src/index.ts",
+ "compatibility_date": "2026-09-01",
+ "observability": { "enabled": true },
+ "d1_databases": [
+ {
+ "binding": "DB",
+ "database_name": "storefront",
+ "database_id": "",
+ "migrations_dir": "migrations"
+ }
+ ]
+}
View file

Checks

No checks reported for this commit.

Runs

No runs for this commit.