# What makes this work

McAstrology asks for a birthday and one silly question, then returns a fast-food
horoscope, a generated portrait of a mascot, a stats card, and a menu item.
This page explains what is actually happening underneath, for anyone who wants
more than the joke.

Short version: it is a single-file PHP app on a small Ubuntu box. There is no
database, no framework, no build step, and no user accounts. Every reading is
assembled at request time from a handful of APIs, then thrown away.

---

## The stack

| Layer | What it is |
|---|---|
| Runtime | PHP 8, no framework, no Composer, no dependencies |
| Server | Ubuntu, Apache, behind HTTPS |
| Storage | The filesystem. No database anywhere in the app |
| Front end | One `index.php`, one `style.css`, one `app.js`. No React, no bundler |
| Images | PHP's GD extension for compositing, generative models for the art |
| Secrets | A key file outside the webroot, read at runtime |

The entire app is five PHP files. `index.php` is the router and the markup,
`lib.php` is the engine, `config.php` is the content and settings, `auth.php`
is the password gate, `gate.php` is the door.

---

## The request, end to end

A visitor picks a month and day, answers one randomly chosen question, and
presses the button. `app.js` POSTs that as JSON to `index.php?api=horoscope`.
From there, `mc_result()` in `lib.php` runs the sequence:

1. **Work out the sign.** Pure date arithmetic, no API. Picking Smarch — yes,
   there is a thirteenth month — routes to an entirely separate personality.
2. **Check the wallet.** A pre-flight call to OpenRouter's `/key` endpoint,
   cached for five minutes. If the balance is dry, the visitor gets the Grimace
   coin screen instead of a degraded reading. Nothing is spent on a request
   that cannot finish.
3. **Fetch a real horoscope.** A genuine daily horoscope for the sign is pulled
   from a free horoscope API and cached to disk for the day. This is the seed,
   not the output.
4. **Rewrite it.** The real horoscope, the sign, the answer to the silly
   question, the menu item, and any seasonal note are assembled into a prompt
   and sent to a text model through OpenRouter. What comes back is the parody.
5. **Generate the portrait.** A separate call to an image model, built from a
   reference PNG of the mascot plus a randomly assembled scene.
6. **Composite the cards.** PHP's GD library draws the text card and the stats
   card locally — no model involved, no cost.
7. **Return everything as JSON.** The browser renders it. Navigate away and it
   is gone; nothing about the visit is written down.

---

## Where the words come from

Two sources, deliberately layered.

The factual seed is a real daily horoscope from a public API, cached per sign
per day so twelve people with the same sign cost one lookup. The rewrite is a
language model call routed through **OpenRouter**, which is a single API in
front of many providers — it means the model can be swapped by changing one
string in `config.php` without touching any code.

The default text model is a cheap open-weights model; a second model is
configured as a fallback for when the first is unavailable. If both fail, the
app falls back to the real horoscope unrewritten, and if that is missing too,
to a static line. There is no state in which a visitor sees an error page
instead of a reading.

The randomness that makes each reading different is not the model's. It is
dealt in PHP before the prompt is built: the mascot, the action, the art style,
the menu item, and the question are each drawn from lists in `config.php` using
a deal-without-replacement helper that tracks recent picks on disk, so the same
combination does not come up twice in a row.

---

## Where the pictures come from

Each of the thirteen signs maps to a mascot with a reference PNG in `assets/`.
The reference image plus a generated scene description goes to an image model,
and a 1080x1350 portrait comes back.

Two details worth knowing, because both were found the hard way:

**Model routing per character.** The default image model refuses one of the
characters outright, returning a content policy error every time. That single
mascot is routed to a different provider's model that will render him. The
substitute has noticeably worse likeness, so it is used for exactly one
character and not promoted to the default to save money.

**Reference images versus written descriptions.** Handing a model flat 2D
cartoon art and asking it to keep the character recognisable makes it preserve
the drawing and restyle only the background — so you get cartoon art pasted
into a photograph. For those characters the reference image is dropped and the
character is described in words instead, which lets the model build them inside
the chosen style. The 3D-rendered references do not have this problem and keep
their images.

Cost is roughly fourteen cents per portrait. The text is effectively free. The
text and stats cards are drawn locally with GD and cost nothing at all.

Generated images are written to `cache/images/` with a yellow frame baked in,
normalised to a fixed size regardless of what the model returned, and pruned
after seven days.

---

## The password gate

The site sits behind a single shared password. It is not protecting anything
sensitive — it exists so the app is not open to the whole internet spending
API credits, and so the parody stays among people who were handed the word.

The check is entirely server side. The browser never receives the password
list, so "View source" gives away nothing. Passwords are stored as bcrypt
hashes rather than plaintext, because this repository is public — a short word
behind bcrypt is crackable by anyone who cares, which is fine and expected. The
point is that the word is not sitting in the diff.

Typed passwords are normalised before comparison — lowercased, punctuation and
spaces stripped, "three" swapped for the digit — so near misses get in rather
than generating a text message asking what the password was.

Getting through sets one session cookie. There is no tracking, no analytics,
and no logging of what anyone typed or when.

---

## Secrets

The API key is never in the code. `mc_openrouter_api_key()` reads the
`OPENROUTER_API_KEY` environment variable, falling back to a key file at
`/home/ubuntu/.mcastrology-secrets/openrouter.key` — outside the webroot, so it
cannot be served even if Apache stops executing PHP. `.gitignore` covers
`.env`, `secrets.*`, and `*.key`.

---

## What is deliberately absent

No database. No accounts, no email, no password reset. No analytics, no
tracking pixels, no third-party scripts. No cookies beyond the one session
cookie for the gate. No logging of birthdays or readings.

The result screen says the images are gone if you navigate away, and that is
literal — nothing about a visit is persisted beyond the day's cached horoscope
text, which is per sign and not per person.

A strict Content-Security-Policy of `'self'` is set on every response, along
with `X-Frame-Options: DENY` and `nosniff`. There is no inline JavaScript
anywhere, which is what lets that policy stay strict.

---

## Disclaimer

Unofficial parody. Not affiliated with, endorsed by, or connected to McDonald's
Corporation, or to The Simpsons, Twentieth Century Fox, or The Walt Disney
Company. All trademarks, characters, and likenesses belong to their owners and
appear here as satire. For entertainment only.
