# Turning on menu subdomains

Goal: `https://test-mohamed.oordarat.com` serves that business's menu, instead of
`https://oordarat.com/m/test-mohamed`.

Everything in the application is already built for this. What is missing is a wildcard DNS record,
a wildcard certificate, an nginx block, and one environment variable. Do them in the order below —
the order is what keeps a subdomain from ever resolving before there is a certificate for it.

DNS is at **Hostinger**, the server is the Hetzner box at **188.245.117.146**.

---

## Before you start

Check who actually answers for the zone. If the domain's nameservers were pointed at Cloudflare or
anywhere else, Hostinger's DNS panel is not what browsers are reading and step 2 belongs there
instead:

```bash
dig NS oordarat.com +short
```

---

## The short way

Steps 1, 3 and part of 5 are scripted. On the server as root, with the wildcard DNS record from
step 2 already in place:

```bash
export HOSTINGER_Token="<hPanel API token>"
bash /opt/ordrat/backend/deploy/enable-menu-subdomains.sh
```

It only ever adds: one certificate, one nginx site, no changes to any existing vhost, and no reload
unless `nginx -t` passes. Safe to re-run. It deliberately stops short of setting `MENU_DOMAIN`,
which is step 4 below.

The rest of this page is what the script does, and what to do when a step needs doing by hand.

---

## 1. Get the wildcard certificate first

DNS-01 proves control of the zone by writing a TXT record, so this works **before** any A record
exists for the subdomains. Doing it first means there is never a window where
`something.oordarat.com` resolves but has no certificate — which is what a browser shows as a full
page security warning, and what HSTS on the apex turns into a hard refusal.

Get an API token at <https://hpanel.hostinger.com/api> (hPanel → API). It is shown once; copy it
straight into the command below. Give it DNS scope.

On the server, as root:

```bash
curl https://get.acme.sh | sh -s email=admin@oordarat.com
export HOSTINGER_Token="<the token>"

~/.acme.sh/acme.sh --server letsencrypt \
    --issue --dns dns_hostinger -d oordarat.com -d '*.oordarat.com'
```

Install it where nginx expects it, and register the reload so renewals take effect:

```bash
mkdir -p /etc/ssl/ordrat/wildcard.oordarat.com && chmod 700 /etc/ssl/ordrat/wildcard.oordarat.com

~/.acme.sh/acme.sh --install-cert -d oordarat.com \
    --key-file       /etc/ssl/ordrat/wildcard.oordarat.com/privkey.pem \
    --fullchain-file /etc/ssl/ordrat/wildcard.oordarat.com/fullchain.pem \
    --reloadcmd      "systemctl reload nginx"
```

> **Not** `/etc/letsencrypt/live/oordarat.com/`. That directory is certbot's — symlinks into
> `archive/`, a renewal config, and the certificate currently serving the apex site. Writing
> acme.sh's files over it replaces those symlinks and breaks certbot's renewal of `oordarat.com`.
> Two issuers, two directories, no shared state.

acme.sh installs its own cron entry and renews every 60 days. The token is saved in
`~/.acme.sh/account.conf` for the renewal, so treat that file as a secret.

> Two traps here. The variable is **`HOSTINGER_Token`** — the acme.sh wiki calls it
> `Hostinger_Key`, which is silently ignored and fails only once it tries to write the TXT record.
> And `--server letsencrypt` is not optional: acme.sh defaults to ZeroSSL, which drags in an EAB
> account registration for no benefit.

Check it covers both names:

```bash
openssl x509 -in /etc/letsencrypt/live/oordarat.com/fullchain.pem -noout -text \
  | grep -A1 "Subject Alternative Name"
# expect: DNS:oordarat.com, DNS:*.oordarat.com
```

> A wildcard covers one level only. `test-mohamed.oordarat.com` is covered;
> `a.b.oordarat.com` is not. Slugs never contain a dot, so this is not a limit in practice.

---

## 2. Point the wildcard at the server

hPanel → Domains → oordarat.com → DNS / Nameservers → Manage DNS records.

| Type | Name | Points to | TTL |
|------|------|-----------|-----|
| A | `*` | `188.245.117.146` | 300 |

Leave the existing `@`, `www`, `api` and `app` records alone. An exact record always wins over the
wildcard, so those keep resolving exactly as they do now — the wildcard only picks up names nothing
else claims.

Wait for it, then confirm a name that does not exist yet resolves:

```bash
dig +short anything-at-all.oordarat.com   # expect 188.245.117.146
```

---

## 3. Add the nginx block

The block is in this repo at `nginx/wildcard.oordarat.com.conf`.

```bash
scp nginx/wildcard.oordarat.com.conf \
    root@188.245.117.146:/etc/nginx/sites-available/wildcard.oordarat.com

ssh root@188.245.117.146 '
  ln -sf /etc/nginx/sites-available/wildcard.oordarat.com \
         /etc/nginx/sites-enabled/wildcard.oordarat.com
  nginx -t && systemctl reload nginx
'
```

It defines no block for `oordarat.com` or `www.oordarat.com`, so whatever serves the apex today
keeps serving it.

At this point a tenant subdomain should already load the menu, because the website reads the slug
from the hostname on its own:

```bash
curl -sI https://test-mohamed.oordarat.com | head -1   # expect 200
```

---

## 4. Tell the backend to hand out subdomain links

Until now the QR codes and links still say `/m/<slug>`, because the backend builds them from
`MENU_DOMAIN` and it is empty. In `/opt/ordrat/backend/.env`:

```
MENU_DOMAIN=oordarat.com
MENU_SCHEME=https
```

Restart the backend, then check what it now hands out:

```bash
curl -s https://api.oordarat.com/api/v1/menu/address -H "Authorization: Bearer <owner token>"
# expect {"slug":"test-mohamed","url":"https://test-mohamed.oordarat.com",...}
```

**Old codes keep working.** `/m/<slug>` is still routed and still served, permanently — codes
printed before today are stuck to tables and cannot be recalled. This variable only changes what
new links and new QR codes say.

---

## 5. Check the whole path

```bash
# the menu
curl -sI https://test-mohamed.oordarat.com | head -1

# a table code
curl -sI 'https://test-mohamed.oordarat.com/?t=12' | head -1

# a wall board
curl -sI https://test-mohamed.oordarat.com/screen/<token> | head -1

# the old path still works
curl -sI https://oordarat.com/m/test-mohamed | head -1

# a reserved name is not a tenant
curl -s https://api.oordarat.com/api/v1/public/menu/api | head -c 80
```

Then in a browser, on a real subdomain: the menu loads, images load (no mixed-content warnings in
the console), and adding an item and placing an order works. Images and ordering are the two things
that fail quietly if `X-Forwarded-Proto` is missing, which is why the nginx block sets it.

---

## Rolling back

Set `MENU_DOMAIN=` empty and restart the backend. Links and QR codes go back to `/m/<slug>`
immediately. Leave the DNS record and the certificate in place; neither does any harm on its own,
and taking them out only makes the next attempt slower.

---

## Why store names are checked

A subdomain is a hostname, so a business called "API" must never end up holding
`api.oordarat.com`. Handles are generated and validated by `io.ordrat.domain.model.Slug`:

- reserved names are refused — the platform's own hosts, mail names Hostinger and mail clients
  expect (`webmail`, `autodiscover`, `mx`…), and ORDRAT's own product words
- 3 to 40 characters, lowercase letters, digits and hyphens, never starting or ending with one
- accents are folded, so "Café Étoile" becomes `cafe-etoile` rather than losing the letters
- a name that folds to nothing — every Arabic-only or Chinese-only name does — gets a generated
  `store-xxxxxx` handle, and the owner is expected to change it
- duplicates get `-2`, `-3`, re-checked each time so the suffix cannot push a slug past the length
  limit or into a reserved name

Owners see and change their address on the Catalog screen. Availability is checked as they type and
again when they save, because between those two moments somebody else can claim the same name.

`V66__repair_tenant_slugs.sql` already rewrote any existing row that was empty, malformed or
reserved. Before deploying, check whether it moved anybody — if it did, they lose their old links
and should be told:

```sql
SELECT id, business_name, slug FROM tenants WHERE slug LIKE 'store-%';
```
