# simple-limiter.pilely.app — owner-defined rate limits and bot checks on the routes you own

Base URL: `https://simple-limiter.pilely.app` (the `@simple_limiter` service). Every operation is a `POST` to a route below. This README is the only manual: `GET https://simple-limiter.pilely.app/README.md`, public — every caller, signed in or not, gets it. The platform directory of every service README is `https://pilely.app/README.md`.

## When to use

- The user wants to protect an endpoint from abuse or runaway traffic, or cap what their app's users and visitors cost them: put a rate limit on any route you own — a route on your own app host, or a route your app calls on a simple service: simple_db tables and their file uploads and downloads (`https://simple-db.pilely.app/README.md`), simple_email sends (`https://simple-email.pilely.app/README.md`).
- A **policy** names a target route, a counter key, and a strategy. Root enforces it on the forward path; this service only stores policies and publishes them to root. Who a policy counts is in `### Who is counted` below.
- A policy is immutable once created: there is no update, no reset, no re-enable — disable it and create a new one instead.
- The user wants spam protection on an endpoint signed-out visitors can call (a public form writing to a simple_db table, a contact form sending through simple_email): put a **challenge policy** on it (`POST /challenge_policies/create`). Root then refuses every matching call that does not carry a solved bot check (an ALTCHA proof of work) in the `X-Pilely-Challenge` header — the owner's own calls and the app's server included — before the call reaches the service or the app. The app's page gets a challenge from `POST /challenge`, solves it in the browser, and sends the answer with the one call that needs it. A challenge policy takes the same `host`, `app_id`, `method` and `path` as a rate-limit policy and has no `key` or `strategy`; it is immutable the same way.

## Auth

- Every request reaches this service as one caller — a person, a backend app, an agent, or an anonymous visitor — authenticated on one app (the app whose token was sent). How to sign in and get a token: `https://pilely.app/README.md`; the sign-in page is `https://pilely.app/~/login`.
- Every route but `POST /challenge` admits a person only. A backend app (any box), an agent and an anonymous visitor get the uniform `404 not_found`, the answer an unknown policy gets.
- Which app the person comes through decides what they reach. Through this service itself (a token for `simple-limiter.pilely.app`) you act on all your policies. Through any other app you reach only that app's policies: `create` a policy only on that app, `list` only its policies, `disable` only its policies. Anything else is the uniform `404`, so one app can never strip or tighten another app's limits. The `challenge_policies/*` routes follow exactly the same rules as the `policies/*` routes.
- A policy is always scoped to its owner: `list` shows only your own policies, and `disable` acts only on your own.
- Ownership of the TARGET route, and the policy's `app_id`, are checked once, at `create`. A policy's `host` is exactly one of three shapes:

| `host` | `app_id` | `path` | You must own |
|---|---|---|---|
| `<label>.pilely.app` (your own app host) | absent or `null`; anything else is `400 bad_request` | any valid pattern | the app registered under that dns label |
| `simple-db.pilely.app` | optional; when sent it must equal the path's `<app_id>` (`400 bad_request` otherwise) | `/apps/<app_id>/**` with a literal app uuid (`400 unsupported_path` otherwise) | the app `<app_id>` |
| `simple-email.pilely.app` | required: the app's uuid (`400 bad_request` when missing, `null` or not a uuid) | any valid pattern, e.g. `/accounts/<app_id>/templates/*/send`; never checked against `app_id` | the app `app_id` |

- An app you do not own, or an unknown app, is `400 target_not_owned`. Every other host — other `simple-*` hosts included — is `400 unsupported_host`.
- `app_id` is the app's id: the uuid in its simple_db paths. `simple-db.` and `simple-email.pilely.app` are SHARED hosts: every owner's apps call them, so a policy there belongs to one app.
- A policy on your own app host also covers requests that arrive through a custom domain bound to that app; a custom-domain host is never a policy `host` itself.
- Unknown policy ids and policies you do not own answer the same `404 not_found`.

### Who may get a challenge

`POST /challenge` answers a challenge bound to the app the call came through (the app whose token was sent). Root accepts its answer only on a call made through that same app.

| Caller | `POST /challenge` |
|---|---|
| A signed-in person, through app A | `200`, a challenge bound to app A |
| A signed-out visitor with app A's anonymous token (`POST https://pilely.app/~/mint/anon`) | `200`, a challenge bound to app A |
| App A's server (its backend token), acting as app A | `200`, a challenge bound to app A — challenge policies challenge the app's own server too |
| An agent | the uniform `404 not_found` |
| No token | root's `401 login_required`, before this service is reached |

### Who is counted

A request's app is the app named by the token root verified: the app's own app token, its anonymous visitor token, or its server's backend token. A request with an account or session token, an agent token, or no token names no app. Key rules apply on top: `user` never counts a signed-out caller, `ip` counts every caller by client IP, `all` is one counter for the whole route.

| Caller | Your own app host | `simple-db.pilely.app` | `simple-email.pilely.app` |
|---|---|---|---|
| You, the app's owner, with any credential | never counted | never counted | never counted |
| Your app's backend server | never counted (it is the app itself) | never counted | never counted |
| A signed-in user through the policy's app | counted | counted | counted |
| A signed-out visitor through the policy's app (anonymous token) | counted by `ip` / `all` | counted by `ip` / `all` | counted by `ip` / `all` |
| Anyone else through another app | counted | not counted | not counted |
| Anyone else with no app (account token, session) | counted | not counted | not counted |

Your own calls also use up none of an `all` counter your visitors share. simple_db and simple_email refuse a non-owner who does not come through the app anyway, so on those hosts every non-owner they serve is counted.

## Limits

| Limit | Value |
|---|---|
| Active policies per owner | 50 by default (`409 policy_limit_reached`); disabled policies do not count |
| Active policies per app on a shared host | 200 by default for one app on `simple-db.` or `simple-email.pilely.app`, counting only that app's policies — other owners' policies never use up your room (`409 host_policy_limit_reached`) |
| Active policies per own app host | 200 by default on one `<label>.pilely.app` (`409 host_policy_limit_reached`) |
| `name` | 1–120 characters |
| `method` | one of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, uppercase |
| Page size | `limit` 1–200, default 50 (outside that range is a `400`) |
| `fixed_window.window_secs` | one of `1`, `10`, `60`, `3600`, `86400`, `604800` (7 days), `2592000` (30 days) |
| `fixed_window.limit`, `token_bucket.burst` | a positive integer up to 4294967295 |
| `token_bucket.rate_per_sec` | a positive finite number |
| Active challenge policies per owner | 50 by default (`409 policy_limit_reached`), counted over challenge policies alone — rate-limit policies never use up this room, nor the reverse |
| Active challenge policies per app on a shared host, and per own app host | 200 by default, counted the same way as rate-limit policies but over challenge policies alone (`409 host_policy_limit_reached`) |
| A challenge | answerable for 5 minutes after `POST /challenge` issues it, and by one call only |
| Challenge difficulty | one platform value: the secret number is between 0 and the challenge's `maxnumber` (100000) |

## Errors

JSON errors are `{"ok": false, "code": "…", "reason": "…"}`; markdown errors are a `Request failed (<status>)` page with a short reason, carrying the same status. Codes shared by every route:

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | a malformed JSON body; the route lists its own field rules |
| 401 | `bad_forwarded_identity` | the forwarded identity headers reached this service missing or malformed (always JSON) |
| 404 | `not_found` | the caller may not call this route on this policy or app — the same answer an unknown policy gets |
| 500 | `internal_error` | the operation failed — retry in a few seconds |

Route-specific codes: `unsupported_host`, `unsupported_path`, `target_not_owned` (`400`) and `policy_limit_reached`, `host_policy_limit_reached` (`409`) on `policies/create` and `challenge_policies/create`; `not_found` (`404`) and `already_disabled` (`409`) on `policies/{policy_id}/disable` and `challenge_policies/{policy_id}/disable`.

What root answers on a call a challenge policy matches (`403 challenge_required`, `403 challenge_failed`) is in `### When a challenge policy refuses a request` under `## Notes`.

A `GET` (or any method other than `POST`) on a route below, and any unknown path, answers an empty-body `404`.

## Response format

Make these calls with an HTTP client (`curl` or your runtime's equivalent), never a browser. Every route answers `POST` only. Markdown comes back by default; send `Accept: application/json` for JSON. Both forms carry the same status code.

A policy's JSON success body is FLAT — the policy object itself, no wrapper key and no `ok` field:

```json
{ "id": "aB3dEf9k", "name": "public image downloads", "host": "simple-db.pilely.app",
  "app_id": "<app uuid>", "method": "POST", "path": "/apps/<app uuid>/tables/posts/records/*/files/image/download", "key": "ip",
  "strategy": {"type": "fixed_window", "limit": 300, "window_secs": 60},
  "created_at": 1734112233445, "disabled_at": null }
```

`id` is the 8-character public id — the only identity a policy ever carries; the internal uuid never leaves this service. `app_id` is the policy's app: the one it was created with on `simple-email`, the path's `<app_id>` on `simple-db` (for every simple_db policy, however old), and `null` on your own app host, whose host names the app. `created_at` and `disabled_at` are epoch ms; `disabled_at` is `null` while the policy is active. The markdown form of a policy is a `Policy <id>` page listing the same fields.

A challenge policy's JSON success body is FLAT the same way, with the same fields minus `key` and `strategy`:

```json
{ "id": "cH4dEf9k", "name": "signup form bot check", "host": "simple-db.pilely.app",
  "app_id": "<app uuid>", "method": "POST", "path": "/apps/<app uuid>/tables/signups/records/create",
  "created_at": 1734112233445, "disabled_at": null }
```

Its markdown form is a `Challenge policy <id>` page listing the same fields.

## Routes

| Route | Purpose | Who may call |
|---|---|---|
| `POST /policies/create` | create a policy on a route you own | a person who owns the target, through this service or the target's own app |
| `POST /policies/list` | your policies, newest first, disabled included | a person, through any app (another app than this service lists only that app's policies) |
| `POST /policies/{policy_id}/disable` | disable a policy by its public id | the policy's owner, through this service or the policy's own app |
| `POST /challenge_policies/create` | require a bot check on a route you own | a person who owns the target, through this service or the target's own app |
| `POST /challenge_policies/list` | your challenge policies, newest first, disabled included | a person, through any app (another app than this service lists only that app's challenge policies) |
| `POST /challenge_policies/{policy_id}/disable` | disable a challenge policy by its public id | the policy's owner, through this service or the policy's own app |
| `POST /challenge` | a fresh bot-check challenge bound to the calling app | a person, an app's anonymous visitor, or an app's own server, through any app |

`{policy_id}` is the policy's 8-character public `id`, returned by `create` and `list` (a rate-limit policy's on `policies/*`, a challenge policy's on `challenge_policies/*`).

### POST /policies/create

Create a rate-limit **policy** on a route you own.

#### Auth

A person only, through this service itself or through the app the policy would apply to (the app your own app host serves, the `/apps/<app_id>/` app on `simple-db`, the `app_id` on `simple-email`). Any other caller or app: the uniform `404 not_found`. You must also own the target route — see `## Auth` for the three accepted `host` shapes, the `app_id` rule of each and the ownership each one checks (`400 target_not_owned`).

#### Request

JSON body. The first six keys are required; `app_id` depends on `host`:

- `name` — string, 1–120 characters, label only (trimmed).
- `host` — one of the three shapes in `## Auth`: `"<label>.pilely.app"`, `"simple-db.pilely.app"` or `"simple-email.pilely.app"`. Stored lowercased.
- `method` — one of `"GET"`, `"POST"`, `"PUT"`, `"PATCH"`, `"DELETE"`, uppercase. Anything else is `400 bad_request` ("method must be one of GET, POST, PUT, PATCH, DELETE"). simple_db and simple_email routes answer only `POST`, so a policy with another method on those hosts never matches.
- `path` — absolute path matched against the pile-local path root forwards (the path the target pile itself sees). `*` matches one segment, `**` matches the rest (one or more trailing segments) and is only allowed as the last segment. On `simple-db.pilely.app` the path must start `/apps/<app_id>/` with a literal app uuid — a wildcard id is refused.
- `key` — `"user"` | `"ip"` | `"all"`: what the counter is bucketed by.
  - `user` never counts an anonymous caller; pair it with an `ip` policy to cover those.
  - `ip` counts every caller by client IP, signed in or not.
  - `all` is one shared counter for the whole route.
  - You, the app's owner, are never counted, on every `key` and every host — see `### Who is counted`.
- `strategy` — one of:
  - `{"type": "fixed_window", "limit": <positive int>, "window_secs": one of 1, 10, 60, 3600, 86400, 604800, 2592000}`
  - `{"type": "token_bucket", "rate_per_sec": <positive number>, "burst": <positive int>}`
- `app_id` — string, the app's uuid. Required on `simple-email`; optional on `simple-db` (must equal the path's `<app_id>`); absent or `null` on your own app host.

#### Example

```bash
curl -X POST https://simple-limiter.pilely.app/policies/create \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "inquiry form",
    "host": "simple-db.pilely.app",
    "method": "POST",
    "path": "/apps/<pile_id>/tables/inquiries/records/create",
    "key": "user",
    "strategy": {"type": "fixed_window", "limit": 5, "window_secs": 60}
  }'
```

#### Response

`201`. JSON is the FLAT policy object, no wrapper key (shape in `## Response format`), with `disabled_at: null`. The markdown form is the `Policy <id>` page. The policy is live for root's enforcer before this answer returns.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | missing/malformed field, a `method` outside the five, a strategy number/window outside its documented range, `limit`/`burst` too large to encode, a `**` used anywhere but the last path segment, or an `app_id` that breaks its host's rule: "app_id is required for simple-email policies", "app_id must match the /apps/<app_id>/ segment of path", "app_id applies only to simple-db and simple-email policies" |
| 400 | `unsupported_host` | `host` is none of the three accepted shapes |
| 400 | `unsupported_path` | `simple-db.pilely.app` with a `path` not shaped `/apps/<app_id>/**` |
| 400 | `target_not_owned` | the target app does not exist or you do not own it |
| 409 | `policy_limit_reached` | you already have the maximum active policies allowed per owner (50 by default) |
| 409 | `host_policy_limit_reached` | the target already has 200 active policies: this app's policies on a shared host, or every policy on your own app host; disable one first |
| 500 | `internal_error` | creation failed — retry in a few seconds |

#### When to use

Use when the user wants to cap how often a route on their own app, a simple_db table their app uses (its records and its files), or their app's simple_email template sends may be called.

### POST /policies/list

List YOUR policies, newest first. Disabled rows are included, with their `disabled_at` set.

#### Auth

A person only, through any app; a backend app, an agent or an anonymous visitor gets the uniform `404 not_found`. Lists only the caller's own policies — through this service, all of them; through another app, only that app's policies (every page and its `next_cursor` name only that app's policies).

#### Request

JSON body, all keys optional:

- `limit` — 1–200, default 50.
- `after_created_at` / `after_id` — keyset cursor: pass BOTH, exactly as returned in a previous page's `next_cursor`, to fetch the next page. Omit both for the first page.

#### Example

```bash
curl -X POST https://simple-limiter.pilely.app/policies/list \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"limit": 50}'
```

#### Response

`200`:

```json
{ "policies": [ { "id": "aB3dEf9k", "name": "…", "host": "…", "app_id": null, "method": "GET",
                   "path": "…", "key": "user",
                   "strategy": {"type": "fixed_window", "limit": 5, "window_secs": 60},
                   "created_at": 1734112233445, "disabled_at": null } ],
  "next_cursor": { "after_created_at": 1734112233445, "after_id": "aB3dEf9k" } }
```

`next_cursor` is `null` when there is no next page.

Every policy carries its `app_id` (see `## Response format`). The markdown form is a `Your policies (<count>)` page with one line per policy (`id`, method, host, path, key, `app_id` when it has one, `disabled_at`). It carries the same cursor as an actionable line — "More pages remain — repeat with `after_created_at` = … and `after_id` = `…`." — when there IS a next page; nothing prints when there isn't.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | `limit` outside 1–200, or a half-supplied cursor (`after_created_at` and `after_id` must be sent together or not at all) |
| 500 | `internal_error` | listing failed — retry in a few seconds |

#### When to use

The user wants to see which limits are in place on their routes, or needs a policy's `id` to disable it.

### POST /policies/{policy_id}/disable

Disable a policy by its public id. The policy stops matching immediately. There is no re-enable — create a new one instead.

#### Auth

A person only, the policy's owner, through this service itself or through the policy's own app. Unknown id, "not the owner", another app, and any caller that is not a person are the same uniform `404`.

#### Request

JSON body: `{}` — no fields.

#### Example

```bash
curl -X POST https://simple-limiter.pilely.app/policies/aB3dEf9k/disable \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -d '{}'
```

#### Response

`200`. JSON is the FLAT policy object (same shape as `policies/create`), with `disabled_at` now set. The markdown form is the `Policy <id>` page.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | malformed JSON body |
| 404 | `not_found` | unknown id, not this caller's, or not this caller's app |
| 409 | `already_disabled` | this policy was already disabled |
| 500 | `internal_error` | disable failed — retry in a few seconds |

#### When to use

The user wants a limit lifted, or wants to change one: disable it, then create its replacement.

### POST /challenge_policies/create

Create a **challenge policy**: root refuses every call that matches it unless the call carries a solved bot check in `X-Pilely-Challenge`.

#### Auth

Exactly as `policies/create`: a person only, through this service itself or through the app the policy would apply to; any other caller or app gets the uniform `404 not_found`. You must own the target — the three `host` shapes, their `app_id` rules and the ownership each checks are in `## Auth` (`400 target_not_owned`).

#### Request

JSON body. The first four keys are required; `app_id` depends on `host`:

- `name` — string, 1–120 characters, label only (trimmed).
- `host` — `"<label>.pilely.app"`, `"simple-db.pilely.app"` or `"simple-email.pilely.app"`. Stored lowercased.
- `method` — one of `"GET"`, `"POST"`, `"PUT"`, `"PATCH"`, `"DELETE"`, uppercase.
- `path` — absolute path glob on the pile-local path (`*` one segment, `**` the rest, last segment only). On `simple-db.pilely.app` it must start `/apps/<app_id>/` with a literal app uuid.
- `app_id` — string, the app's uuid. Required on `simple-email`; optional on `simple-db` (must equal the path's `<app_id>`); absent or `null` on your own app host.

There is no `key` and no `strategy`: every call that matches is challenged, whoever makes it — you, your app's server, a signed-in user or a signed-out visitor.

#### Example

```bash
curl -X POST https://simple-limiter.pilely.app/challenge_policies/create \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "signup form bot check",
    "host": "simple-db.pilely.app",
    "method": "POST",
    "path": "/apps/<pile_id>/tables/signups/records/create"
  }'
```

#### Response

`201`. JSON is the FLAT challenge policy object (shape in `## Response format`), with `disabled_at: null`. The markdown form is the `Challenge policy <id>` page. Root challenges matching calls before this answer returns.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | missing/malformed field, a `method` outside the five, a `**` used anywhere but the last path segment, or an `app_id` that breaks its host's rule (the same reasons as `policies/create`) |
| 400 | `unsupported_host` | `host` is none of the three accepted shapes |
| 400 | `unsupported_path` | `simple-db.pilely.app` with a `path` not shaped `/apps/<app_id>/**` |
| 400 | `target_not_owned` | the target app does not exist or you do not own it |
| 409 | `policy_limit_reached` | you already have the maximum active challenge policies allowed per owner (50 by default) |
| 409 | `host_policy_limit_reached` | the target already has 200 active challenge policies: this app's on a shared host, or every one on your own app host; disable one first |
| 500 | `internal_error` | creation failed — retry in a few seconds |

#### When to use

The user wants a public form, signup or contact endpoint protected from bots and spam: a simple_db record create, a simple_email template send through their app, or a route on their own app host.

### POST /challenge_policies/list

List YOUR challenge policies, newest first. Disabled rows are included, with their `disabled_at` set.

#### Auth

Exactly as `policies/list`: a person only, through any app; a backend app, an agent or an anonymous visitor gets the uniform `404 not_found`. Through this service it lists all your challenge policies; through another app, only that app's.

#### Request

JSON body, all keys optional:

- `limit` — 1–200, default 50.
- `after_created_at` / `after_id` — keyset cursor: pass BOTH, exactly as returned in a previous page's `next_cursor`. Omit both for the first page.

#### Example

```bash
curl -X POST https://simple-limiter.pilely.app/challenge_policies/list \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"limit": 50}'
```

#### Response

`200`:

```json
{ "policies": [ { "id": "cH4dEf9k", "name": "…", "host": "…", "app_id": "<app uuid>", "method": "POST",
                   "path": "…", "created_at": 1734112233445, "disabled_at": null } ],
  "next_cursor": { "after_created_at": 1734112233445, "after_id": "cH4dEf9k" } }
```

`next_cursor` is `null` when there is no next page. The markdown form is a `Your challenge policies (<count>)` page with one line per policy (`id`, method, host, path, `app_id` when it has one, `disabled_at`) and the same cursor as an actionable "More pages remain" line when there is a next page.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | `limit` outside 1–200, or a half-supplied cursor |
| 500 | `internal_error` | listing failed — retry in a few seconds |

#### When to use

The user wants to see which routes require a bot check, or needs a challenge policy's `id` to disable it.

### POST /challenge_policies/{policy_id}/disable

Disable a challenge policy by its public id. Root stops challenging the route before this answer returns. There is no re-enable — create a new one instead.

#### Auth

Exactly as `policies/{policy_id}/disable`: a person only, the policy's owner, through this service itself or through the policy's own app. Unknown id, "not the owner", another app, and any caller that is not a person are the same uniform `404`.

#### Request

JSON body: `{}` — no fields.

#### Example

```bash
curl -X POST https://simple-limiter.pilely.app/challenge_policies/cH4dEf9k/disable \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -d '{}'
```

#### Response

`200`. JSON is the FLAT challenge policy object, with `disabled_at` now set. The markdown form is the `Challenge policy <id>` page.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | malformed JSON body |
| 404 | `not_found` | unknown id, not this caller's, or not this caller's app |
| 409 | `already_disabled` | this challenge policy was already disabled |
| 500 | `internal_error` | disable failed — retry in a few seconds |

#### When to use

The user wants a bot check lifted, or wants to change one: disable it, then create its replacement.

### POST /challenge

Get a fresh bot-check challenge, bound to the app this call comes through. Nothing is stored: the challenge carries its own expiry and app, signed.

#### Auth

Not person-only: a signed-in person, an app's signed-out visitor (its anonymous token) and an app's own server (its backend token) may call it, through any app — see `### Who may get a challenge` under `## Auth`. An agent gets the uniform `404 not_found`.

#### Request

An empty body, or a JSON object (its fields are ignored).

#### Example

```bash
curl -X POST https://simple-limiter.pilely.app/challenge \
  -H "Authorization: Bearer <the page's token for app A>" \
  -H "Accept: application/json"
```

#### Response

`200`. JSON is the ALTCHA challenge exactly as issued:

```json
{ "algorithm": "SHA-256", "challenge": "<64 hex>", "maxnumber": 100000,
  "salt": "<24 hex>?expires=<unix seconds>&app=<app uuid>&", "signature": "<64 hex>" }
```

`salt` is random hex, then `?`, then the `expires=` and `app=` params in either order, each followed by `&` (`<24 hex>?app=<app uuid>&expires=<unix seconds>&` is just as valid). Treat it as an opaque string: hash it and send it back exactly as issued, and never read a param by its position.

To solve it, find the `number` from 0 to `maxnumber` for which the lowercase hex SHA-256 of `salt` followed by `number` (in decimal) equals `challenge`. The answer is standard base64 of the JSON `{"algorithm", "challenge", "number", "salt", "signature"}` — the four strings exactly as issued, plus the number — sent in the `X-Pilely-Challenge` request header of one call made through the same app within 5 minutes (`### When a challenge policy refuses a request`). In a browser, `@pilely/simple-limiter`'s `getChallenge()` and `solveChallenge()` do both steps. The markdown form is a `Challenge for app <id>` page carrying the same JSON.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | a body that is neither empty nor a JSON object |
| 500 | `internal_error` | the challenge could not be created — retry |

#### When to use

Just before your app makes a call a challenge policy covers (for example when the visitor submits the form): get one challenge, solve it, and send the answer with that one call. Every further challenged call needs a new challenge.

## Notes

### Other GETs on this host

- `GET /README.md` — this page; public, the same bytes for every caller and every `Accept`.
- `GET /` — `302` to `/README.md`.
- `GET /healthz` — liveness probe, plain `ok`.

### When a policy refuses a request

A request refused by a limiter policy gets `429 rate_limited` ("too many requests — retry later", always the JSON error envelope) with a `Retry-After` header in seconds, answered by root before the request reaches the service or the app. Every active policy whose `method` and `path` match the request, and that counts the caller (`### Who is counted`), counts it, and the request is refused when any one of them is over. A gated app still answers its `404` before any `429`. The `/~/mint/anon` per-IP limiter also answers `429 rate_limited` but carries no `Retry-After`. An owner has up to 50 active policies, and one app has up to 200 on each shared host.

### When a challenge policy refuses a request

Root checks every call to an app host — `<label>.pilely.app`, a custom domain bound to the app, `simple-db.` and `simple-email.pilely.app` — against the challenge policies of that host (on a shared host, only the policies of the app the call's token came through; a call through no app matches none). It runs after the app's own access gate (a gated app still answers its `404` or `401` first) and before the rate-limit counters, and it never calls this service.

| Root answers | When |
|---|---|
| the call goes on | no active challenge policy matches its method and path — an `X-Pilely-Challenge` sent anyway is ignored and not used up |
| `403 challenge_required` | a policy matches and the call carries no `X-Pilely-Challenge` |
| `403 challenge_failed` | a policy matches and the answer is malformed, its hash or signature is wrong, it has expired (5 minutes after `POST /challenge`), it was started through another app, it was already used by an earlier call, or its use could not be recorded |

Both refusals use root's error envelope `{"ok": false, "code": "challenge_required" | "challenge_failed", "reason": "…"}`. Several matching policies need one answer. A call refused here is never counted by a rate limit, so unsolved calls use up none of an `ip` or `all` policy on the same route. Getting a new challenge and solving it starts over. Root removes `X-Pilely-Challenge` before forwarding, so neither the service nor your backend ever sees it; browsers may send it cross-origin to app hosts.

### How a window counts

A `fixed_window` opens with the first request it counts for a subject (one user, one IP, or the whole route for `all`) and lasts `window_secs`; that subject's count resets when it ends. A 7-day or 30-day window is therefore not a calendar week or month and never resets on the 1st: a 30-day per-user policy caps each user's calls in the 30 days from their first counted call. Refused requests count too. Disabling a policy and creating its replacement starts every count from zero.

### Methods

A policy matches the request's method exactly. Root forwards `PUT`, `PATCH` and `DELETE` to a backend app like any other method, and the policy counts them; a browser page on another origin can only send `GET` and `POST` to an app host (cross-origin preflights allow `GET`, `POST`, `OPTIONS`), while the app's own page, servers, agents and `curl` can send all five. simple_db and simple_email routes answer only `POST`.

### Limiting a simple_db table

There is no per-table or per-column limit setting on simple_db; a table limit is a policy with `host: "simple-db.pilely.app"` and a `path` under that table's routes. Every record route (`create`, `list`, `get`, `update`, `delete`, e.g. `/apps/<app_id>/tables/<table>/records/create` or `/apps/<app_id>/tables/<table>/records/*/get`) is a normal forwarded `POST` and matches like any other, and so are the table's file routes (`/apps/<app_id>/tables/<table>/files/<column>/upload/start`, `/apps/<app_id>/tables/<table>/records/*/files/<column>/download`). On that host the `path` must start with `/apps/<app_id>/` with a literal uuid (a wildcard id is refused, `400 unsupported_path`) naming an app the caller owns (otherwise `400 target_not_owned`). The policy counts only calls made through that app, and never you.

### Limiting simple_email sends

A simple_email policy names its app in `app_id` and targets the template send, `POST /accounts/<app_id>/templates/<template_id>/send` — the send a page of the app makes for its signed-in users and signed-out visitors (`https://simple-email.pilely.app/README.md`). Use `*` in place of the template id to cover every template of the account. Key it `ip` (or `all`) to cap signed-out visitors, `user` to cap signed-in users:

```json
{ "name": "contact form sends per visitor", "host": "simple-email.pilely.app", "app_id": "<app uuid>",
  "method": "POST", "path": "/accounts/<app uuid>/templates/*/send", "key": "ip",
  "strategy": {"type": "fixed_window", "limit": 5, "window_secs": 3600} }
```

It counts every template send made through the app, you and your app's backend server excepted (`### Who is counted`). A policy on the freestyle send, `POST /accounts/<app_id>/send`, counts nothing: only your app's backend server can make it, and the backend is never counted. Its frequency is your backend's own code, under simple_email's freestyle cap of 500 recipients per app per UTC day. The other account and template routes (`/accounts/...`) are owner-only, so a policy there never counts anyone either. Every send is billed to you, the app's owner, whoever made it.

### Policies take effect within one Redis round trip

On every `create` and `disable`, this service recomputes the active policy list the policy sits in and writes it in the same request, before answering: `pilely:simple_limiter:policies:<host>` for your own app host (24-hour TTL), `pilely:simple_limiter:policies:<host>:app:<app_id>` for one app on a shared host (300-second TTL). Root's enforcer reads that key. A failed refresh deletes the key rather than leaving a stale list, and the TTL is only a backstop. There is no propagation delay.

Challenge policies work the same way under their own keys: `pilely:simple_limiter:challenge_policies:<host>` (24-hour TTL) and `pilely:simple_limiter:challenge_policies:<host>:app:<app_id>` (300-second TTL). An answer root accepts is marked used under `pilely:challenge_used:<signature>` until it expires.