# Lunar API Reference

Complete reference for every endpoint exposed by `lunar-api-v3`. Every example below mirrors the actual request and response shapes produced by the current codebase.

- Production base URL: `https://api.lunargroup.dev`
- Local base URL: `http://localhost:3000`
- Auth scheme: `Authorization: Bearer <accessToken>`
- Content type for JSON requests: `application/json`

> Both `/auth/login` and `/auth/register` return `accessToken` with the literal `Bearer ` prefix already attached. Use the value as the `Authorization` header verbatim.

## Table of contents

1. [Conventions](#conventions)
2. [Roles and permissions](#roles-and-permissions)
3. [Rate limits](#rate-limits)
4. [Error shapes](#error-shapes)
5. [Base routes](#base-routes)
6. [Auth routes](#auth-routes)
7. [Admin routes](#admin-routes)
8. [Admin S3 routes](#admin-s3-routes)
9. [Image routes](#image-routes)
10. [Image proxy route](#image-proxy-route)

## Conventions

- Usernames must match `^[a-zA-Z0-9_.-]+$` with length 3 to 32.
- Bodies are JSON unless a route documents a different content type (the S3 upload route accepts raw image bytes).
- Auth-protected routes return `401` when the token is missing, malformed, or revoked, and `403` when the user is banned or lacks role priority.
- Successful responses include a numeric `status` field that mirrors the HTTP status code, with a few exceptions noted inline.

## Roles and permissions

| Role | Priority | Notes |
|------|----------|-------|
| `user` | 0 | Default for new accounts. Can call authenticated routes such as `/images/*`. |
| `support` | 1 | Adds access to `GET /admin/user/search`. |
| `moderator` | 2 | Same as support. |
| `admin` | 3 | Adds access to ban, list users, edit role, edit username, and all `/admin/s3/*` routes. |
| `owner` | 4 | Same as admin. |

Permission helpers in `src/controllers/admin.controller.ts` use priority comparisons:

- `canBan` requires the actor priority to be strictly greater than the target priority.
- `canChangeRole` requires the actor priority to exceed both the target priority and the priority of the new role, and refuses admin-on-admin / owner-on-owner edits.
- `canChangeUsername` mirrors `canBan` but with the same admin-on-admin / owner-on-owner refusal.

## Rate limits

Limits live in `src/app.ts` and `src/routes/index.ts` and per-route files. They are in-memory only, so they reset on restart. Username `fops` bypasses every limit that opts in via `shouldBypassRateLimit`.

| Scope | Window | Limit | Notes |
|-------|--------|-------|-------|
| Global API | 2 minutes | 1000 | Plus slowdown after 1000 reqs (50 ms per extra req, capped at 3 s). |
| `/auth/*` group | 15 minutes | 50 | Plus slowdown after 10 reqs (500 ms per extra, capped at 5 s). |
| `/auth/register` | 1 hour | 3 | Plus slowdown after 1 req (5 s per extra, capped at 60 s). |
| `/auth/login` (IP) | 15 minutes | 8 | Successful logins do not consume the quota. |
| `/auth/login` (username) | 15 minutes | 5 | Keyed by `login:user:<username>`. Successful logins do not consume the quota. |

When a limit is exceeded the API responds with `429`:

```json
{
  "status": 429,
  "message": "Too many requests from this IP, please try again later."
}
```

Headers `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and (on rejection) `Retry-After` are set on every limited response.

## Error shapes

The API currently emits three distinct error envelopes. Treat all of them as possible when calling any endpoint.

```json
{ "error": "..." }
```

Used by `src/controllers/auth.controller.ts` for input validation, `Invalid credentials`, `User is banned`, `Username already exists`, etc.

```json
{ "status": 401, "message": "..." }
```

Used by `src/middleware/auth.ts`, the S3 admin handlers, and every admin role check.

```json
{ "message": "...", "stack": "..." }
```

Returned by `src/middleware/errors.ts` for unmatched routes and unhandled exceptions. `stack` is `"N/A"` in production.

## Base routes

### `GET /`

Liveness check. No auth required.

Response `200`:

```json
{
  "status": 200,
  "message": "🦊 The API is up!"
}
```

### `GET /health`

Extended liveness check with uptime and round-trip timing.

Response `200`:

```json
{
  "status": 200,
  "message": "API is up! (Duh). 🦊",
  "timestamp": "2026-03-31T19:00:00.000Z",
  "uptimeSeconds": 1234.56,
  "responseTimeMs": 2
}
```

### `GET /docs`

Returns the HTML docs page (rendered by `src/docs.ts`). Useful from a browser.

### `GET /docs.md`

Returns this Markdown reference as `text/markdown`. Designed to be piped into an LLM context window.

## Auth routes

### `POST /auth/register`

Create a user with the default `user` role and return a one-time JWT.

Request:

```json
{
  "username": "winter_fe",
  "password": "super-secret-password"
}
```

Response `201`:

```json
{
  "user": {
    "id": "user_id",
    "username": "winter_fe",
    "role": "user",
    "banned": false
  },
  "accessToken": "Bearer eyJhbGciOi..."
}
```

Errors:

```json
{ "error": "Username is required" }
{ "error": "Password is required" }
{ "error": "Unable to sanitize username. Please try again later or report this to WinterFe." }
{ "error": "Username already exists" }
{ "error": "Could not register user" }
```

### `POST /auth/login`

Validate credentials, rotate the stored access token hash, return the new bearer token.

Request:

```json
{
  "username": "winter_fe",
  "password": "super-secret-password"
}
```

Response `200`:

```json
{
  "user": {
    "id": "user_id",
    "username": "winter_fe",
    "role": "user",
    "banned": false
  },
  "accessToken": "Bearer eyJhbGciOi..."
}
```

Errors:

```json
{ "error": "Username is required" }
{ "error": "Password is required" }
{ "error": "Unable to sanitize username. Please try again later or report this to WinterFe." }
{ "error": "Invalid credentials" }
{ "error": "User is banned" }
```

> Each successful login or register invalidates the previous bearer token for that user. Old tokens fail the `accessTokenHash` check in `requireAuth` and respond with `{ "status": 401, "message": "Invalid or revoked access token" }`.

## Admin routes

All admin routes require `Authorization: Bearer <accessToken>` and pass through `requireAuth`. Routes that need elevated privileges also enforce a role allow-list, returning `403` for anything below.

### `POST /admin/ban`

Ban a user. Requires `admin` or `owner`. Cannot ban yourself or any account whose priority is greater than or equal to yours.

Request:

```json
{
  "userId": "optional-user-id",
  "username": "optional-username"
}
```

Response `200`:

```json
{
  "status": 200,
  "message": "User winter_fe has been banned",
  "user": {
    "id": "user_id",
    "username": "winter_fe",
    "role": "user",
    "banned": true
  }
}
```

Errors:

```json
{ "status": 400, "message": "userId or username is required" }
{ "status": 400, "message": "You cannot ban yourself" }
{ "status": 400, "message": "User is already banned" }
{ "status": 403, "message": "You do not have permission to ban this user" }
{ "status": 404, "message": "User not found" }
{ "status": 500, "message": "Failed to ban user" }
```

### `GET /admin/user/search`

Find a single user plus their request counters. Requires `support`, `moderator`, `admin`, or `owner`.

Query params (one is required): `userId`, `username`.

```
GET /admin/user/search?username=winter_fe
GET /admin/user/search?userId=user_id
```

Response `200`:

```json
{
  "status": 200,
  "user": {
    "id": "user_id",
    "username": "winter_fe",
    "role": "user",
    "banned": false,
    "createdAt": "2026-03-30T12:00:00.000Z",
    "updatedAt": "2026-03-31T08:15:00.000Z",
    "collection": "users"
  },
  "requests": {
    "id": "requests_id",
    "auth": 5,
    "admin": 0,
    "images": 12,
    "total": 17,
    "createdAt": "2026-03-30T12:00:00.000Z",
    "updatedAt": "2026-03-31T08:15:00.000Z",
    "collection": "requests"
  }
}
```

`requests` is `null` if the user has no row in the `requests` table yet.

Errors:

```json
{ "status": 400, "message": "userId or username is required" }
{ "status": 404, "message": "User not found" }
```

### `GET /admin/users`

List every user keyed by username, each with their counters. Requires `admin` or `owner`.

Response `200`:

```json
{
  "status": 200,
  "users": {
    "winter_fe": {
      "user": {
        "id": "user_id",
        "username": "winter_fe",
        "role": "user",
        "banned": false,
        "createdAt": "2026-03-30T12:00:00.000Z",
        "updatedAt": "2026-03-31T08:15:00.000Z",
        "collection": "users"
      },
      "requests": {
        "id": "requests_id",
        "auth": 5,
        "admin": 0,
        "images": 12,
        "total": 17,
        "createdAt": "2026-03-30T12:00:00.000Z",
        "updatedAt": "2026-03-31T08:15:00.000Z",
        "collection": "requests"
      }
    }
  }
}
```

### `GET /admin/users/dashboard`

Flat row layout plus aggregate totals, optimised for dashboard tables. Requires `admin` or `owner`.

Response `200`:

```json
{
  "status": 200,
  "summary": {
    "totalUsers": 42,
    "bannedUsers": 1,
    "totalRequests": 9001,
    "adminRequests": 120,
    "authRequests": 350,
    "imageRequests": 8531
  },
  "users": [
    {
      "id": "user_id",
      "username": "winter_fe",
      "role": "user",
      "banned": false,
      "createdAt": "2026-03-30T12:00:00.000Z",
      "updatedAt": "2026-03-31T08:15:00.000Z",
      "collection": "users",
      "requestsId": "requests_id",
      "auth": 5,
      "admin": 0,
      "images": 12,
      "total": 17,
      "requestsCreatedAt": "2026-03-30T12:00:00.000Z",
      "requestsUpdatedAt": "2026-03-31T08:15:00.000Z",
      "requestsCollection": "requests"
    }
  ]
}
```

### `POST /admin/user/edit/role`

Change a user's role. Requires `admin` or `owner`. Refuses admin-on-admin and owner-on-owner edits. Refuses to promote a user above your own priority.

Request:

```json
{
  "userId": "optional-user-id",
  "username": "optional-username",
  "role": "support"
}
```

Valid role values: `user`, `support`, `moderator`, `admin`, `owner`.

Response `200`:

```json
{
  "status": 200,
  "message": "User winter_fe has been changed to support",
  "user": {
    "id": "user_id",
    "username": "winter_fe",
    "role": "support",
    "banned": false
  }
}
```

Errors:

```json
{ "status": 400, "message": "userId or username is required" }
{ "status": 400, "message": "role is required and must be a string" }
{ "status": 400, "message": "Invalid role provided" }
{ "status": 400, "message": "You cannot change your own role" }
{ "status": 403, "message": "You do not have permission to change this user's role" }
{ "status": 404, "message": "User not found" }
{ "status": 500, "message": "Failed to update user role" }
```

### `POST /admin/user/edit/username`

Change another user's username. Requires `admin` or `owner`. Same admin-on-admin / owner-on-owner restrictions as role edits.

Request:

```json
{
  "userId": "optional-user-id",
  "username": "optional-current-username",
  "newUsername": "new_name"
}
```

Response `200`:

```json
{
  "status": 200,
  "message": "User username has been changed to new_name",
  "user": {
    "id": "user_id",
    "username": "new_name",
    "role": "user",
    "banned": false
  }
}
```

Errors:

```json
{ "status": 400, "message": "userId or username is required" }
{ "status": 400, "message": "newUsername is required" }
{ "status": 400, "message": "Invalid username format. Must be 3-32 characters with only alphanumeric, underscore, hyphen, or dot" }
{ "status": 400, "message": "You cannot change your own username" }
{ "status": 403, "message": "You do not have permission to change this user's username" }
{ "status": 404, "message": "User not found" }
{ "status": 409, "message": "Username already exists" }
{ "status": 500, "message": "Failed to update user username" }
```

## Admin S3 routes

All `/admin/s3/*` routes require `admin` or `owner`.

### `GET /admin/s3/test`

Smoke test. With no body it just confirms the route is wired. If the body contains `bucketExists`, the API calls MinIO `bucketExists` for that name.

Request body (optional):

```json
{ "bucketExists": "lunar-api" }
```

Response `200` (default):

```json
{ "status": 200, "message": "S3 test endpoint is working!" }
```

Response `200` (with `bucketExists` provided):

```json
{ "status": 200, "bucketExists": true }
```

### `POST /admin/s3/upload`

Raw image upload. The body must be image bytes and `Content-Type` must start with `image/`. The bucket and object name are passed as query params.

```
POST /admin/s3/upload?bucketName=lunar-api&objectName=fox/example.png
Content-Type: image/png
Authorization: Bearer <token>

<binary image bytes>
```

Response `200`:

```json
{
  "status": 200,
  "message": "Image uploaded successfully",
  "bucketName": "lunar-api",
  "objectName": "fox/example.png",
  "etag": "etag-value",
  "versionId": "version-id"
}
```

Errors:

```json
{ "status": 400, "message": "bucketName, objectName and path query params are required" }
{ "status": 400, "message": "Content-Type must be an image/* mime type" }
{ "status": 400, "message": "Request body must contain image bytes" }
```

### `DELETE /admin/s3/delete`

Delete by `objectName` (preferred) or `etag`.

Request:

```json
{
  "bucketName": "lunar-api",
  "objectName": "fox/example.png"
}
```

or

```json
{
  "bucketName": "lunar-api",
  "etag": "etag-value"
}
```

Response `200`:

```json
{
  "status": 200,
  "message": "Object deleted successfully",
  "bucketName": "lunar-api",
  "object": "fox/example.png"
}
```

When the object is identified by etag, `object` is the string `object with etag <etag>`.

Errors:

```json
{ "status": 400, "message": "bucketName and either objectName or etag are required" }
{ "status": 500, "message": "Error deleting object" }
```

## Image routes

All `/images/*` routes require authentication. Each returns a random object from the matching MinIO prefix and adds a `url` that points to the authenticated proxy.

| Route | Bucket prefix |
|-------|---------------|
| `GET /images/fox/random` | `fox/` |
| `GET /images/bird/random` | `bird/` |
| `GET /images/shibe/random` | `shibe/` |
| `GET /images/weiner-dog/random` | `weiner-dog/` |
| `GET /images/slap/random` | `slap/` |
| `GET /images/hug/random` | `hug/` |
| `GET /images/kiss/random` | `kiss/` |
| `GET /images/smug/random` | `smug/` |
| `GET /images/pat/random` | `pat/` |

Response `200`:

```json
{
  "name": "fox/example.png",
  "etag": "etag-value",
  "size": 123456,
  "lastModified": "2026-03-31T18:00:00.000Z",
  "url": "https://api.lunargroup.dev/i/fox/example.png"
}
```

In development `url` points to `http://localhost:3000/i/<folder>/<filename>`.

Errors:

```json
{ "status": 500, "message": "Fox image name missing" }
```

The internal middleware throws when the bucket contains no images for that prefix; the central error handler renders the failure as `{ "message": "No fox images found in bucket", "stack": "..." }`.

## Image proxy route

### `GET /i/:folder/:filename`

Streams a single image through the API. Allowed folders: `fox`, `bird`, `shibe`, `weiner-dog`, `slap`, `hug`, `kiss`, `pat`, `smug`. Filenames must match `^[a-zA-Z0-9._-]+$` and cannot contain `..` or `/`.

Successful responses set `Content-Type` from the upstream object (falling back to `image/jpeg`) and pipe the bytes through. A failed lookup responds with `{ "status": 404, "message": "Image not found" }`.

The route is registered directly on `app` in `src/app.ts`, so it inherits the global rate limit but is otherwise public. If you want it gated by `requireAuth`, add the middleware there.

## Quick curl recipes

Register, then immediately use the returned token:

```bash
TOKEN=$(curl -s -X POST http://localhost:3000/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"username":"winter_fe","password":"correct horse battery staple"}' \
  | jq -r .accessToken)

curl -s http://localhost:3000/admin/users -H "Authorization: $TOKEN"
```

Login and refresh the token:

```bash
curl -s -X POST http://localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"winter_fe","password":"correct horse battery staple"}' \
  | jq -r .accessToken
```

Fetch a random fox:

```bash
curl -s http://localhost:3000/images/fox/random \
  -H "Authorization: Bearer $JWT"
```
