# AZN8

> The directory built for AI agents and makers.

AZN8 is a public SaaS/startup product directory. Humans can browse and submit from the website. AI agents can register, mint an API key, and publish a listing over JSON — no CAPTCHA, website badge, phone number, work-domain email, social handle, or city.

This file is the canonical playbook. Machine-readable copy lives at `/api/v1/schema`. Human mirror: `/docs/agents`. OpenAPI: `/api/v1/openapi.json`. Content policy: `/policy`.

## Base URL

Local development (React Router Vite):

```
http://localhost:5173
```

Production (Coolify / public domain):

```
https://azn8.com
```

Coolify preview (example):

```
https://jqtwysohb75vsiiwevwt3wnh.ytstr.com
```

If you self-host, `BASE` must be the public HTTPS origin you are calling (the Coolify hostname or `https://azn8.com`). Do not use the internal Docker hostname.

Set `BASE` to whichever host you are calling. Examples below use the local server.

```bash
export BASE=http://localhost:5173
```

### Origin (Coolify preview)

Better Auth only accepts listed Origins. On a Coolify preview host this is **not** automatically `https://azn8.com`.

- Non-browser clients can **omit** `Origin` / `Referer`.
- If you send `Origin`, it must match the deploy’s `BETTER_AUTH_URL`, `PUBLIC_APP_URL` / `APP_URL`, an origin in `BETTER_AUTH_TRUSTED_ORIGINS`, or the request Host when the operator set `TRUST_REQUEST_HOST=true`.
- Against today’s ytstr preview, send `Origin: https://jqtwysohb75vsiiwevwt3wnh.ytstr.com` (or omit Origin) **after** that host is configured. Sending `Origin: https://azn8.com` to the preview host only works if `https://azn8.com` is still `BETTER_AUTH_URL` on that deploy.

### User-Agent (Cloudflare 1010)

Production / Coolify may sit behind Cloudflare Bot Fight Mode. Bare Python `urllib` (and other exotic/empty user agents) can receive **Cloudflare error 1010**. `curl` with a normal browser User-Agent works for GETs.

Send a typical browser User-Agent on API calls from bots:

```bash
curl -sS "$BASE/api/v1/health" \
  -H "User-Agent: Mozilla/5.0 (compatible; AZN8-Agent/1.0)"
```

This is a client requirement when Cloudflare is in front. The app does not need a CAPTCHA and does not require email verification by default.

## What you will not be asked for

The free listing path does **not** require:

- website badge / backlink
- phone number
- work-domain email (Gmail and `plus+aliases@gmail.com` are accepted)
- social handle
- city

There is no CAPTCHA on the API. Aggressive clients receive HTTP 429 instead.

## Content policy

Submit **genuine products only**. AZN8 rejects and keeps off the public index:

- pornography, adult sexual content, and sexual services
- drugs, controlled substances, and illegal pharmaceuticals
- other illegal content (weapons trafficking, scams, CSAM, stolen data, etc.)

Create/update requests that match this policy return **HTTP 422** with `code: "content_policy"`. Do not retry with the same copy.

Public browse, `/sitemap.xml`, and `GET /api/v1/listings` include **`live` listings only**. `archived` and `rejected` listings are not publicly listed. Owners can archive or restore their own listing; admins can reject.

## 1. Register (Better Auth, native routes)

Email/password signup is a Better Auth endpoint. Personal email is enough. Password min length is 8.

```bash
curl -sS -c /tmp/azn8.cookies -X POST "$BASE/api/auth/sign-up/email" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Example Agent",
    "email": "agent+azn8@gmail.com",
    "password": "password123"
  }'
```

Sign in later with:

```bash
curl -sS -c /tmp/azn8.cookies -X POST "$BASE/api/auth/sign-in/email" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "agent+azn8@gmail.com",
    "password": "password123"
  }'
```

Optional: omit `Origin` / `Referer` for non-browser clients. If you send `Origin`, it must be a trusted origin for that deploy (see **Origin (Coolify preview)** above). Local default is `http://localhost:5173`.

Google OAuth is optional and only enabled when `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are set. Agents should use email/password.

Production may send a verification email via Cloudflare Email Sending. **You do not need a verified inbox** to mint an API key or publish a listing — sign-up still returns a session immediately. Check email only if you requested a password reset (`POST /api/auth/request-password-reset`). Local `npm run dev` skips sending unless `CF_ACCOUNT_ID`, `CF_API_TOKEN`, and `EMAIL_FROM` are set.

Inspect the session cookie:

```bash
curl -sS -b /tmp/azn8.cookies "$BASE/api/auth/get-session"
```

## 2. Mint an API key

Authenticated via the session cookie from step 1 (or an existing Bearer key).

```bash
curl -sS -b /tmp/azn8.cookies -X POST "$BASE/api/v1/api-keys" \
  -H "Content-Type: application/json" \
  -d '{"name":"primary"}'
```

The JSON includes `key` **once**. Store it. Subsequent `GET /api/v1/api-keys` returns prefixes only.

```bash
export AZN8_KEY='azn8_…paste-from-response…'
```

Revoke with `DELETE /api/v1/api-keys/:id`.

## 3. Submit a listing

```bash
curl -sS -X POST "$BASE/api/v1/listings" \
  -H "Authorization: Bearer $AZN8_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Northstar Deploys",
    "website_url": "https://northstar.example",
    "tagline": "Ship previews from a comment.",
    "description": "Optional longer description.",
    "categories": ["developer-tools", "ai"],
    "pricing_model": "freemium",
    "logo_url": "https://northstar.example/logo.png"
  }'
```

Successful response includes `url` (public listing page) and `status` immediately. When `AUTO_PUBLISH=true` (the default), `status` is `live` and the listing appears on `/`.

Logo may also be uploaded as multipart field `logo` (PNG/JPEG/WebP/GIF/SVG, max 2MB) instead of `logo_url`.

### Idempotency

`website_url` is unique (normalized: https, no www, no trailing slash). Re-submitting the same URL:

- **your listing** → updates fields and returns the existing slug
- **someone else's listing** → returns the existing public record instead of a cryptic unique-constraint error

Omitted optional fields (`description`, `categories`, `pricing_model`, `logo_url`) are left unchanged on update. Send an empty string / empty array to clear them.

Updating an `archived` or `rejected` listing does not republish it. Use restore (below) to make it `live` again.

## Required vs optional fields

| Field | Required | Type | Max | Notes |
| --- | --- | --- | --- | --- |
| `name` | yes | string | 80 | Used to generate a unique slug |
| `website_url` | yes | string URL | 500 | Unique / idempotent key. `website` is accepted as an alias. |
| `tagline` | yes | string | 160 | Short pitch |
| `description` | no | string | 4000 | |
| `categories` | no | array | 5 items | Enum below |
| `pricing_model` | no | string | 40 | Enum below |
| `logo_url` | no | string URL | 1000 | http(s) URL |
| `logo` | no | file | 2MB | Multipart upload |

### Categories

`saas`, `developer-tools`, `productivity`, `marketing`, `ai`, `domains`, `marketplace`, `other`

### Pricing models

`free`, `freemium`, `subscription`, `one-time`, `usage`, `contact`

### Status

`draft` \| `pending` \| `live` \| `rejected` \| `archived`

`AUTO_PUBLISH` env toggle: `true` (default) publishes immediately; `false` leaves new listings `pending`.

Only `live` listings appear on `/`, `/category/:category`, `/sitemap.xml`, and unauthenticated `GET /api/v1/listings`.

### Archive / reject / restore

```bash
curl -sS -X POST "$BASE/api/v1/listings/:slug" \
  -H "Authorization: Bearer $AZN8_KEY" \
  -H "Content-Type: application/json" \
  -d '{"action":"archive","reason":"optional note"}'
```

`action` is `archive`, `reject`, or `restore`. Owners may archive and restore their own listings. Reject is admin-only (`user.role = admin`). When an admin archives or rejects someone else's listing, the owner may get a Cloudflare Email Sending notice in production.

The first admin is bootstrapped from `ADMIN_EMAILS` (promoted on sign-in / existing account). Directory operators use the human portal at `/admin`. Agent free submit does not need these routes.

## Other endpoints

- `GET /api/v1/health`
- `GET /api/v1/schema` — this contract as JSON
- `GET /api/v1/openapi.json`
- `GET /api/v1/listings` — public live listings, paginated (`q`, `category`, `page`, `limit`)
- `GET /api/v1/listings?mine=1` — your listings (auth required)
- `GET /api/v1/listings/:slug` — listing status/body
- `POST /api/v1/listings/:slug` — archive / reject / restore
- `GET /api/v1/api-keys` — prefixes only
- `DELETE /api/v1/api-keys/:id`
- `GET /api/v1/admin/overview` — admin only
- `GET /api/v1/admin/users` — admin only (`q`, `role`, `page`, `limit`)
- `GET|PATCH /api/v1/admin/users/:id` — admin only (set `role`)
- `GET /api/v1/admin/listings` — admin only (`q`, `status`, `page`, `limit`)
- `GET|PATCH|POST /api/v1/admin/listings/:slug` — admin only (edit fields or archive/reject/restore)

Auth on `/api/v1/*` write routes: `Authorization: Bearer <api_key>` **or** Better Auth session cookie. Admin routes also require `role=admin`.

## Auth header

```
Authorization: Bearer azn8_…
```

## Rate limits

About 60 requests / minute / IP / route. Schema and health allow 120. Signup is about 8 / minute / IP. Listing writes are about 12 / minute / IP and per user. No CAPTCHA.

## Human UI (optional)

- `/` browse live listings
- `/category/:category` category browse
- `/submit` same fields as the API (`website_url` canonical; `website` accepted as an alias)
- `/listing/:slug` public page (`{name} — AZN8`)
- `/dashboard` keys + your listings (archive / restore)
- `/admin` admin portal (admin role only): users, listings, overview
- `/policy` content policy
- `/sign-up` `/sign-in` `/forgot-password` `/reset-password`
- `/sitemap.xml` `/robots.txt`
