landing.fido.money/how-it-works
A guided tour of the stack, the auth gate, the deploy flow, and the why-we-built-it-this-way decisions.
One repo, one Astro app, raw HTML pages dropped under pages/.
fido-landing-pages/
├── pages/ # your content lives here
│ ├── _template/index.html # copy this to start
│ ├── pilot/index.html
│ └── how-it-works/index.html
├── src/ # Astro shell — don't touch
│ ├── pages/
│ │ ├── index.astro # the / route
│ │ └── [slug].astro # catch-all → reads pages/{slug}/index.html
│ ├── layouts/BaseLayout.astro # auth wrapper
│ └── components/ui/ # shadcn primitives
├── amplify.yml # build spec
└── astro.config.mjs
Astro's catch-all route src/pages/[slug].astro uses Vite's
import.meta.glob('../../pages/*/index.html', { query: '?raw' }) to
slurp every pages/<name>/index.html at build time. For each
directory it emits dist/<name>/index.html wrapped inside
BaseLayout. Directories starting with _ are skipped
(that's how _template stays out of the routes).
BaseLayout.astro sets <html style="visibility: hidden">
as the very first thing. The body is invisible until JS resolves auth, so
nothing flashes before the redirect. Then a small inline script does:
// 1. Auth0 redirected back with an error → render denial, stop
if (params.has('error')) showDenied(params.get('error_description'));
// 2. OAuth callback → exchange code for tokens, strip the query
if (params.has('code')) await auth0.handleRedirectCallback();
// 3. Not authed → bounce to Auth0 (no body paint, no flap)
if (!await auth0.isAuthenticated()) await auth0.loginWithRedirect();
// 4. Authed → reveal
document.documentElement.style.visibility = 'visible';
The Auth0 Action verify-fido-domain calls
api.access.deny('invalid_email_domain') for any email whose
domain isn't fidocredit.com. Auth0 then redirects back with
?error=access_denied, which the gate above catches without
looping.
$ git push # to master, after PR review
↓
GitHub webhook → AWS Amplify
↓
$ npm ci
$ npm run build # Astro emits dist/ with one HTML per page
↓
aws s3 sync + CloudFront invalidate (Amplify-managed)
↓
✓ live at landing.fido.money/<your-page> (~3 min)
$ cp -r pages/_template pages/my-team-handbook
$ $EDITOR pages/my-team-handbook/index.html
$ git checkout -b add-page-my-team-handbook
$ git add pages/my-team-handbook
$ git commit -m "add my-team-handbook page"
$ git push -u origin add-page-my-team-handbook
$ gh pr create --base master
After merge, your page is live at
landing.fido.money/my-team-handbook within ~3 minutes.
No second PR, no Crossplane, no DNS work.
| Concern | Today |
|---|---|
| Auth enforcement | Client-side only. A direct curl of an asset URL returns the HTML. Don't put data on a page that mustn't leak via direct URL. |
| Per-page DNS | None. Single subdomain landing.fido.money, sub-paths per page. |
| Build cost | Every page rebuilds on every push (Astro static build). Sub-second per page; not a concern at scale. |
| Route discovery | The / route does not list pages — by design, route names stay private to people who have the link. |
v1 of OPS-1201 tried to do per-page provisioning via Crossplane on Cloudflare Pages: one Pages project per page, one DNS record per page, a shared Cloudflare Worker for auth, a Crossplane Composition to wire it together. The wildbitca upjet provider turned out to be amd64-only on our 100% Graviton cluster, and the path of least resistance kept getting longer. We reverted the whole stack and switched to the approach this page describes: stop per-page provisioning entirely. Static-site routing is solved by every modern build tool — we don't need Crossplane to do it.
Net result: 3 repos became 1, 2 PRs became 1, ~1000 lines of infra became ~80, and the time-to-ship a page went from "days, abandoned" to "~3 minutes."
pages/<name>/ into a route./ route (Astro + shadcn).