{
  "slug": "cloudflare",
  "name": "Cloudflare D1 + Access",
  "summary": "Cloudflare's D1 database for the data, and Cloudflare Access in front of /admin so staff sign in with an emailed code — no sign-in code to write.",
  "audience": [
    "developers"
  ],
  "bestFor": "Sites already hosted on Cloudflare that want the simplest secure admin for a small team.",
  "cost": "Access is free for up to 50 people; D1 has a free tier.",
  "rank": 7,
  "covers": "full",
  "docs": [
    {
      "label": "Cloudflare Access applications",
      "href": "https://developers.cloudflare.com/cloudflare-one/applications/configure-apps/self-hosted-apps/"
    },
    {
      "label": "Validate the Access JWT",
      "href": "https://developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/"
    },
    {
      "label": "D1",
      "href": "https://developers.cloudflare.com/d1/"
    }
  ],
  "body": "## 1. Put Access in front of /admin\n\nIn Cloudflare Zero Trust → Access → Applications, add a **self-hosted** application for `yourdomain.com/admin` and `yourdomain.com/api/admin`. Add an Allow policy listing the owner's and staff's email addresses, with \"One-time PIN\" as the login method. Cloudflare now asks for an emailed code before anyone reaches those paths — admin-sign-in isn't needed with this recipe. This check runs on Cloudflare's servers, before a request ever reaches your site, and step 3 checks the role again on your own server code — never rely on hiding links in the browser.\n\n## 2. Tables in D1\n\nCreate a D1 database and run the data-model tables as SQLite `create table` statements (`integer` for pence and booleans, `text` for times in ISO format, JSON text for order items). `staff_members` is keyed by email, with the role.\n\n## 3. Check identity and role in the Worker\n\nAccess adds a signed `Cf-Access-Jwt-Assertion` header to every request it lets through. Verify it in your Worker or Pages Function (with `jose` against your team's certs URL), take the email from it, and look up the role:\n\n```ts\nimport { createRemoteJWKSet, jwtVerify } from \"jose\";\n\nconst JWKS = createRemoteJWKSet(new URL(\"https://YOUR-TEAM.cloudflareaccess.com/cdn-cgi/access/certs\"));\n\nexport async function requireRole(request: Request, env: Env, allowed: string[]) {\n  const token = request.headers.get(\"Cf-Access-Jwt-Assertion\");\n  if (!token) throw new Response(\"Not signed in\", { status: 401 });\n  const { payload } = await jwtVerify(token, JWKS, { issuer: \"https://YOUR-TEAM.cloudflareaccess.com\", audience: env.ACCESS_AUD });\n  const member = await env.DB.prepare(\"select role from staff_members where email = ? and status = 'active'\").bind(payload.email).first<{ role: string }>();\n  if (!member || !allowed.includes(member.role)) throw new Response(\"Not allowed\", { status: 403 });\n  return member;\n}\n```\n\nUse it at the top of every `/api/admin/*` handler, following the access table (staff endpoints update only `status` or `stage`). Public endpoints for the booking and contact forms live outside `/api/admin`, insert only, and should be protected with Turnstile.\n\n## 4. The first owner\n\nAdd the owner's email to the Access policy and insert their `staff_members` row with `wrangler d1 execute`.\n\n## 5. Wire the screens\n\nThe admin pages fetch from `/api/admin/*`; Access's cookie travels with those requests automatically.",
  "url": "https://copypasteui.dev/r/recipes/cloudflare.json",
  "prompt": "# Back office with Cloudflare D1 + Access\n\nSet up the back office for this site with this backend, following every step. The back-office screens are the CopyPasteUI \"admin\" components (admin-sign-in, admin-overview, admin-bookings …); each takes its data and save actions as props. Best for: Sites already hosted on Cloudflare that want the simplest secure admin for a small team. Cost: Access is free for up to 50 people; D1 has a free tier.\n\n## The back-office data model\n\nEvery recipe stores the same data with the same names, so the back-office screens (category \"admin\") work with any backend. Money is stored in pence/cents as whole numbers. Times are stored in UTC.\n\n| Table | Fields | Used by |\n| --- | --- | --- |\n| `staff_members` | user_id (the auth user), email, name, role (`owner` \\| `manager` \\| `staff`), status (`active` \\| `invited`) | admin-staff, every access check |\n| `settings` | one row: name, phone, email, address, instagram, booking_link, notify_bookings, notify_enquiries, notify_reviews, daily_summary | admin-settings, the public header/footer/contact |\n| `bookings` | id, starts_at, name, party, phone, email, table_label, note, status (`confirmed` \\| `arrived` \\| `no-show` \\| `cancelled`), created_at | admin-bookings, admin-overview |\n| `orders` | id, number, customer_name, placed_at, pickup_at, items (list of {name, qty}), total_pence, paid, stage (`new` \\| `preparing` \\| `ready` \\| `collected`) | admin-orders |\n| `enquiries` | id, name, email, phone, subject, message, received_at, status (`new` \\| `replied` \\| `archived`) | admin-enquiries |\n| `menu_sections` | id, name, position | admin-menu-editor, the public menu |\n| `menu_items` | id, section_id, name, description, price_pence, tags (list), available, position | admin-menu-editor, the public menu |\n| `customers` | id, name, email, phone, visits, last_visit, spend_pence, notes, marketing_opt_in | admin-customers |\n| `reviews` | id, source, author, rating (1–5), text, posted_at, reply | admin-reviews |\n| `opening_hours` | day (0 = Monday … 6 = Sunday), open, opens, closes | admin-hours, the public footer/contact |\n| `closures` | id, starts_on, ends_on, note | admin-hours, the closed-notice banner |\n\n## Who may do what\n\nThis is the security contract. Implement it in the database's own rules, not only in the interface — hiding a button doesn't stop anyone.\n\n| Data | Public visitors | Staff | Manager | Owner |\n| --- | --- | --- | --- | --- |\n| menu_sections, menu_items, opening_hours, closures | read | read | read, write | read, write |\n| settings | read (public fields only) | read | read | read, write |\n| bookings | create only (from the booking form), never read | read, change status | read, write | read, write |\n| orders | create only (from checkout), never read | read, change stage | read, write | read, write |\n| enquiries | create only (from the contact form), never read | — | read, write | read, write |\n| customers, reviews | — | — | read, write | read, write |\n| staff_members | — | read own row | read | read, write |\n\nRules that apply to every backend:\n- Every `/admin` page checks the session **on the server** and sends signed-out people to `/admin/sign-in`.\n- Secret keys (service-role keys, admin SDK credentials, API secrets) live only in server code and environment variables, never in the browser bundle.\n- Public forms may only insert, with fields limited to what the form sends; they can never read other people's rows.\n- `/admin` is left out of the navigation and the sitemap, and disallowed in robots.txt.\n- The first owner account is created by hand once (instructions in each recipe); everyone else is invited from admin-staff.\n\n# Cloudflare D1 + Access: steps\n\n## 1. Put Access in front of /admin\n\nIn Cloudflare Zero Trust → Access → Applications, add a **self-hosted** application for `yourdomain.com/admin` and `yourdomain.com/api/admin`. Add an Allow policy listing the owner's and staff's email addresses, with \"One-time PIN\" as the login method. Cloudflare now asks for an emailed code before anyone reaches those paths — admin-sign-in isn't needed with this recipe. This check runs on Cloudflare's servers, before a request ever reaches your site, and step 3 checks the role again on your own server code — never rely on hiding links in the browser.\n\n## 2. Tables in D1\n\nCreate a D1 database and run the data-model tables as SQLite `create table` statements (`integer` for pence and booleans, `text` for times in ISO format, JSON text for order items). `staff_members` is keyed by email, with the role.\n\n## 3. Check identity and role in the Worker\n\nAccess adds a signed `Cf-Access-Jwt-Assertion` header to every request it lets through. Verify it in your Worker or Pages Function (with `jose` against your team's certs URL), take the email from it, and look up the role:\n\n```ts\nimport { createRemoteJWKSet, jwtVerify } from \"jose\";\n\nconst JWKS = createRemoteJWKSet(new URL(\"https://YOUR-TEAM.cloudflareaccess.com/cdn-cgi/access/certs\"));\n\nexport async function requireRole(request: Request, env: Env, allowed: string[]) {\n  const token = request.headers.get(\"Cf-Access-Jwt-Assertion\");\n  if (!token) throw new Response(\"Not signed in\", { status: 401 });\n  const { payload } = await jwtVerify(token, JWKS, { issuer: \"https://YOUR-TEAM.cloudflareaccess.com\", audience: env.ACCESS_AUD });\n  const member = await env.DB.prepare(\"select role from staff_members where email = ? and status = 'active'\").bind(payload.email).first<{ role: string }>();\n  if (!member || !allowed.includes(member.role)) throw new Response(\"Not allowed\", { status: 403 });\n  return member;\n}\n```\n\nUse it at the top of every `/api/admin/*` handler, following the access table (staff endpoints update only `status` or `stage`). Public endpoints for the booking and contact forms live outside `/api/admin`, insert only, and should be protected with Turnstile.\n\n## 4. The first owner\n\nAdd the owner's email to the Access policy and insert their `staff_members` row with `wrangler d1 execute`.\n\n## 5. Wire the screens\n\nThe admin pages fetch from `/api/admin/*`; Access's cookie travels with those requests automatically.\n\nDocs to check before you start, since providers change their APIs: Cloudflare Access applications (https://developers.cloudflare.com/cloudflare-one/applications/configure-apps/self-hosted-apps/); Validate the Access JWT (https://developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/); D1 (https://developers.cloudflare.com/d1/).\n"
}
