ACE Training
Website

How the website is built

← Back to the portal

The technical specification for ace.co.nz. What it is built with, what it talks to, where its email comes from, and where the code lives. Written so somebody who has never seen this project can pick it up.

The short version

ace.co.nz is a static website. Every page is built into plain HTML ahead of time and served as a file, which is why it is fast and why there is very little that can break at the moment a customer visits.

The pages are built from two sources. Course listings, dates and prices come from Administrate, which stays ACE's system of record. Page wording comes from Sanity, where Tracey edits it. The site is rebuilt every morning and every night so both stay current.

The only parts that run live, at the moment somebody clicks, are the forms, the booking and payment steps, the course advisor and this portal. Everything else is a file.

What it is built with

LayerWhat we useWhy it is that
The site Astro 7, a static site builder. Node 22.
package.json, astro.config.mjs
Builds 419 pages to HTML in about three minutes. No database call and no server work when a customer opens a page.
Hosting Cloudflare Pages, project ace-training, in ACE's own Cloudflare account.
ace-training.pages.dev, ace.co.nz, demo.ace.co.nz
ACE own the account, so nothing here depends on Rove keeping a login. DNS for the ace.co.nz zone sits in the same account.
The live bits Cloudflare Pages Functions, in functions/api/.
enquiries, bookings, payments, digests
Small pieces of code that run on request. They are the only server-side code in the website itself.
Page wording Sanity content studio, project kvftx3ne, dataset production.
ace-training-nz.sanity.studio
So ACE change their own words without a developer. Sanity always beats the wording committed in the code, which is the thing people get wrong first.
Course data Administrate, read over its GraphQL API at build time.
api.getadministrate.com/graphql
Administrate remains the single source of truth for courses, dates, places and prices. The website never decides any of them.
Database Supabase project "ACE and Project Laneways", reference noonadzhstgxncsdobzj, in ACE's name.
schemas: ace, portal, project_laneways
Holds what the website learns and Administrate has nowhere to put: unfinished bookings, 404s, course advisor questions, how people heard about ACE, and this portal.
This portal Plain HTML on Cloudflare Pages project ace-portal, signed in against Supabase.
portal.ace.co.nz
Members only, four people. No framework, so it stays readable to anyone.
Builds and jobs GitHub Actions, five workflows.
.github/workflows/
Building and deploying is not done by hand. Every scheduled job is listed on What runs automatically.

There is no WordPress, no plugin list, no separate CMS licence and no server to patch. The bill for the site itself is Cloudflare, Sanity and Supabase, all on free or low tiers today.

The code, and where it is

Two repositories on GitHub, both under the account JPmack23. Everything to do with the website is in the first one, including this portal.

RepositoryWhat is in itBranches that matter
JPmack23/
ace-website
The website, the booking and payment code, the Supabase edge functions, this portal, and every migration and checking script.
src/ pages and components · functions/api/ live endpoints · worker-lib/ shared booking, email and Administrate code · supabase/functions/ edge functions · portal/ this portal · migration/ one-off and repeatable checks · .github/workflows/ the scheduled jobs
master is the site, and the only branch that ships.
portal-login is this portal.
heard-dashboard is the dashboard work.
The practice site is not a branch: the same code is deployed to Cloudflare under the label demo.
JPmack23/
ace-studio
The Sanity studio: the shape of every editable field and the studio Tracey signs into. React and Sanity 3.
deployed with npx sanity deploy
master
Read these three files first. In ace-website: README.md is the architecture, CLAUDE.md is the rules and the traps that have already cost time, and NEXT-SESSION.md is the current state of play. They are kept current deliberately, because this project has been built across many short sessions and the written record is the only thing that carries between them.

The four addresses

AddressWhat it is
ace.co.nz The live website. Real customers and real payments.
demo.ace.co.nz The practice copy. The same site, rebuilt for checking before anything reaches customers. It deliberately cannot send email or take a payment, so a booking made here does nothing.
portal.ace.co.nz This portal. Members only.
old.ace.co.nz The previous website, still running on its original server. It is the way back if it is ever needed, and it is hidden from search engines.
The practice copy and the old site are both hidden from search engines, on purpose. Each one is a near-complete copy of ace.co.nz, and a search engine that indexed either would be holding two or three versions of the same pages. That splits the value of the real site and can cost it its own rankings, which is the last thing worth risking on a domain ACE have had for years. So every page of demo.ace.co.nz tells search engines not to index it, and old.ace.co.nz is told the same by a rule on the domain. Only ace.co.nz is offered to Google.
The practice copy is locked twice over, deliberately. First, it knows its own address: a build that calls itself demo.ace.co.nz marks itself no-index automatically, and a build that calls itself ace.co.nz does not. Second, a rule on the domain adds the same instruction to every response from demo.ace.co.nz. The two work independently, so neither one failing quietly would leave the practice copy exposed. Checked on 16 September: both are in place on demo.ace.co.nz, neither is on ace.co.nz, and a search for pages on the practice copy returns nothing.

Code only ever reaches any of these through GitHub Actions, which builds the site and hands it to Cloudflare. There is no other path, and nothing deploys from anyone's laptop.

The systems it talks to

Every outside service the website uses, what it is for, and who owns the account. The account owner is the important column: it says who can still get in if the person who set it up is not available.

ServiceWhat it does hereAccount
Administrate Courses, dates, places, prices, registrations. Read when the site builds, written to when somebody books. Authenticated with a client id and secret over OAuth.
api.getadministrate.com/graphql · auth.getadministrate.com · ADM_ID, ADM_SECRET, ADM_ACCOUNT_ID, ADM_WRITE_ENABLED
ACE
Sanity Page wording, hero text, images and the eight Insight articles. Read when the site builds. The dataset is public to read, so no key is needed to display it.
project kvftx3ne, dataset production · SANITY_PROJECT_ID, SANITY_DATASET, SANITY_TOKEN
Rove, for ACE
Stripe Card payments, and the webhook that turns a successful payment into an Administrate registration.
api.stripe.com · js.stripe.com · STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET
ACE
Supabase The database, the five edge functions, and sign-in for this portal.
noonadzhstgxncsdobzj.supabase.co
ACE
Resend Sends every email the website sends. See the next section.
api.resend.com · RESEND_API_KEY
ACE
Anthropic The Course Advisor. One model call per question, with the whole ACE catalogue in front of it so it can only recommend real courses.
api.anthropic.com · claude-haiku-4-5 · ANTHROPIC_API_KEY, held on the Supabase function and not in the website
Set up by Rove
Cloudflare Turnstile The anti-spam check on all four forms. Checked on the server, so a form that skips it is refused.
challenges.cloudflare.com · TURNSTILE_SECRET
ACE
Mailchimp Marketing list. Only people who tick the box are added, and the decision is made on the server so a form cannot subscribe anybody by accident. Starting a booking no longer writes to it.
MAILCHIMP_API_KEY, MAILCHIMP_LIST_ID
ACE
PD Training Their public schedule is read once a week to find dates Administrate does not have. Read only, and it writes nothing anywhere.
pd-training-dates.yml · migration/pd-training-load.mjs loads them by hand
ACE
Google Tag Manager
and GA4
Analytics. GTM container GTM-52Z75C5, which loads GA4.
googletagmanager.com
ACE
Microsoft Clarity Heatmaps and session recordings, used to decide what to change on the busiest pages.
project ybdi1v9jkz
ACE
LiveChat The chat widget. Licence 10204607.
cdn.livechatinc.com
ACE
GitHub The code, and the scheduled jobs that build and check the site.
github.com/JPmack23
Rove
The browser is told exactly which of these it may reach. A Content Security Policy in public/_headers lists every allowed address, and anything not on the list is blocked before the request leaves the browser. So adding a new service means adding it to that policy too, and the change has to be checked in a real browser, because command-line tests ignore the policy.

Where the emails come from

Every email the website sends goes through Resend on ACE's own account, from the sending domain send.ace.co.nz. Nothing is sent from a Rove address, and no email is sent from the practice site at all.

EmailSent fromWho gets it
Enquiries, in-house requests, contact forms ACE website <enquiries@send.ace.co.nz>, replies go to the customer info@ace.co.nz
New booking, and ACTION NEEDED when a booking cannot hold a place ACE website <enquiries@send.ace.co.nz> bookings@ace.co.nzRove, always copied
Booking acknowledgement to the customer ACE Training <enquiries@send.ace.co.nz>, replies go to bookings@ace.co.nz The person who booked, and each attendee
Course confirmation to the learner Administrate, not the website. It sends ACE's own template, from ACE's own address, and it lands in the customer's history in Administrate The learner
Daily and weekly checks: unfinished bookings, pages not found, the Administrate check, PD Training dates ACE Training <enquiries@send.ace.co.nz> See What runs automatically
Sign-in codes for this portal ACE Training <noreply@send.ace.co.nz>, through Supabase using the same Resend account The person signing in

Addresses are settings, not code. Changing who gets what is a settings change on the Cloudflare Pages project followed by a rebuild, because Cloudflare fixes settings into the site at the moment it is built. The settings are ENQUIRY_TO, BOOKINGS_NOTIFY_TO, BOOKING_ALERT_TO, OPS_ALERT_TO and ENQUIRY_FROM.

Email authentication, and why it matters

ACE publish a strict DMARC policy on ace.co.nz: any mail claiming to be from ACE that cannot prove it is genuine gets rejected, not filed in junk. That is the right setting, and it means the website's own email has to prove itself properly or it simply would not arrive.

It does. All three checks pass on real delivered mail:

Checked 16 September against the message headers of a booking notification actually delivered on 15 September, rather than against the settings screen. The sending mail is handled in Resend's Tokyo region, and the DNS records that authorise it live in the ace.co.nz zone in ACE's own Cloudflare account.

ace.co.nz's own mail has nothing to do with the website. Staff email runs through Proofpoint on a separate set of DNS records, and the website's sending domain is a subdomain that sits alongside it. Nothing on the website touches those records, and nobody working on the website should. Changing the MX records on ace.co.nz would stop ACE receiving mail.

What the database holds

One Supabase project, three schemas. It is ACE's project, holding both of the client's businesses. Nothing in it duplicates Administrate.

WhereWhat is in it
schema ace booking_starts, booking_completions and booking_attempts: who started a booking and what happened. heard_about: the answer to "how did you hear about us", kept with no name attached. not_found_hits: addresses visitors could not find. advisor_queries: every Course Advisor question and answer, with no name, no email and no IP address. publishes: every publish of the live site from this portal, and who pressed the button.
schema portal This portal: tasks, files, courses, levels, redirects, clients and members. Documents sit in a private bucket and are opened with links that expire after five minutes.
schema project_laneways The client's other business. Same project, separate schema, deliberately kept apart.

Every table has row level security switched on, and the portal tables are members only: the key that sits in this page reads nothing on its own. Five edge functions run on the same project: ace-course-advisor, ace-booking-log, ace-dashboard, ace-not-found and ace-publish.

How a change reaches the live site

  1. Wording is edited in Sanity, then Show me my changes on this portal builds the practice site so it can be checked, and Publish to ace.co.nz builds the real one. Six minutes each.
  2. Code goes to the master branch on GitHub. A push builds the practice site only. The live site is built by the 9:56pm job, or on demand by pressing Publish.
  3. Every build refuses to deploy a half-built site: fewer than 200 course pages and it stops. That check exists because an Administrate outage once let a nearly empty site reach the web.
  4. Course dates and prices are refreshed by the build itself, so the live site is never more than a day behind Administrate.

Going back is one DNS change. The old website is still running at old.ace.co.nz on the original server, and pointing ace.co.nz back at it takes under a minute.

If you are the developer picking this up

Clone the repo, read README.md, CLAUDE.md and NEXT-SESSION.md, then:

npm install
npx astro build        # needs adm-creds.json: {"id":"...","secret":"..."}

Without Administrate credentials the build stops on a 401, which is expected and still catches mistakes. Add a sanity-creds.json holding the project and dataset and no token, and every non-course page builds locally, because the dataset is public to read.

Run these before any deploy

node migration/verify-build.mjs                       # refuses a half-built site
node migration/search-check.mjs                       # 50 search checks
node migration/consent-check.mjs                      # nobody subscribed by accident
node migration/filter-audit.mjs                       # every filter on every topic page
node migration/verify-redirects.mjs https://ace.co.nz # all 440 old addresses
node migration/bookable-check.mjs --host https://ace.co.nz

bookable-check is the one that asks the customer's question rather than the data's: does every Book button, on every page, lead to a charge at the advertised price.

Before changing anything to do with bookings

node migration/adm-request-audit.mjs       # every request shape against Administrate
node migration/test-booking-refusal.mjs    # the refusal path, not just the happy one

The four traps that have cost the most time

The full list is in CLAUDE.md and is worth twenty minutes before touching anything.

Keys and passwords

No key is on this page, and no key is in the code. They are held in three places: Cloudflare Pages project settings for the website (14 of them on production, fewer on the practice site, which is why the practice site cannot send email or take a payment), GitHub repository secrets for the scheduled jobs, and Supabase function settings for the edge functions. Anyone with access to ACE's Cloudflare and Supabase accounts can read or replace all of them without Rove.

Written 16 September 2026. Every fact on this page was read off the system that holds it on that day, not copied from an earlier document. If a service, an address or a schedule changes, change it here at the same time.